API error bodies: inconsistencies found while documenting every route (#264) #272
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#272
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Make every API error body a coded message with the right code, the right args and the right owner: one rule for page-path validation, a code for each engine refusal instead of
error.unexpected, no composed English or absolute paths insideargs, anddocs/api.mdupdated with each change. Found while verifying the docs for #264; every item below is confirmed in the code.Current behavior
Reproduced against a fresh single-page site (
about.md):GET /api/media/bundlewithoutpage400 page.pathMissing, textmissing required parameter "path",args: {}POST /api/element/deletewithoutpage400 request.missingParam {name: "path"}(the route readspage)POST /api/status405 text/plain "method not allowed"— the only uncoded error bodyPOST /api/element/deletewith a stale path[5]409 error.unexpected {detail: "resolve: path[0] = 5 is out of range (the parent has 0 children)"}Confirmed by reading:
resolvePageFile,refuseTranslationPathininternal/server) re-implements what pageops owns (CleanPath,refuseTranslationPathininternal/pageops/paths.go), with different codes: an empty path isrequest.missingParam {name: "path"}in the server butpage.pathMissingin pageops; a translation file's path iseditor.translationPathin the server butpage.translationPathin pageops.path, even on routes whose key ispage(page/save, every element route, element/body, element/refs, element/delete-plan, move-refs, the form routes).content.MutateError(stale path, index out of range, a node that cannot hold children, move into itself) andcontent.Parse/content.Serializefailures reach the wire aserror.unexpected {detail}at 409 or 422 (element insert/remove/duplicate/move, element transfer,pageAndTree,serializePage).400 error.unexpectedin the server (media.ResolvePatherror wrapped bywriteError) and in mediaopsresolveFile, althoughlibrary.invalidRef {ref}exists and is used by media delete.folderNameputs the English textthe library rootintoargs.folderofmedia.folderNotFound.translation.appeared {path}(pageops translate) carries the absolute filesystem path of the file that appeared.argsisomitemptyonpublishJobState, so it vanishes when empty; every other error body carriesargs: {}.field.unknown'sargs.fieldis the bare name for an item-row key:items[0].foocomes back asfoo(the form checker ininternal/server/forms.gopasses the row name on), while the other 400s echo the key as sent.handlePublishAllsays "requireGit's 409/503" (requireGit only writes 409);pageResponse.TranslationandtranslationInfosaytranslationappears only on GET page, page/save and element/enabled (it is on every page answer in a non-default language);transferResponse's comment onTargetandhandleElementInsert's response comment do not mentionuid.Desired behavior
/api/route, including a wrong method onGET /api/status, is JSON{error, code, args}withargsalways an object.page.translationPathfor a translation file's path,request.missingParam {name}for an empty path, wherenameis the key the route actually reads (pageorpath, asdocs/api.mddocuments per route).editor.translationPathandpage.pathMissingno longer exist in the registry or the catalog.content.MutateErrorkind (stale or out-of-range path, a node that cannot hold children, a move into itself or an empty path),page.parseFailed {detail}when a page does not parse,page.serializeRefused {detail}when the serializer refuses the result. Status codes stay as they are (409 for a stale request, 422 for refused content). The English text keeps the detail it has today.400 library.invalidRef {ref}from every route that resolves one, with the rule evaluated once.argsnever contains composed English:media.folderNotFoundcarries the folder as sent, and the root case has a code of its own (or no such error can arise for the root; decide from whatresolveFoldercan actually fail on, and document the answer).translation.appeared {path}carries the content-root-relative path of the translation file (about.de.md), never an absolute path.argsis on the wire as{}when the message names nothing.field.unknown'sargs.fieldis the key as sent (items[0].foo), like every other field error.docs/api.mddescribes every changed shape: the "Errors" and "Coded messages" conventions, the per-route error lists that name the affected codes, the publish job state, and theGET /api/status405.Decisions
docs/api.md; splitting would cost two catalog regenerations for no independent value.request.missingParam {name}is what every other missing parameter answers; a page-specific spelling was the second copy, sopage.pathMissingis retired rather than given anamearg.internal/contentdoes not importapimsg— the engine stays free of the API vocabulary.MutateErrorcarries a kind the server maps to a code in one function; that function is the only place the mapping lives.lastSnapshotErrorandunsyncedReasonstay uncoded — ADR 0020 anddocs/api.md"Coded messages" list them as deliberately uncoded git text, and the git state'sreasonalready carries a coded message beside it. Not in scope.web/srcTypeScript references them), so the only client change is the regeneratedservermessages.jsonand theen.json/de.jsonentries.Key interfaces
internal/apimsg— the registry: new codes for the engine refusals and the parse/serialize failures,editor.translationPathandpage.pathMissingremoved.TestCallSitesenforces literal args at every call site;TestExport -updateregeneratesweb/src/i18n/servermessages.json, anden.json/de.jsonneed the matching entries (German included).content.MutateError— gains a kind (an enum, not a string match on the message) the server reads; itsError()text stays.resolvePageFile/refuseTranslationPath(internal/server/server.go) — call pageops' validation; the function takes, or its callers supply, the request key so the missing-param message names it.writeError/writeOpError(internal/server) — the one place aMutateErrorkind and a parse/serialize failure become a code.media.ResolvePath(internal/media/library.go) and mediaopsresolveFile— the escaping-ref refusal is coded once; both the server's media routes and mediaops answerlibrary.invalidRef.folderName(internal/mediaops/paths.go) — no longer composes English into args.publishJobState.Args(internal/server/publish.go) — noomitempty.internal/server/forms.go,checkField) — the unknown-field error echoes the full key.docs/api.md— owner of every wire shape above.docs/architecture.mdonly if its error narrative names a retired code (verify).scaffold/site/AGENTS.md, the scaffolded skills,docs/harness.md— none names an API error code (verified by grep).Acceptance criteria
GET /api/statusanswers405with a JSONrequest.methodbody, like every other route.GET /api/media/bundlewithoutpageandPOST /api/element/deletewithoutpageanswer400 request.missingParam {name: "page"};GET /api/pagewithoutpathanswers{name: "path"}.400 page.translationPathon every route that resolves a page, server and pageops alike;editor.translationPathandpage.pathMissingare absent frominternal/apimsg,servermessages.json,en.jsonandde.json.internal/pageopscovers the rule and the server tests cover the status and code only.409with their own code and args;error.unexpectedno longer appears for anycontent.MutateError.422 page.parseFailed {detail}; a serializer refusal answers422 page.serializeRefused {detail}.400 library.invalidRef {ref}from every media route and from mediaops.argsvalue anywhere is composed English text;media.folderNotFound'sargs.folderis the folder as sent.translation.appeared'sargs.pathis content-root-relative."args": {}when the message names nothing.field.unknownforitems[0].foocarriesargs.field: "items[0].foo".docs/api.mdnames every new code at the routes that answer it and no retired code anywhere;docs/docs_test.goand the route test ininternal/serverpass.How to verify
go test ./...andmake check(or the repo's equivalent lint/test target) pass, includingTestCallSites,TestExportand the SPA's catalog parity test underweb/.grep -rn "editor.translationPath\|page.pathMissing" internal web/src docsfinds nothing.grep -rn "error.unexpected" docs/api.mdnames only underlying failures (filesystem, git, decoder), none of the engine refusals.Assumptions to check
content.Parse/Serializeor aMutateErrorthrough a different write helper; grep forwriteError(with acontenterror before trusting the list.media.folderNotFoundcan arise for the root at all; if it cannot, the root case needs no code.docs/architecture.mddoes not name a retired code.Out of scope
lastSnapshotError,unsyncedReason, Hugo output or sidecar load errors (deliberately uncoded, ADR 0020).Blocked by
None - can start immediately
Original report
Verifying docs/api.md against the handlers for #264 turned up these error-body inconsistencies. The docs describe the code as it is; each is a candidate fix.
request.missingParam {name: "path"}in the server (resolvePageFile) butpage.pathMissingin pageops (pageops/paths.go:48); a path naming a translation file iseditor.translationPathin the server (refuseTranslationPath) butpage.translationPathin pageops (paths.go:260).resolvePageFile(server.go ~709) names the missing parameterpatheven on routes whose key ispage(page/save, every element route, element/body, element/refs, element/delete-plan, move-refs, the form routes).GET /api/media/bundlewithoutpageanswerspage.pathMissing, whose text says "path".GET /api/statusanswers a wrong method with plain text viahttp.Error(server.go ~354) — the only uncoded error body.content.MutateError(stale path, index out of range, move into itself) and content parse/serialize errors surface aserror.unexpected {detail}at 409/422 (element.go:159,230,319,439; element_transfer.go:284; server.go:806,1166) rather than codes of their own.400 error.unexpected(media.go:354, :882, mediaopsresolveFile) althoughlibrary.invalidRefexists.mediaopsfolderNameputs the English text "the library root" intoargs.folderofmedia.folderNotFound— untranslatable text inside args.translation.appeared {path}(pageops/translation.go:323) carries the absolute filesystem path.argsisomitempty(publish.go:136), so it disappears when empty, unlike every other error body'sargs: {}.field.unknown'sargs.fieldis the bare name foritems[<i>].<unknown>(forms.go:407-412), while the other 400s echo the key as sent.lastSnapshotErrorandunsyncedReason(git/state, git/sync) are server/git text with no code beside them.handlePublishAllsays "requireGit's 409/503" (requireGit only writes 409);pageResponse.Translation(server.go:488) andtranslationInfo(translation.go:41) saytranslationappears only on GET page, page/save and element/enabled (it is on every page answer in a non-default language);transferResponseon Target.Uid;handleElementInsertomitsuid.