diff --git a/CHANGELOG.md b/CHANGELOG.md index 514b4743a..1c151d647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ here.** none shares the gap. - The `hand-rolled-aggregate` advisory now names `object.report` for grouped, served or reported figures, and says an `object.projection` with `origin.aggregate` still fits a per-row figure. +- **`meta docs` honours `routesFile({ expose })` and `routesFileHono({ expose })`.** The API + pages listed all five verbs for an entity whose routes file mounts fewer (`DELETE` for an entity + exposed as `["list", "get"]`), so the adopter overrode the pages by hand. The generators now + carry the option in a `mountsRoutes` marker, `meta docs` reads it off the resolved suite + (`runRoutesExpose`) per routes surface, so a narrowed Fastify routes file beside a default Hono + one (or the reverse) narrows only its own section, and documents only the verbs mounted: `update` lists both `PATCH` and `PUT`, + a read-only projection or report still lists `GET`, and a TPH base is narrowed from its + read-only pair. Output without `expose` is byte-identical. **An owned (ejected) copy needs the + marker to be honoured:** run `meta eject routes --force` (and `routes-hono --force`), or add + `mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}` beside the generator's + `name:`. TypeScript only: no other port has an `expose` option. ## [1.1.0] — 2026-10-10 diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 2938e9472..ba5b3b071 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -271,7 +271,9 @@ rather than off this page; its own JSDoc carries the recipe with the right one.) Both are executed as tests (`runtime-ts/test/route-auth-seam.test.ts`) rather than asserted in prose. For mounting *fewer* endpoints, narrow the generator: -`routesFile({ expose: ["list", "get"] })`. To register every entity's routes in one call, +`routesFile({ expose: ["list", "get"] })` (`meta docs` reads the option per routes surface, Fastify +and Hono apart, and documents only the verbs that surface mounts; a copy ejected before 1.1.0 +needs `meta eject routes --force` to be honoured). To register every entity's routes in one call, pass `registerAll: true`: the generator also emits `routes.index.ts` (or `routes.index.hono.ts`) with a `registerAllRoutes(...)`, so adding an entity needs no edit to your host file. What neither can express is a row-ownership diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 425fcfcdb..2c6886786 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts, so an `expose` you + // pass here is honoured there. Keep this line if you change what the file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 425fcfcdb..2c6886786 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts, so an `expose` you + // pass here is honoured there. Keep this line if you change what the file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't diff --git a/server/typescript/packages/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts index 32920b89b..a9eed1ef9 100644 --- a/server/typescript/packages/cli/src/commands/docs.ts +++ b/server/typescript/packages/cli/src/commands/docs.ts @@ -44,7 +44,7 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, renderCoreMetamodelDocs, } from "@metaobjectsdev/metadata"; -import { dbEmittingObjects, DEFAULT_DIALECT, DEFAULT_DOCS_DIR, missingDialectMessage, resolveGenerators, runEmitsHonoRoutes, runEmitsUiTier } from "@metaobjectsdev/codegen-ts"; +import { dbEmittingObjects, DEFAULT_DIALECT, DEFAULT_DOCS_DIR, missingDialectMessage, resolveGenerators, runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose } from "@metaobjectsdev/codegen-ts"; import type { MetaDataTypeProvider, MetaRoot } from "@metaobjectsdev/metadata"; import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site"; // The `agent` schema surface takes the physical schema as an ARGUMENT with its resolvers @@ -579,6 +579,7 @@ export async function docsCommand( suite = (loadedConfig?.generators ?? []).filter((g): g is Generator => typeof g !== "string"); } + const routesExpose = runRoutesExpose(suite); const ctx: GenContext = { entities: root.objects(), loadedRoot: root, @@ -594,6 +595,9 @@ export async function docsCommand( // does (a generator opts in via emitsHonoRoutes), so `meta docs` auto-detects // routesFileHono() rather than relying on a field users don't normally set. includeHonoRoutes: loadedConfig?.includeHonoRoutes ?? runEmitsHonoRoutes(suite), + // The API pages list the verbs the routes generators mount. `routesFile({ expose })` is a + // generator option, so it is read off the resolved suite, as the Hono marker above is. + ...(routesExpose !== undefined && { routesExpose }), // `agent/ui.md` describes forms, grids and the endpoints their hooks call. Whether // any of that is emitted is a GENERATOR fact — the page's own predicate is // metadata-only and answered "could a UI be generated?", so a project with diff --git a/server/typescript/packages/cli/test/docs-expose.test.ts b/server/typescript/packages/cli/test/docs-expose.test.ts new file mode 100644 index 000000000..d6c9342e0 --- /dev/null +++ b/server/typescript/packages/cli/test/docs-expose.test.ts @@ -0,0 +1,80 @@ +// `meta docs` honours the `expose` option of the project's routes generator: the API pages list +// the verbs the routes file mounts and no others (adopter report). +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { docsCommand } from "../src/commands/docs.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +const META = { + "metadata.root": { + package: "acme::shop", + children: [{ + "object.entity": { + name: "AuditEntry", + children: [ + { "source.rdb": { "@table": "audit_entries" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "note" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + ], + }, + }], + }, +}; + +// A routes generator as an owned `routesFile({ expose })` presents itself to the docs door: the +// `mountsRoutes` marker carries the option. +// `hono` is the marker of a second, Hono routes generator in the same suite (or ""). +const config = (marker: string, hono = "") => [ + `import { defineConfig } from "@metaobjectsdev/codegen-ts";`, + `const routes = { name: "routes-file", ${marker} generate: () => [] };`, + `const honoRoutes = { name: "routes-file-hono", emitsHonoRoutes: true, ${hono} generate: () => [] };`, + `export default defineConfig({ outDir: "out", dialect: "sqlite", generators: [routes${hono ? ", honoRoutes" : ""}] });`, +].join("\n"); + +async function docsFor(marker: string, hono = ""): Promise { + const root = await mkdtemp(join(tmpdir(), "meta-docs-expose-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.json"), JSON.stringify(META), "utf8"); + await writeFile(join(root, "metaobjects.config.ts"), config(marker, hono), "utf8"); + const out = join(root, "docs-out"); + expect(await docsCommand([root, "--out", out], root)).toBe(0); + return readFile(join(out, "api", "acme", "shop", "AuditEntry.md"), "utf8").catch(() => + readFile(join(out, "api", "AuditEntry.md"), "utf8")); +} + +describe("meta docs and routesFile({ expose })", () => { + test("a routes generator that exposes list and get documents no write verb", async () => { + const page = await docsFor(`mountsRoutes: { expose: ["list", "get"] },`); + expect(page).toContain("GET /audit_entries"); + expect(page).not.toMatch(/DELETE \/audit_entries/); + expect(page).not.toMatch(/POST \/audit_entries/); + expect(page).not.toMatch(/PATCH \/audit_entries/); + }); + + test("with no expose the page still lists every verb", async () => { + const page = await docsFor(`mountsRoutes: {},`); + expect(page).toContain("DELETE /audit_entries"); + expect(page).toContain("POST /audit_entries"); + }); + + test("a narrowed Fastify routes file beside a default Hono one narrows only the Fastify section", async () => { + const page = await docsFor(`mountsRoutes: { expose: ["list", "get"] },`, `mountsRoutes: {},`); + const at = page.indexOf("[Hono]"); + expect(at).toBeGreaterThan(-1); + const fastify = page.slice(0, page.indexOf("[Hono] List")); + expect(fastify).not.toMatch(/DELETE \/audit_entries/); + expect(page.slice(at)).toContain("DELETE /audit_entries"); + }); + + test("a narrowed Hono routes file beside a default Fastify one narrows only the Hono section", async () => { + const page = await docsFor(`mountsRoutes: {},`, `mountsRoutes: { expose: ["list", "get"] },`); + expect(page).toContain("DELETE /audit_entries"); + expect(page.slice(page.indexOf("[Hono] List"))).not.toMatch(/DELETE \/audit_entries/); + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index f474db35c..b91fff542 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -1,3 +1,4 @@ +import type { ExposeOption } from "./routes-expose.js"; import type { MetaObject, MetaRoot, TypeRegistry } from "@metaobjectsdev/metadata"; import type { LibraryManifest } from "@metaobjectsdev/metadata/library"; import type { RenderContext } from "./render-context.js"; @@ -114,6 +115,12 @@ export interface Generator { * that Hono routes are actually being emitted and document them — rather than * silently omitting the Hono CRUD registrars whenever the variant is wired. */ emitsHonoRoutes?: boolean; + /** Marks a generator that mounts the CRUD routes (`routesFile`, `routesFileHono`) and carries + * the `expose` option it was built with. `meta docs` aggregates it across the suite + * (`runRoutesExpose`), per surface (a `emitsHonoRoutes` generator is the Hono one), so the API docs list the verbs each routes file actually mounts, not all five. + * Absent `expose` means every verb. A copy ejected before this marker existed needs + * `meta eject routes --force` (or the same one-line addition) to be honoured. */ + mountsRoutes?: { readonly expose?: ExposeOption }; /** §A6 — marks the generator that emits the Names artifact. The runner * aggregates this across the suite into ResolvedGenConfig.includeNames, which the * entity generator reads to decide whether it may reference those constants. diff --git a/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts b/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts index dbe9f585d..163e33b69 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts @@ -99,6 +99,9 @@ export const apiDocsFile = function apiDocsFile(opts?: ApiDocsFileOpts): Generat loadedRoot: ctx.loadedRoot, outputLayout: layout, includeHonoRoutes: ctx.config.includeHonoRoutes ?? false, + // The verbs the routes generators actually mount (`expose`), so a page never lists a + // route the project does not serve. + ...(ctx.config.routesExpose !== undefined && { expose: ctx.config.routesExpose }), // The project's prefix, because every route is mounted inside it. Without // this the page documents `/authors` for routes served at `/api/authors`. apiPrefix: ctx.renderContext?.apiPrefix ?? "", diff --git a/server/typescript/packages/codegen-ts/src/generators/api-model.ts b/server/typescript/packages/codegen-ts/src/generators/api-model.ts index 57b820121..ce1c54f12 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -102,6 +102,7 @@ // very models this builder was reading it from. The read is gone — see // constants.ts.) +import { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, intersectExpose, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "../routes-expose.js"; import { type MetaRoot, type MetaObject, @@ -293,6 +294,10 @@ export interface ApiModelContext { * short. Threaded explicitly rather than read off the RenderContext below, * which is a `{ pkMap }` shim and carries nothing else. Defaults to "". */ apiPrefix?: string; + /** The `expose` option of each routes surface (`runRoutesExpose`). A routes generator + * mounts only its own verbs, so each surface documents the same subset. A surface left + * out means every verb, which documents byte-identically to before. */ + expose?: RoutesExpose; } // --------------------------------------------------------------------------- @@ -308,6 +313,7 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel { const apiPrefix = ctx.apiPrefix ?? ""; const relationMap = ctx.relationMap ?? buildRelationMap(root); const includeHono = ctx.includeHonoRoutes ?? false; + const expose = ctx.expose ?? {}; const units: ApiUnitDoc[] = []; @@ -316,7 +322,7 @@ export function buildApiModel(root: MetaRoot, ctx: ApiModelContext): ApiModel { // every symbol documents is the derived one. `generatableObjects` is the same swap // `runGen` hands the generators, so the units are the objects code was emitted for. for (const obj of generatableObjects(root.objects(), root)) { - units.push(buildEntityUnit(obj, pkCtx, root, layout, relationMap, includeHono, apiPrefix)); + units.push(buildEntityUnit(obj, pkCtx, root, layout, relationMap, includeHono, apiPrefix, expose)); } for (const tmpl of templateOutputs(root)) { @@ -430,6 +436,7 @@ function buildEntityUnit( relationMap: RelationMap, includeHono: boolean, apiPrefix: string, + expose: RoutesExpose, ): ApiUnitDoc { const name = obj.name; const symbols: ApiSymbol[] = []; @@ -460,10 +467,10 @@ function buildEntityUnit( // object gets routes. (Its `filter` option can narrow that further; this builder // reads the model, not the wired generator set, and cannot see it. See the module // header.) - symbols.push(...restSymbols(obj, layout, root, apiPrefix)); + symbols.push(...restSymbols(obj, layout, root, apiPrefix, expose.rest)); // The OPT-IN Hono variant mounts the SAME CRUD verbs under the same rules — // documented only when the adopter wired it. - if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix)); + if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono)); } // --- relation: the drizzle relations() export, when the resolver files entries @@ -667,10 +674,11 @@ function validationSymbols(obj: MetaObject, entityMod: string): ApiSymbol[] { */ function restSymbols( obj: MetaObject, layout: OutputLayout, root: MetaRoot, apiPrefix: string, + expose: ExposeOption | undefined, ): ApiSymbol[] { const name = obj.name; const path = servedPath(obj, apiPrefix); - const readOnly = isReadOnlySurface(obj) || isTphDiscriminatorBase(obj, root); + const mounted = mountedVerbs(obj, isTphDiscriminatorBase(obj, root), expose); // REST endpoints are not importable functions — to WIRE them an adopter // imports the entity's route registrar (`Routes`) from the routes @@ -688,27 +696,48 @@ function restSymbols( const ep = restEndpointFactory("rest", routesMod, registrar); - const symbols: ApiSymbol[] = [ - ep("GET", path, `List ${name} (supports filter/sort/paging query params).`, modelShape), - ]; + const symbols: ApiSymbol[] = []; + if (mounted.has("list")) { + symbols.push(ep("GET", path, `List ${name} (supports filter/sort/paging query params).`, modelShape)); + } // `/:id` is mounted only when a row can be addressed (`hasItemRoute`): not for a // keyless projection, and never for a report. - if (!lacksItemSurface(obj)) { + if (mounted.has("get") && !lacksItemSurface(obj)) { symbols.push(ep("GET", `${path}/:id`, `Fetch a single ${name} by id (404 when not found).`, modelShape)); } - - if (!readOnly) { + if (mounted.has("create")) { + symbols.push(ep("POST", path, `Create a ${name} (body validated by ${name}InsertSchema).`, createShape)); + } + if (mounted.has("update")) { symbols.push( - ep("POST", path, `Create a ${name} (body validated by ${name}InsertSchema).`, createShape), ep("PATCH", `${path}/:id`, `Partially update a ${name} by id (body validated by ${name}UpdateSchema).`, updateShape), ep("PUT", `${path}/:id`, `PUT alias of PATCH — the same handler and partial body (validated by ${name}UpdateSchema).`, updateShape), - ep("DELETE", `${path}/:id`, `Delete a ${name} by id.`), ); } + if (mounted.has("delete")) { + symbols.push(ep("DELETE", `${path}/:id`, `Delete a ${name} by id.`)); + } return symbols; } +/** + * The CRUD verbs the routes generator mounts for `obj`, as `routes-file.ts` decides them. + * + * A read-only surface (a projection or a report) mounts `list` and `get` and `expose` cannot + * narrow it. A TPH discriminator base is read-only by construction and an author's `expose` + * INTERSECTS with that set. Any other entity mounts all five, narrowed by `expose`: the + * option a `meta docs` run reads off the configured routes generator, so a page never lists a + * verb the adopter did not mount (and overrides by hand). + */ +function mountedVerbs( + obj: MetaObject, tphBase: boolean, expose: ExposeOption | undefined, +): ReadonlySet { + if (isReadOnlySurface(obj)) return new Set(["list", "get"]); + const fixed = tphBase ? TPH_POLYMORPHIC_VERBS : CRUD_VERBS; + return new Set(intersectExpose(fixed, resolveExpose(obj, expose))); +} + /** Build a REST endpoint symbol factory bound to one route surface (kind + * route module + registrar). The Fastify and Hono REST builders share this so a * `METHOD /path` endpoint is shaped one way; only the surface-level kind/module/ @@ -883,11 +912,11 @@ function callableArgsRef(obj: MetaObject, root: MetaRoot): string | undefined { * only when the adopter opts into the Hono variant (includeHonoRoutes). */ function restHonoSymbols( - obj: MetaObject, layout: OutputLayout, apiPrefix: string, + obj: MetaObject, layout: OutputLayout, apiPrefix: string, expose: ExposeOption | undefined, ): ApiSymbol[] { const name = obj.name; const path = servedPath(obj, apiPrefix); - const readOnly = isReadOnlySurface(obj); + const mounted = mountedVerbs(obj, false, expose); const honoMod = entityModulePath(layout, obj, `${name}.routes.hono`); const registrar = `register${name}Routes`; @@ -898,22 +927,26 @@ function restHonoSymbols( const ep = restEndpointFactory("rest-hono", honoMod, registrar); - const symbols: ApiSymbol[] = [ - ep("GET", path, `[Hono] List ${name} (filter/sort/paging query params).`, modelShape), - ]; + const symbols: ApiSymbol[] = []; + if (mounted.has("list")) { + symbols.push(ep("GET", path, `[Hono] List ${name} (filter/sort/paging query params).`, modelShape)); + } // The same rule as the Fastify surface: no `/:id` without an addressable row. - if (!lacksItemSurface(obj)) { + if (mounted.has("get") && !lacksItemSurface(obj)) { symbols.push(ep("GET", `${path}/:id`, `[Hono] Fetch a single ${name} by id (404 when not found).`, modelShape)); } - - if (!readOnly) { + if (mounted.has("create")) { + symbols.push(ep("POST", path, `[Hono] Create a ${name} (body validated by ${name}InsertSchema).`, createShape)); + } + if (mounted.has("update")) { symbols.push( - ep("POST", path, `[Hono] Create a ${name} (body validated by ${name}InsertSchema).`, createShape), ep("PATCH", `${path}/:id`, `[Hono] Partially update a ${name} by id (body validated by ${name}UpdateSchema).`, updateShape), ep("PUT", `${path}/:id`, `[Hono] PUT alias of PATCH — the same handler and partial body (validated by ${name}UpdateSchema).`, updateShape), - ep("DELETE", `${path}/:id`, `[Hono] Delete a ${name} by id.`), ); } + if (mounted.has("delete")) { + symbols.push(ep("DELETE", `${path}/:id`, `[Hono] Delete a ${name} by id.`)); + } return symbols; } diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts index 412dc2a0e..91f07541b 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts @@ -62,6 +62,8 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) servesReadApi(e) && userFilter(e); const generator: Generator = { name: "routes-file-hono", + // Read by `meta docs`: the API docs list the verbs this file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // Marks this as the Hono routes generator so the runner can aggregate // `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface. emitsHonoRoutes: true, diff --git a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts index df32a8f87..e8b14e8f5 100644 --- a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts @@ -59,6 +59,8 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // Always set: AND-composes the built-in gates with the optional user filter. filter: (e: MetaObject) => servesReadApi(e) && !isTphSubtype(e) && userFilter(e), diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 552f44bdd..b8ac7c608 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -11,7 +11,7 @@ export { perEntity, perPackage, perModel, oncePerRun } from "./generator.js"; // The one aggregation of the UI-tier markers. Exported because `meta docs` is the // SECOND door onto `agent/ui.md` and must answer "does this run emit a UI tier?" // with the same function the gen runner uses, not a second derivation of it. -export { runEmitsUiTier, runEmitsHonoRoutes, UI_TIER_GENERATOR_NAMES } from "./ui-tier-gate.js"; +export { runEmitsUiTier, runEmitsHonoRoutes, runRoutesExpose, UI_TIER_GENERATOR_NAMES } from "./ui-tier-gate.js"; // SP-1 declarative Mustache template-codegen — scope walks, neutral data dict, // output-pattern, and the JSON template-spec the CLI ports reuse. diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 57a0a8761..081c02265 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -1,4 +1,5 @@ import { DEFAULT_COLUMN_NAMING_STRATEGY, type ColumnNamingStrategy, type MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import type { RoutesExpose } from "./routes-expose.js"; import type { Generator } from "./generator.js"; import type { ExtStyle } from "./render-context.js"; import type { OutputLayout, ResolvedTarget } from "./import-path.js"; @@ -60,6 +61,9 @@ export interface ResolvedGenConfig { * api-docs reads this to AUTO-DETECT whether to document the Hono CRUD surface * (it otherwise mirrors the default Fastify-only suite). Undefined ⇒ false. */ includeHonoRoutes?: boolean; + /** The `expose` option the run's routes generators were built with (`runRoutesExpose`), per + * routes surface, for `meta docs` to document only the verbs they mount. Absent documents all five. */ + routesExpose?: RoutesExpose; /** §A6 — whether the OPT-IN names generator (namesFile) is active in the run, * aggregated by the runner from the suite's `emitsNames` markers. A generator that * wants to REFERENCE `Names` reads this to know the artifact will exist: @@ -100,7 +104,7 @@ export const DEFAULT_EXT_STYLE: ExtStyle = "js"; * fills inert defaults when they are absent AND the model emits no DB artifacts, and * throws a clear error when they are absent but the model DOES emit DB code (#194). */ -export interface MetaobjectsGenConfig extends Omit { +export interface MetaobjectsGenConfig extends Omit { dbImport?: string; dialect?: Dialect; /** Import-specifier suffix in generated code. Defaults to `"js"` (`./x.js`), which is diff --git a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts index ef12b20c8..2d83cebe8 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -282,6 +282,8 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts) // Marks this as the Hono routes generator so the runner can aggregate // `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface. emitsHonoRoutes: true, + // Read by `meta docs`: the API docs list the verbs this file mounts (see routes-file). + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // TPH subtypes are EXCLUDED, matching the Fastify generator. A TPH subtype shares // its base's table, so mounting vanilla CRUD for it produced routes with no // discriminator scoping at all: the list returned EVERY subtype's rows, and diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 425fcfcdb..2c6886786 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts, so an `expose` you + // pass here is honoured there. Keep this line if you change what the file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't diff --git a/server/typescript/packages/codegen-ts/src/routes-expose.ts b/server/typescript/packages/codegen-ts/src/routes-expose.ts index bf8d7bb05..967a98a2f 100644 --- a/server/typescript/packages/codegen-ts/src/routes-expose.ts +++ b/server/typescript/packages/codegen-ts/src/routes-expose.ts @@ -47,6 +47,16 @@ export type ExposeOption = | readonly CrudVerb[] | ((entity: MetaObject) => readonly CrudVerb[] | undefined); +/** + * The `expose` option of each routes SURFACE, as `meta docs` documents them: the Fastify + * routes file and the opt-in Hono one are separate generators and each mounts its own verbs. + * A surface left out mounts every verb. + */ +export interface RoutesExpose { + readonly rest?: ExposeOption; + readonly restHono?: ExposeOption; +} + /** Resolve the option for one entity. `undefined` means "mount all five". */ export function resolveExpose( entity: MetaObject, diff --git a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts index 309c469dc..7f1cfa417 100644 --- a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts +++ b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts @@ -1,4 +1,5 @@ import type { Generator } from "./generator.js"; +import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js"; /** * Generators that emit a CLIENT UI artifact — a form, a hook, a grid or its columns. @@ -77,3 +78,35 @@ export function warnUnmarkedUiGenerators( export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean { return generators.some((g) => g.emitsHonoRoutes === true); } + +/** + * The `expose` option of each routes surface the run mounts, for `meta docs` to apply (a docs + * page that lists DELETE for a route the adopter does not mount is a lie). + * + * Per surface, because the Fastify and Hono routes files are separate generators with their + * own option: a surface is narrowed only when EVERY generator mounting it carries an `expose` + * (several on one surface union their verbs), and a surface with none, or one that mounts all + * five, documents all five. Undefined when no surface is narrowed. + */ +export function runRoutesExpose(generators: readonly Generator[]): RoutesExpose | undefined { + const rest = surfaceExpose(generators.filter((g) => g.emitsHonoRoutes !== true)); + const restHono = surfaceExpose(generators.filter((g) => g.emitsHonoRoutes === true)); + if (rest === undefined && restHono === undefined) return undefined; + return { ...(rest !== undefined && { rest }), ...(restHono !== undefined && { restHono }) }; +} + +function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefined { + const mounts = generators + .map((g) => g.mountsRoutes) + .filter((m): m is NonNullable => m !== undefined); + if (mounts.length === 0 || mounts.some((m) => m.expose === undefined)) return undefined; + return (entity) => { + const union = new Set(); + for (const m of mounts) { + const verbs = resolveExpose(entity, m.expose); + if (verbs === undefined) return undefined; + for (const v of verbs) union.add(v); + } + return CRUD_VERBS.filter((v) => union.has(v)); + }; +} diff --git a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts new file mode 100644 index 000000000..d66863d94 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts @@ -0,0 +1,118 @@ +// `meta docs` honours the routes generator's `expose` option (adopter report): a page listed +// DELETE for an entity whose routes file does not mount it, so the adopter overrode the docs by +// hand. The documented endpoints are the verbs `routes-file.ts` mounts. + +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildApiModel } from "../src/generators/api-model.js"; +import { routesFile } from "../src/generators/routes-file.js"; +import { routesFileHono } from "../src/generators/routes-file-hono.js"; +import { runRoutesExpose } from "../src/ui-tier-gate.js"; + +const ENTITY = (name: string, table: string) => ({ + "object.entity": { + name, + children: [ + { "source.rdb": { "@table": table } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + ], + }, +}); + +async function loadRoot() { + const res = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ "metadata.root": { package: "acme::shop", children: [ENTITY("Product", "products"), ENTITY("AuditEntry", "audit_entries")] } }), + { id: "meta.json", format: "json" }, + ), + ]); + expect(res.errors).toEqual([]); + return res.root; +} + +const sigs = (model: ReturnType, unit: string, kind = "rest") => + model.units.find((u) => u.node === unit)!.symbols.filter((s) => s.kind === kind).map((s) => s.signature).sort(); + +describe("api docs follow the routes generator's expose option", () => { + test("no expose documents all five verbs, as before", async () => { + const root = await loadRoot(); + expect(sigs(buildApiModel(root, { loadedRoot: root }), "Product")).toEqual([ + "DELETE /products/:id", "GET /products", "GET /products/:id", "PATCH /products/:id", "POST /products", "PUT /products/:id", + ]); + }); + + test("without expose the model is identical to exposing every verb, Fastify and Hono", async () => { + const root = await loadRoot(); + const all = ["list", "get", "create", "update", "delete"] as const; + const ctx = { loadedRoot: root, includeHonoRoutes: true }; + expect(JSON.stringify(buildApiModel(root, ctx))).toBe(JSON.stringify(buildApiModel(root, { ...ctx, expose: { rest: all, restHono: all } }))); + }); + + test("a per-entity expose lists only the verbs mounted", async () => { + const root = await loadRoot(); + const rest = (e: { name: string }) => (e.name === "AuditEntry" ? (["list", "get"] as const) : undefined); + const model = buildApiModel(root, { loadedRoot: root, expose: { rest } }); + expect(sigs(model, "AuditEntry")).toEqual(["GET /audit_entries", "GET /audit_entries/:id"]); + expect(sigs(model, "Product")).toHaveLength(6); + }); + + test("update mounts both PATCH and PUT; delete and create are separate", async () => { + const root = await loadRoot(); + const model = buildApiModel(root, { loadedRoot: root, expose: { rest: ["list", "update"] } }); + expect(sigs(model, "Product")).toEqual(["GET /products", "PATCH /products/:id", "PUT /products/:id"]); + }); + + test("the opt-in Hono surface follows the same option", async () => { + const root = await loadRoot(); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, expose: { restHono: ["list", "get", "create"] } }); + expect(sigs(model, "Product", "rest-hono")).toEqual(["GET /products", "GET /products/:id", "POST /products"]); + }); +}); + +describe("each routes surface documents its own generator's expose", () => { + test("Fastify narrowed, Hono default: Fastify lists two verbs, Hono all", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile({ expose: ["list", "get"] }), routesFileHono()]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toEqual(["GET /products", "GET /products/:id"]); + expect(sigs(model, "Product", "rest-hono")).toHaveLength(6); + }); + + test("Hono narrowed, Fastify default: Hono lists two verbs, Fastify all", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile(), routesFileHono({ expose: ["list", "get"] })]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toHaveLength(6); + expect(sigs(model, "Product", "rest-hono")).toEqual(["GET /products", "GET /products/:id"]); + }); + + test("both narrowed differently: each surface keeps its own verbs", async () => { + const root = await loadRoot(); + const expose = runRoutesExpose([routesFile({ expose: ["list"] }), routesFileHono({ expose: ["get", "delete"] })]); + const model = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(expose && { expose }) }); + expect(sigs(model, "Product", "rest")).toEqual(["GET /products"]); + expect(sigs(model, "Product", "rest-hono")).toEqual(["DELETE /products/:id", "GET /products/:id"]); + }); +}); + +describe("runRoutesExpose reads the option off the configured generators", () => { + test("no routes generator, or only ones that mount everything, leaves docs unchanged", () => { + expect(runRoutesExpose([])).toBeUndefined(); + expect(runRoutesExpose([routesFile()])).toBeUndefined(); + expect(runRoutesExpose([routesFile(), routesFileHono()])).toBeUndefined(); + }); + + test("a surface is narrowed only by its own generators; two on one surface union their verbs", async () => { + const root = await loadRoot(); + const product = root.objects().find((o) => o.name === "Product")!; + type Fn = (e: typeof product) => readonly string[] | undefined; + const one = runRoutesExpose([routesFile({ expose: ["list", "get"] }), routesFileHono()]); + expect(one?.restHono).toBeUndefined(); + expect((one?.rest as Fn)(product)).toEqual(["list", "get"]); + const two = runRoutesExpose([routesFile({ expose: ["list"] }), routesFile({ expose: ["get", "delete"] })]); + expect((two?.rest as Fn)(product)).toEqual(["list", "get", "delete"]); + expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFile()])?.rest).toBeUndefined(); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts index a213f14aa..954a24b1f 100644 --- a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts +++ b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts @@ -70,7 +70,7 @@ describe("defineConfig", () => { test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig's non-DB fields exactly (#194 — dbImport/dialect are OPTIONAL here, filled by the runner)", () => { // Everything except dbImport/dialect matches ResolvedGenConfig field-for-field. expectTypeOf>() - .toEqualTypeOf>(); + .toEqualTypeOf>(); // extStyle is OPTIONAL too: the config `meta init` scaffolds omits it and must still // typecheck; the runner fills DEFAULT_EXT_STYLE. expectTypeOf().toEqualTypeOf(); diff --git a/server/typescript/packages/test-generators/src/routes.ts b/server/typescript/packages/test-generators/src/routes.ts index 425fcfcdb..2c6886786 100644 --- a/server/typescript/packages/test-generators/src/routes.ts +++ b/server/typescript/packages/test-generators/src/routes.ts @@ -742,6 +742,9 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator }); const generator: Generator = { name: "routes-file", + // Read by `meta docs`: the API docs list the verbs this file mounts, so an `expose` you + // pass here is honoured there. Keep this line if you change what the file mounts. + mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}, // TPH subtypes get no standalone routes file (their routes live in the discriminator // base's); AND-composed with your filter. // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't