Skip to content

Commit df0841b

Browse files
Merge pull request #288 from dropbox/json-api-error-codes
Add Dropbox API error classification and contract audits
2 parents 247e8d6 + 006cf97 commit df0841b

8 files changed

Lines changed: 1916 additions & 20 deletions

File tree

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,33 @@ $ dbxcli rm --output=json /old-file.txt
165165
$ dbxcli restore --output=json /Reports/old.pdf 015f...
166166
```
167167

168-
Structured success output is rolling out command by command. Currently migrated commands are `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `team info`, `team list-members`, `team list-groups`, `team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`.
168+
Structured success output is rolling out command by command. Currently migrated commands are `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `share list link`, `team info`, `team list-members`, `team list-groups`, `team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`.
169169

170170
Command results and JSON errors are written to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs are written to stderr. JSON errors include a `warnings` array for machine-actionable warnings; it is `[]` when no warnings are present. Successful JSON payloads use the same `warnings` field.
171171
Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for symlinks skipped by recursive upload.
172172

173+
Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only.
174+
175+
JSON error responses use stable `error.code` values:
176+
177+
| Code | Meaning |
178+
|---------------------------------|-----------------------------------------------------------------------------------|
179+
| `invalid_arguments` | The command arguments or flags are invalid. |
180+
| `path_conflict` | A local or Dropbox path conflicts with the requested operation. |
181+
| `auth_required` | No usable saved credentials were found, or Dropbox rejected the saved token. |
182+
| `auth_refresh_failed` | Saved refreshable credentials could not be refreshed. |
183+
| `app_key_required` | Login or token refresh needs a Dropbox app key. |
184+
| `auth_exchange_failed` | The OAuth authorization-code exchange failed or returned unusable tokens. |
185+
| `not_found` | Dropbox reported that the requested object was not found. |
186+
| `permission_denied` | Dropbox denied access because of permissions, scope, member selection, or state. |
187+
| `rate_limited` | Dropbox rate limited the request. |
188+
| `dropbox_api_error` | Dropbox returned an API error that does not map to a more specific code yet. |
189+
| `structured_output_unsupported` | The command does not support `--output=json` yet. |
190+
| `unsupported_output_format` | `--output` was not `text` or `json`. |
191+
| `unknown_command` | Cobra could not resolve the command. |
192+
| `unknown_flag` | Cobra could not resolve a flag. |
193+
| `command_failed` | Fallback for failures without a more specific stable code. |
194+
173195
Successful JSON responses for migrated commands return an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. For commands such as `mkdir`, each result reports what happened to the requested path:
174196

175197
```json
@@ -466,7 +488,7 @@ In JSON mode, command errors are written to stdout as JSON, including errors fro
466488
}
467489
```
468490

469-
Error `code` values are stable identifiers intended for scripts. Current codes are `structured_output_unsupported`, `unsupported_output_format`, `unknown_command`, `unknown_flag`, `path_conflict`, `invalid_arguments`, and `command_failed`.
491+
Error `code` values are stable identifiers intended for scripts. The current stable codes are listed in the table above.
470492

471493
### Authentication
472494

cmd/auth.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package cmd
1717
import (
1818
"context"
1919
"encoding/json"
20-
"errors"
2120
"fmt"
2221
"os"
2322
"path/filepath"
@@ -239,7 +238,10 @@ func getAccessToken(tokType string, domain string, force bool) (string, string,
239238
if !force && credential.shouldRefresh(time.Now()) {
240239
credential, err = refreshStoredCredential(tokType, domain, credential)
241240
if err != nil {
242-
return "", "", fmt.Errorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType))
241+
if jsonErrorCode(err) == jsonErrorCodeAppKeyRequired {
242+
return "", "", appKeyRequiredErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType))
243+
}
244+
return "", "", authRefreshFailedErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType))
243245
}
244246
tokens[tokType] = credential
245247
if err = writeTokens(filePath, tokenMap); err != nil {
@@ -262,7 +264,7 @@ func loginCommand(tokType string) string {
262264
}
263265

264266
func missingAccessTokenError(tokType string) error {
265-
return fmt.Errorf("no saved Dropbox credentials; run %q first or set %s", loginCommand(tokType), envAccessToken)
267+
return authRequiredErrorf("no saved Dropbox credentials; run %q first or set %s", loginCommand(tokType), envAccessToken)
266268
}
267269

268270
func appCredentialsName(tokType string) string {
@@ -287,7 +289,7 @@ func ensureOAuthAppCredentials(tokType string) error {
287289
}
288290
creds.Key = strings.TrimSpace(creds.Key)
289291
if creds.Key == "" {
290-
return errors.New("Dropbox app key is required")
292+
return appKeyRequiredError("Dropbox app key is required")
291293
}
292294

293295
setOAuthCredentials(tokType, creds.Key)
@@ -325,13 +327,13 @@ func requestAccessCredential(tokType string, domain string) (storedCredential, e
325327
}
326328
token, err := exchangeAuthorizationCode(context.Background(), conf, code, verifier)
327329
if err != nil {
328-
return storedCredential{}, err
330+
return storedCredential{}, authExchangeFailedErrorf("exchange authorization code: %w", err)
329331
}
330332
if token == nil || token.AccessToken == "" {
331-
return storedCredential{}, errors.New("authorization did not return an access token")
333+
return storedCredential{}, authExchangeFailedError("authorization did not return an access token")
332334
}
333335
if token.RefreshToken == "" {
334-
return storedCredential{}, errors.New("authorization did not return a refresh token")
336+
return storedCredential{}, authExchangeFailedError("authorization did not return a refresh token")
335337
}
336338
return storedCredentialFromOAuthToken(token, conf.ClientID), nil
337339
}
@@ -342,15 +344,15 @@ func refreshStoredCredential(tokType string, domain string, credential storedCre
342344
appKey = oauthCredentials(tokType)
343345
}
344346
if strings.TrimSpace(appKey) == "" {
345-
return storedCredential{}, errors.New("saved credentials cannot be refreshed without a Dropbox app key")
347+
return storedCredential{}, appKeyRequiredError("saved credentials cannot be refreshed without a Dropbox app key")
346348
}
347349

348350
token, err := refreshOAuthToken(context.Background(), oauthConfigWithAppKey(appKey, domain), credential.oauthToken())
349351
if err != nil {
350352
return storedCredential{}, err
351353
}
352354
if token == nil || token.AccessToken == "" {
353-
return storedCredential{}, errors.New("token refresh did not return an access token")
355+
return storedCredential{}, authRefreshFailedErrorf("token refresh did not return an access token")
354356
}
355357

356358
refreshed := storedCredentialFromOAuthToken(token, appKey)

cmd/auth_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,9 @@ func TestGetAccessTokenRefreshFailureLeavesAuthFileUnchanged(t *testing.T) {
371371
if err == nil {
372372
t.Fatal("expected refresh failure")
373373
}
374+
if got, want := jsonErrorCode(err), jsonErrorCodeAuthRefreshFailed; got != want {
375+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
376+
}
374377
if !strings.Contains(err.Error(), "dbxcli login") {
375378
t.Fatalf("expected login hint, got %q", err)
376379
}
@@ -384,6 +387,43 @@ func TestGetAccessTokenRefreshFailureLeavesAuthFileUnchanged(t *testing.T) {
384387
}
385388
}
386389

390+
func TestGetAccessTokenRefreshWithoutAppKeyReturnsAppKeyRequired(t *testing.T) {
391+
expired := time.Now().Add(-time.Hour).UTC()
392+
authFile := filepath.Join(t.TempDir(), "auth.json")
393+
tokens := TokenMap{
394+
"": {
395+
tokenPersonal: {
396+
AccessToken: "old-access",
397+
RefreshToken: "old-refresh",
398+
TokenType: "Bearer",
399+
Expiry: &expired,
400+
},
401+
},
402+
}
403+
if err := writeTokens(authFile, tokens); err != nil {
404+
t.Fatal(err)
405+
}
406+
t.Setenv(envAuthFile, authFile)
407+
408+
restoreOAuthCredentials(t)
409+
setOAuthCredentials(tokenPersonal, "")
410+
refreshOAuthToken = func(ctx context.Context, conf *oauth2.Config, token *oauth2.Token) (*oauth2.Token, error) {
411+
t.Fatal("refresh should not run without an app key")
412+
return nil, nil
413+
}
414+
415+
_, _, err := getAccessToken(tokenPersonal, "", false)
416+
if err == nil {
417+
t.Fatal("expected missing app key error")
418+
}
419+
if got, want := jsonErrorCode(err), jsonErrorCodeAppKeyRequired; got != want {
420+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
421+
}
422+
if !strings.Contains(err.Error(), "dbxcli login") {
423+
t.Fatalf("expected login hint, got %q", err)
424+
}
425+
}
426+
387427
func TestGetAccessTokenMissingTokenWithDefaultPersonalCredentialsReturnsLoginError(t *testing.T) {
388428
restoreOAuthCredentials(t)
389429
setOAuthCredentials(tokenPersonal, defaultPersonalAppKey)
@@ -415,6 +455,9 @@ func TestGetAccessTokenMissingTokenWithDefaultPersonalCredentialsReturnsLoginErr
415455
if err == nil {
416456
t.Fatal("expected missing credentials error")
417457
}
458+
if got, want := jsonErrorCode(err), jsonErrorCodeAuthRequired; got != want {
459+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
460+
}
418461
if !strings.Contains(err.Error(), "dbxcli login") {
419462
t.Fatalf("expected login hint, got %q", err)
420463
}
@@ -454,6 +497,9 @@ func TestGetAccessTokenMissingTokenWithConfiguredAppKeyReturnsLoginError(t *test
454497
if err == nil {
455498
t.Fatal("expected missing credentials error")
456499
}
500+
if got, want := jsonErrorCode(err), jsonErrorCodeAuthRequired; got != want {
501+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
502+
}
457503
if !strings.Contains(err.Error(), "dbxcli login") {
458504
t.Fatalf("expected login hint, got %q", err)
459505
}
@@ -495,6 +541,8 @@ func TestRequestAccessTokenRejectsEmptyToken(t *testing.T) {
495541

496542
if _, err := requestAccessToken(tokenPersonal, ""); err == nil {
497543
t.Fatal("expected empty access token to return an error")
544+
} else if got, want := jsonErrorCode(err), jsonErrorCodeAuthExchangeFailed; got != want {
545+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
498546
}
499547
}
500548

@@ -517,6 +565,8 @@ func TestRequestAccessTokenRejectsMissingRefreshToken(t *testing.T) {
517565

518566
if _, err := requestAccessToken(tokenPersonal, ""); err == nil {
519567
t.Fatal("expected missing refresh token to return an error")
568+
} else if got, want := jsonErrorCode(err), jsonErrorCodeAuthExchangeFailed; got != want {
569+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
520570
}
521571
}
522572

@@ -742,5 +792,7 @@ func TestRequestAccessTokenRejectsEmptyAppCredentials(t *testing.T) {
742792

743793
if _, err := requestAccessToken(tokenTeamManage, ""); err == nil {
744794
t.Fatal("expected empty app credentials to fail")
795+
} else if got, want := jsonErrorCode(err), jsonErrorCodeAppKeyRequired; got != want {
796+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
745797
}
746798
}

0 commit comments

Comments
 (0)