Skip to content

fix(api): menu REST API with camelCase responses#1052

Merged
ascorbic merged 4 commits into
emdash-cms:mainfrom
Rimander:fix/menus-api-response-shape-and-delete-route
May 16, 2026
Merged

fix(api): menu REST API with camelCase responses#1052
ascorbic merged 4 commits into
emdash-cms:mainfrom
Rimander:fix/menus-api-response-shape-and-delete-route

Conversation

@Rimander
Copy link
Copy Markdown
Contributor

@Rimander Rimander commented May 15, 2026

What does this PR do?

Addresses the menu API problems reported in #932.

What detected while tracing the flow:

  • POST /menus/:name/items with custom_url (snake_case) returned 201 with custom_url: null. The Zod schema only knows the documented camelCase keys (customUrl, sortOrder, …), and Zod's default .object() silently strips unknown keys.
  • DELETE /menus/:name/items/:id returned the SSR 404 HTML page because menus were the only resource using a query-string id (/items?id=); every other resource (content, taxonomies, redirects, sections, widget-areas) uses path-style [id].ts.
  • Menu responses were the only ones returning snake_case (custom_url, menu_id, …) — every other resource (content, media, taxonomies, comments, redirects) goes through a *Repository.rowToX() mapper that produces camelCase. Menus had no repository at all; all SQL lived in handlers/menus.ts.

What changed:

  • New MenuRepository next to the other repositories. All SQL and snake_case ↔ camelCase mapping lives there. handlers/menus.ts is now a thin orchestrator.
  • Menu and menu-item API responses are now camelCase (createdAt, menuId, parentId, sortOrder, customUrl, referenceCollection, …).
  • createMenuItemBody / updateMenuItemBody are .strict() — unknown keys (custom_url, url, sort_order) now fail with 400 VALIDATION_ERROR instead of being silently dropped. type is validated against the canonical enum (custom | page | post | taxonomy | collection).
  • Added PUT and DELETE /_emdash/api/menus/:name/items/:id (path-style).
  • OpenAPI document and admin UI types/components updated to match. Database schema is not touched.

Heads-up for theme authors: if your theme renders a custom menu block on the frontend by reading the REST API directly (rather than going through getMenu() from the runtime), the response keys have changed from snake_case to camelCase. getMenu() already returned camelCase — themes using it are unaffected.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Screenshots / test output

Test Files  208 passed (208)
     Tests  3291 passed (3291)

Repository test: tests/integration/database/menu-repository.test.ts (12 cases — row mapping, item counts, translation cloning, atomic setItems, scoped delete/reorder, listTranslations).

Handler/schema/route test: tests/integration/api/menus-handlers.test.ts (9 cases — .strict() rejects snake_case, enum rejects type: "link", customUrl persists and is returned in camelCase, path-style route module exists with PUT/DELETE).

Rimander added 2 commits May 15, 2026 15:05
…schema validation, and repository-pattern implementation.
…a transaction-scoped existence check and throwing MenuGoneError
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

🦋 Changeset detected

Latest commit: 11cfdc7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,345 lines across 14 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 15, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1052

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1052

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1052

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1052

emdash

npm i https://pkg.pr.new/emdash@1052

create-emdash

npm i https://pkg.pr.new/create-emdash@1052

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1052

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1052

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1052

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1052

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1052

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1052

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1052

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1052

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1052

commit: 11cfdc7

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid fix and a clean refactor. The bugs being addressed are real (silent snake_case key drop, query-string id route falling through to the SSR 404 HTML, response shape mismatch with the rest of the REST surface), and the new MenuRepository aligns menus with the existing repository pattern. Test coverage on the repository (mapping, item counts, translation cloning, atomic setItems, cross-menu guards, listTranslations) and on the MenuGoneError race is thorough.

A couple of small observations, no blockers:

  1. MCP tool responses also became camelCase. The MCP server delegates to the same handleMenu* functions, so menu_get / menu_set_items callers now see camelCase too (the MCP test file in this PR confirms it). The PR's heads-up only mentions REST consumers / theme authors — worth widening the note to MCP clients as well, since that's a separately-consumed surface.

  2. The path-style route test is structural-only. It asserts the file exports PUT/DELETE/prerender = false but doesn't exercise the injectCoreRoutes wiring or hit the route end-to-end. The route injection is the bug being fixed (without it, Astro fell through to the SSR 404), so a smoke test that the registered route actually accepts :id would close the loop. Not a blocker — the schema/handler tests cover the logic, and the route file itself is trivial.

LGTM otherwise.

const mod = await import("../../../src/astro/routes/api/menus/[name]/items/[id].js");
expect(typeof mod.PUT).toBe("function");
expect(typeof mod.DELETE).toBe("function");
expect(mod.prerender).toBe(false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this verifies the module exports but not that injectCoreRoutes actually registers the path-style pattern (which is the bug being fixed — without registration Astro returns the SSR 404 HTML page, regardless of what the module exports). A small additional assertion that injectCoreRoutes calls injectRoute with pattern: "/_emdash/api/menus/[name]/items/[id]" would close the loop and guard against a future refactor that drops that line from routes.ts.

).error,
};
}
return { success: true, menu: matches[0] };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/clarity: matches[0] here is typed as Menu only because packages/core/tsconfig.json overrides noUncheckedIndexedAccess to false (the base config has it true). After the length === 0 and length > 1 guards above, the array is known non-empty but TS won't narrow indexed access without that override. An explicit matches[0]! (or destructuring const [menu] = matches) would make the intent obvious to a reader used to the stricter base setting.

.executeTakeFirst();
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing behavior carried over from handleMenuItemCreate, but worth flagging now that there's a dedicated repository layer: this reads max(sort_order) and inserts without a surrounding transaction. Two concurrent createItem calls with sortOrder omitted under the same (menu_id, parent_id) can both compute the same next index. Out of scope for this PR (the original handler had the same race), but the move to a repository makes it a natural place to wrap the read+insert in a single transaction in a follow-up.

locale: row.locale,
translation_group: row.translation_group,
}),
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this manually re-spells every column just to feed rowToMenu. If MenuTable ever gains a column, this object literal has to be updated in lockstep with the select([...]) and groupBy([...]) above, or rowToMenu will start producing stale entities for findMany while findById is correct. Two cheaper options: (a) destructure the row and spread (const { itemCount, ...menuRow } = row; return { itemCount: ..., ...rowToMenu(menuRow) };), or (b) select m.* and group by m.id only on dialects that allow it. Not a correctness bug today.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted successfully. The review includes a summary highlighting the strengths of the PR (clean refactor, real bugs fixed, thorough test coverage) plus four line-anchored comments covering:

  1. menus-handlers.test.ts:278 — the route file existence test is structural-only and doesn't verify the injectCoreRoutes wiring.
  2. handlers/menus.ts:111matches[0] relies on the package's noUncheckedIndexedAccess: false override; an explicit assertion would be clearer.
  3. repositories/menu.ts:466 — pre-existing race in createItem default sortOrder (read max + insert without transaction), worth flagging in the new repository layer.
  4. repositories/menu.ts:242 — the manual column re-spelling in findMany is fragile if MenuTable gains a column.

All comments are nits/observations, not blockers. The PR is approved in spirit (LGTM otherwise).

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Feel free to follow up with the suggested fixes, as they're not blockers

@ascorbic ascorbic merged commit 0d5843f into emdash-cms:main May 16, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants