Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ JSON error responses use stable `error.code` values:
| `unknown_flag` | Cobra could not resolve a flag. |
| `command_failed` | Fallback for failures without a more specific stable code. |

JSON errors may also include an optional `error.details` object when dbxcli has reliable machine-readable context. Current detail keys include `path` for known path conflicts; `token_type`, `login_command`, and `env_var` for auth remediation; and `api_summary` for Dropbox API errors. Generic local failures omit `error.details`.

Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas and the command contract catalog live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path:

```json
Expand Down
50 changes: 41 additions & 9 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ func getAccessToken(tokType string, domain string, force bool) (string, string,
if !force && credential.shouldRefresh(time.Now()) {
credential, err = refreshStoredCredential(tokType, domain, credential)
if err != nil {
details := authTokenDetails(tokType)
if jsonErrorCode(err) == jsonErrorCodeAppKeyRequired {
return "", "", appKeyRequiredErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType))
return "", "", appKeyRequiredErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
}
return "", "", authRefreshFailedErrorf("refresh saved Dropbox credentials: %w; run %q again", err, loginCommand(tokType))
return "", "", authRefreshFailedErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType))
}
tokens[tokType] = credential
if err = writeTokens(filePath, tokenMap); err != nil {
Expand All @@ -264,7 +265,26 @@ func loginCommand(tokType string) string {
}

func missingAccessTokenError(tokType string) error {
return authRequiredErrorf("no saved Dropbox credentials; run %q first or set %s", loginCommand(tokType), envAccessToken)
return authRequiredErrorfWithDetails("no saved Dropbox credentials; run %q first or set %s", authTokenDetails(tokType), loginCommand(tokType), envAccessToken)
}

func authTokenDetails(tokType string) map[string]any {
return map[string]any{
"token_type": authTokenTypeName(tokType),
"login_command": loginCommand(tokType),
"env_var": envAccessToken,
}
}

func authTokenTypeName(tokType string) string {
switch tokType {
case tokenTeamAccess:
return "team-access"
case tokenTeamManage:
return "team-manage"
default:
return "personal"
}
}

func appCredentialsName(tokType string) string {
Expand All @@ -289,7 +309,9 @@ func ensureOAuthAppCredentials(tokType string) error {
}
creds.Key = strings.TrimSpace(creds.Key)
if creds.Key == "" {
return appKeyRequiredError("Dropbox app key is required")
return appKeyRequiredErrorWithDetails("Dropbox app key is required", map[string]any{
"token_type": authTokenTypeName(tokType),
})
}

setOAuthCredentials(tokType, creds.Key)
Expand Down Expand Up @@ -327,13 +349,19 @@ func requestAccessCredential(tokType string, domain string) (storedCredential, e
}
token, err := exchangeAuthorizationCode(context.Background(), conf, code, verifier)
if err != nil {
return storedCredential{}, authExchangeFailedErrorf("exchange authorization code: %w", err)
return storedCredential{}, authExchangeFailedErrorfWithDetails("exchange authorization code: %w", map[string]any{
"token_type": authTokenTypeName(tokType),
}, err)
}
if token == nil || token.AccessToken == "" {
return storedCredential{}, authExchangeFailedError("authorization did not return an access token")
return storedCredential{}, authExchangeFailedErrorWithDetails("authorization did not return an access token", map[string]any{
"token_type": authTokenTypeName(tokType),
})
}
if token.RefreshToken == "" {
return storedCredential{}, authExchangeFailedError("authorization did not return a refresh token")
return storedCredential{}, authExchangeFailedErrorWithDetails("authorization did not return a refresh token", map[string]any{
"token_type": authTokenTypeName(tokType),
})
}
return storedCredentialFromOAuthToken(token, conf.ClientID), nil
}
Expand All @@ -344,15 +372,19 @@ func refreshStoredCredential(tokType string, domain string, credential storedCre
appKey = oauthCredentials(tokType)
}
if strings.TrimSpace(appKey) == "" {
return storedCredential{}, appKeyRequiredError("saved credentials cannot be refreshed without a Dropbox app key")
return storedCredential{}, appKeyRequiredErrorWithDetails("saved credentials cannot be refreshed without a Dropbox app key", map[string]any{
"token_type": authTokenTypeName(tokType),
})
}

token, err := refreshOAuthToken(context.Background(), oauthConfigWithAppKey(appKey, domain), credential.oauthToken())
if err != nil {
return storedCredential{}, err
}
if token == nil || token.AccessToken == "" {
return storedCredential{}, authRefreshFailedErrorf("token refresh did not return an access token")
return storedCredential{}, authRefreshFailedErrorfWithDetails("token refresh did not return an access token", map[string]any{
"token_type": authTokenTypeName(tokType),
})
}

refreshed := storedCredentialFromOAuthToken(token, appKey)
Expand Down
2 changes: 1 addition & 1 deletion cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func ensureLocalDirectoryResult(source, target string, metadata files.IsMetadata
status := getStatusCreated
if info, err := os.Stat(target); err == nil {
if !info.IsDir() {
return getResult{}, pathConflictErrorf("path exists and is not a folder: %s", target)
return getResult{}, pathConflictErrorWithPath(target, "path exists and is not a folder: %s", target)
}
status = getStatusExisting
} else if !os.IsNotExist(err) {
Expand Down
3 changes: 3 additions & 0 deletions cmd/json_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ func TestPublicJSONSchemaFiles(t *testing.T) {
errorSchema := schema.Properties["error"]
codeSchema := errorSchema.Properties["code"]
assertStringSliceEqual(t, tt.file+" error code enum", codeSchema.Enum, expectedJSONErrorCodes())
if _, ok := errorSchema.Properties["details"]; !ok {
t.Fatalf("%s error schema missing details property", tt.file)
}
}
})
}
Expand Down
6 changes: 4 additions & 2 deletions cmd/json_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ type jsonErrorResponse struct {
}

type jsonError struct {
Message string `json:"message"`
Code string `json:"code"`
Message string `json:"message"`
Code string `json:"code"`
Details map[string]any `json:"details,omitempty"`
}

type jsonWarning struct {
Expand Down Expand Up @@ -56,6 +57,7 @@ func newJSONErrorResponse(cmd *cobra.Command, err error) jsonErrorResponse {
Error: jsonError{
Message: err.Error(),
Code: jsonErrorCode(err),
Details: jsonErrorDetails(err),
},
Warnings: emptyJSONWarnings(),
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/mkdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
}
status = mkdirStatusExisting
case ok && (conflictTag == files.WriteConflictErrorFile || conflictTag == files.WriteConflictErrorFileAncestor):
return pathConflictErrorf("path exists and is not a folder: %s", dst)
return pathConflictErrorWithPath(dst, "path exists and is not a folder: %s", dst)
case ok:
return err
case isConflictError(err):
Expand Down Expand Up @@ -112,7 +112,7 @@ func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata
}
folder, ok := metadata.(*files.FolderMetadata)
if !ok || folder == nil {
return nil, pathConflictErrorf("path exists and is not a folder: %s", dst)
return nil, pathConflictErrorWithPath(dst, "path exists and is not a folder: %s", dst)
}
return folder, nil
}
Expand Down
99 changes: 88 additions & 11 deletions cmd/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ type jsonCodedError interface {
JSONErrorCode() string
}

type jsonDetailedError interface {
error
JSONErrorDetails() map[string]any
}

type codedError struct {
code string
err error
code string
err error
details map[string]any
}

func (e codedError) Error() string {
Expand All @@ -54,13 +60,18 @@ func (e codedError) JSONErrorCode() string {
return e.code
}

func newCodedError(code string, err error) error {
func (e codedError) JSONErrorDetails() map[string]any {
return cloneJSONErrorDetails(e.details)
}

func newCodedError(code string, err error, details ...map[string]any) error {
if err == nil {
return nil
}
return codedError{
code: code,
err: err,
code: code,
err: err,
details: mergeJSONErrorDetails(details...),
}
}

Expand All @@ -72,34 +83,52 @@ func invalidArgumentsErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodeInvalidArguments, fmt.Errorf(format, args...))
}

func pathConflictErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...))
func pathConflictErrorWithPath(path string, format string, args ...any) error {
return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...), map[string]any{
"path": path,
})
}

func authRequiredErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodeAuthRequired, fmt.Errorf(format, args...))
}

func authRequiredErrorfWithDetails(format string, details map[string]any, args ...any) error {
return newCodedError(jsonErrorCodeAuthRequired, fmt.Errorf(format, args...), details)
}

func appKeyRequiredError(message string) error {
return newCodedError(jsonErrorCodeAppKeyRequired, errors.New(message))
}

func appKeyRequiredErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodeAppKeyRequired, fmt.Errorf(format, args...))
func appKeyRequiredErrorWithDetails(message string, details map[string]any) error {
return newCodedError(jsonErrorCodeAppKeyRequired, errors.New(message), details)
}

func appKeyRequiredErrorfWithDetails(format string, details map[string]any, args ...any) error {
return newCodedError(jsonErrorCodeAppKeyRequired, fmt.Errorf(format, args...), details)
}

func authExchangeFailedError(message string) error {
return newCodedError(jsonErrorCodeAuthExchangeFailed, errors.New(message))
}

func authExchangeFailedErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodeAuthExchangeFailed, fmt.Errorf(format, args...))
func authExchangeFailedErrorWithDetails(message string, details map[string]any) error {
return newCodedError(jsonErrorCodeAuthExchangeFailed, errors.New(message), details)
}

func authExchangeFailedErrorfWithDetails(format string, details map[string]any, args ...any) error {
return newCodedError(jsonErrorCodeAuthExchangeFailed, fmt.Errorf(format, args...), details)
}

func authRefreshFailedErrorf(format string, args ...any) error {
return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...))
}

func authRefreshFailedErrorfWithDetails(format string, details map[string]any, args ...any) error {
return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...), details)
}

func commandOutput(cmd *cobra.Command) *output.Renderer {
if cmd == nil {
return output.New(nil, nil, output.FormatText)
Expand Down Expand Up @@ -275,6 +304,54 @@ func jsonErrorCode(err error) string {
}
}

func jsonErrorDetails(err error) map[string]any {
details := make(map[string]any)

var detailed jsonDetailedError
if errors.As(err, &detailed) {
for key, value := range detailed.JSONErrorDetails() {
details[key] = value
}
}

if summary, ok := dropboxAPIErrorSummary(err); ok {
details["api_summary"] = summary
} else if summary, ok := dropboxAPISummaryFromMessage(err.Error()); ok {
details["api_summary"] = summary
}

if len(details) == 0 {
return nil
}
return details
}

func cloneJSONErrorDetails(details map[string]any) map[string]any {
if len(details) == 0 {
return nil
}
cloned := make(map[string]any, len(details))
for key, value := range details {
cloned[key] = value
}
return cloned
}

func mergeJSONErrorDetails(details ...map[string]any) map[string]any {
merged := make(map[string]any)
for _, detail := range details {
for key, value := range detail {
if value != nil {
merged[key] = value
}
}
}
if len(merged) == 0 {
return nil
}
return merged
}

func dropboxAPIJSONErrorCode(err error) string {
var rateLimitErr dropboxauth.RateLimitAPIError
var rateLimitErrPtr *dropboxauth.RateLimitAPIError
Expand Down
Loading
Loading