diff --git a/CHANGELOG.md b/CHANGELOG.md
index a47f6b071..5142264b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,17 @@ here.**
### Fixed
+- **The api reference now follows a routes generator's `filter` as well as its `expose`.**
+ `routesFile({ filter })` and `routesFileHono({ filter })` mount nothing for an entity the filter
+ rejects, but the API pages still listed all five verbs for it. The pages now document the
+ routes the generators mount and nothing else, from the one resolved answer the run reads off
+ them (`runRoutesExpose`). Output with neither option is byte-identical. A routes generator
+ ejected before the `mountsRoutes` marker existed is now named in a `meta gen` warning when the run
+ writes an api reference: its `expose` and `filter` are invisible to the pages until it carries
+ the marker (or is re-ejected with `meta eject routes --force`). TypeScript only: no other port
+ has an `expose` option or a routes-generator `filter`, and the ports' api references document
+ routes from the model alone.
+
- **C#: the two shared helpers now carry the `// ` marker (#460).**
`DecimalWire[.].g.cs` and `MetaNetValidation[.].g.cs` were the only
emitted files without it, so a runner with no `GenStateDir` wrote them once and then logged
diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md
index 77922d8ac..33fcf4e91 100644
--- a/docs/features/own-your-codegen.md
+++ b/docs/features/own-your-codegen.md
@@ -741,7 +741,8 @@ 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"] })` (`apiDocsFile()` 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
+and Hono apart, and documents only the verbs that surface mounts; `routesFile({ filter })` removes
+the entities it rejects from the pages altogether; 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
diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts
index 69e2521b8..934358a69 100644
--- a/examples/advanced-modeling/codegen/generators/routes.ts
+++ b/examples/advanced-modeling/codegen/generators/routes.ts
@@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
name: "routes-file",
// Read by the api reference (`apiDocsFile()`): it lists 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 } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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 69e2521b8..934358a69 100644
--- a/examples/showcase/codegen/generators/routes.ts
+++ b/examples/showcase/codegen/generators/routes.ts
@@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
name: "routes-file",
// Read by the api reference (`apiDocsFile()`): it lists 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 } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts
index 64bcdec18..c3fcc0dc5 100644
--- a/server/typescript/packages/codegen-ts/src/generator.ts
+++ b/server/typescript/packages/codegen-ts/src/generator.ts
@@ -164,7 +164,12 @@ export interface Generator {
* (`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 };
+ mountsRoutes?: {
+ readonly expose?: ExposeOption;
+ /** The generator's own `filter` option (not its built-in gates): an entity it rejects is
+ * mounted nowhere, so the API docs list none of its routes. */
+ readonly filter?: (entity: MetaObject) => boolean;
+ };
/** Marks a generator that writes the per-object model pages and their index (`docsFile()`).
* The runner reads it, per target, to tell an api reference generator in the same target
* that there are model pages to link back to (`ctx.docs.modelPages`). It changes nothing
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 7b17c7b0c..8cda79f56 100644
--- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts
@@ -469,10 +469,12 @@ 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, expose.rest));
+ if (expose.restMounted?.(obj) !== false) 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, expose.restHono));
+ if (includeHono && expose.restHonoMounted?.(obj) !== false) {
+ symbols.push(...restHonoSymbols(obj, layout, apiPrefix, expose.restHono));
+ }
}
// --- relation: the drizzle relations() export, when the resolver files entries
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 0221c1c62..a237fe875 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
@@ -63,7 +63,10 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
const generator: Generator = {
name: "routes-file-hono",
// Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts.
- mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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 6b3f38323..e47667d82 100644
--- a/server/typescript/packages/codegen-ts/src/generators/routes-file.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/routes-file.ts
@@ -60,7 +60,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
const generator: Generator = {
name: "routes-file",
// Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts.
- mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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/reference/routes-hono.ts b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
index ad7b81d60..1101d71b0 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts
@@ -281,7 +281,10 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
// `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface.
emitsHonoRoutes: true,
// Read by the api reference (`apiDocsFile()`): it lists the verbs this file mounts.
- mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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 69e2521b8..934358a69 100644
--- a/server/typescript/packages/codegen-ts/src/reference/routes.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts
@@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
name: "routes-file",
// Read by the api reference (`apiDocsFile()`): it lists 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 } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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 04e5e50aa..8c89f9511 100644
--- a/server/typescript/packages/codegen-ts/src/routes-expose.ts
+++ b/server/typescript/packages/codegen-ts/src/routes-expose.ts
@@ -50,11 +50,16 @@ export type ExposeOption =
/**
* The `expose` option of each routes SURFACE, as the api reference 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.
+ * A surface left out mounts every verb. `restMounted` / `restHonoMounted` say whether any routes
+ * generator on that surface accepts the entity at all (its `filter`): an entity none accepts has no
+ * routes, however read-only its surface is. They are separate from `expose` because a read-only
+ * surface ignores `expose` but not `filter`.
*/
export interface RoutesExpose {
readonly rest?: ExposeOption;
readonly restHono?: ExposeOption;
+ readonly restMounted?: (entity: MetaObject) => boolean;
+ readonly restHonoMounted?: (entity: MetaObject) => boolean;
}
/** Resolve the option for one entity. `undefined` means "mount all five". */
diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts
index 51389c5a2..2712a3d27 100644
--- a/server/typescript/packages/codegen-ts/src/runner.ts
+++ b/server/typescript/packages/codegen-ts/src/runner.ts
@@ -1,6 +1,6 @@
import { join, relative, resolve, isAbsolute, dirname } from "node:path";
import { warnMissingPromptGenerators } from "./prompt-generator-gate.js";
-import { runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedUiGenerators } from "./ui-tier-gate.js";
+import { runEmitsHonoRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedRoutesGenerators, warnUnmarkedUiGenerators } from "./ui-tier-gate.js";
import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -709,6 +709,7 @@ export async function runGen(opts: RunGenOpts): Promise {
(m) => warnings.push(m),
);
warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m));
+ warnUnmarkedRoutesGenerators(config.generators, (m) => warnings.push(m));
// Codegen is opt-in, so `meta gen` is the post-selection audit: the two ways a legal
// selection still surprises you. Warnings only — see catalog-gates.ts.
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 e2e28b829..52658d7b0 100644
--- a/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts
+++ b/server/typescript/packages/codegen-ts/src/ui-tier-gate.ts
@@ -1,3 +1,4 @@
+import type { MetaObject } from "@metaobjectsdev/metadata";
import type { Generator } from "./generator.js";
import { CRUD_VERBS, resolveExpose, type CrudVerb, type ExposeOption, type RoutesExpose } from "./routes-expose.js";
@@ -83,29 +84,63 @@ export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean {
* 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.
+ * own option: several generators on one surface union their verbs, and a generator with no
+ * `expose` mounts all five. A generator's own `filter` removes the entities it rejects, so an
+ * entity no generator on the surface accepts is documented with no routes. Undefined when no
+ * generator on any surface carries an `expose` or a `filter`.
*/
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 }) };
+ return {
+ ...(rest !== undefined && { rest: rest.expose, restMounted: rest.mounted }),
+ ...(restHono !== undefined && { restHono: restHono.expose, restHonoMounted: restHono.mounted }),
+ };
}
-function surfaceExpose(generators: readonly Generator[]): ExposeOption | undefined {
+function surfaceExpose(
+ generators: readonly Generator[],
+): { expose: ExposeOption; mounted: (entity: MetaObject) => boolean } | 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));
+ if (mounts.length === 0 || mounts.some((m) => m.expose === undefined && m.filter === undefined)) return undefined;
+ const accepts = (m: (typeof mounts)[number], entity: MetaObject): boolean => m.filter === undefined || m.filter(entity);
+ return {
+ mounted: (entity) => mounts.some((m) => accepts(m, entity)),
+ expose: (entity) => {
+ const union = new Set();
+ for (const m of mounts) {
+ if (!accepts(m, entity)) continue;
+ for (const v of resolveExpose(entity, m.expose) ?? CRUD_VERBS) union.add(v);
+ }
+ return CRUD_VERBS.filter((v) => union.has(v));
+ },
};
}
+
+/**
+ * A routes generator ejected before `mountsRoutes` existed still mounts its routes, but the api
+ * reference cannot see its `expose` or `filter` and documents all five verbs for every entity.
+ * Warned about when the run writes an api reference, by name for the same reason as
+ * {@link warnUnmarkedUiGenerators}; adding the marker silences it.
+ */
+export function warnUnmarkedRoutesGenerators(
+ generators: readonly Generator[],
+ warn: (msg: string) => void,
+): void {
+ if (!generators.some((g) => g.emitsApiDocs !== undefined)) return;
+ const stale = generators.filter(
+ (g) => (g.name === "routes-file" || g.name === "routes-file-hono") && g.mountsRoutes === undefined,
+ );
+ if (stale.length === 0) return;
+ const names = stale.map((g) => `'${g.name}'`).join(", ");
+ warn(
+ `${names} mount${stale.length === 1 ? "s" : ""} routes but carr${stale.length === 1 ? "ies" : "y"} no ` +
+ `'mountsRoutes' marker — an owned copy ejected before the marker existed. The api reference ` +
+ `lists every verb for every entity whatever its 'expose' or 'filter' says: add ` +
+ `'mountsRoutes: opts?.expose !== undefined ? { expose: opts.expose } : {}' beside 'name:' in your ` +
+ `copy (and 'filter' likewise), or re-eject it with 'meta eject routes --force'.`,
+ );
+}
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
index d66863d94..fcf42328d 100644
--- a/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts
+++ b/server/typescript/packages/codegen-ts/test/api-docs-expose.test.ts
@@ -8,6 +8,12 @@ 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";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runGen } from "../src/runner.js";
+import { defineConfig } from "../src/metaobjects-config.js";
+import { apiDocsFile } from "../src/generators/api-docs-file.js";
const ENTITY = (name: string, table: string) => ({
"object.entity": {
@@ -116,3 +122,119 @@ describe("runRoutesExpose reads the option off the configured generators", () =>
expect(runRoutesExpose([routesFile({ expose: ["list"] }), routesFile()])?.rest).toBeUndefined();
});
});
+
+describe("a routes generator's own filter removes the entities it does not mount", () => {
+ test("the rejected entity is documented with no routes; the rest are unchanged", async () => {
+ const root = await loadRoot();
+ const expose = runRoutesExpose([routesFile({ filter: (e) => e.name !== "AuditEntry" })]);
+ const model = buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) });
+ expect(sigs(model, "AuditEntry")).toEqual([]);
+ expect(sigs(model, "Product")).toHaveLength(6);
+ });
+
+ test("filter and expose compose; a second unfiltered generator on the surface mounts everything", async () => {
+ const root = await loadRoot();
+ const audit = root.objects().find((o) => o.name === "AuditEntry")!;
+ const product = root.objects().find((o) => o.name === "Product")!;
+ type Fn = (e: typeof audit) => readonly string[] | undefined;
+ const both = runRoutesExpose([routesFile({ filter: (e) => e.name !== "AuditEntry", expose: ["list"] })]);
+ expect((both?.rest as Fn)(audit)).toEqual([]);
+ expect((both?.rest as Fn)(product)).toEqual(["list"]);
+ expect(runRoutesExpose([routesFile({ filter: () => false }), routesFile()])?.rest).toBeUndefined();
+ });
+});
+
+describe("a routes generator's filter also removes a read-only surface it does not mount", () => {
+ async function loadWithProjection() {
+ const res = await new MetaDataLoader().load([
+ new InMemoryStringSource(
+ JSON.stringify({
+ "metadata.root": {
+ package: "acme::shop",
+ children: [
+ ENTITY("Product", "products"),
+ {
+ "object.projection": {
+ name: "ProductSummary",
+ children: [
+ { "source.rdb": { "@kind": "view", "@view": "v_product_summary" } },
+ { "field.long": { name: "id", extends: "Product.id" } },
+ { "field.string": { name: "title", extends: "Product.title" } },
+ { "identity.primary": { name: "pk", extends: "Product.id" } },
+ ],
+ },
+ },
+ ],
+ },
+ }),
+ { id: "meta.json", format: "json" },
+ ),
+ ]);
+ expect(res.errors).toEqual([]);
+ return res.root;
+ }
+
+ test("an unfiltered projection keeps GET list and GET by id; a filtered one is documented with no routes, Fastify and Hono", async () => {
+ const root = await loadWithProjection();
+ const docs = (expose: ReturnType) =>
+ buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) });
+
+ const open = docs(undefined);
+ expect(sigs(open, "ProductSummary")).toHaveLength(2);
+
+ const fastify = docs(runRoutesExpose([routesFile({ filter: (e) => e.name !== "ProductSummary" })]));
+ expect(sigs(fastify, "ProductSummary")).toEqual([]);
+ expect(sigs(fastify, "Product")).toHaveLength(6);
+
+ const hono = docs(runRoutesExpose([routesFileHono({ filter: (e) => e.name !== "ProductSummary" })]));
+ expect(sigs(hono, "ProductSummary", "rest-hono")).toEqual([]);
+ });
+
+ test("expose cannot narrow a projection: the routes generators mount GET list and GET by id regardless", async () => {
+ const root = await loadWithProjection();
+ for (const gens of [
+ [routesFile({ expose: ["list"] })],
+ [routesFile({ expose: () => [] })],
+ ]) {
+ const expose = runRoutesExpose(gens);
+ const model = buildApiModel(root, { loadedRoot: root, ...(expose !== undefined && { expose }) });
+ expect(sigs(model, "ProductSummary")).toHaveLength(2);
+ }
+ const hono = runRoutesExpose([routesFileHono({ expose: ["list"] })]);
+ const honoModel = buildApiModel(root, { loadedRoot: root, includeHonoRoutes: true, ...(hono !== undefined && { expose: hono }) });
+ expect(sigs(honoModel, "ProductSummary", "rest-hono")).toHaveLength(2);
+ });
+});
+
+describe("through meta gen: the written api pages list only the mounted verbs", () => {
+ async function pages(generators: Parameters[0]["generators"]) {
+ const root = await loadRoot();
+ const tmp = mkdtempSync(join(tmpdir(), "api-docs-expose-"));
+ try {
+ const { warnings } = await runGen({
+ config: defineConfig({
+ outDir: join(tmp, "src"), extStyle: "none", dbImport: "../db", dialect: "sqlite",
+ targets: { docs: { outDir: join(tmp, "docs") } }, generators,
+ }),
+ metadata: root, projectRoot: tmp,
+ });
+ return { product: readFileSync(join(tmp, "docs", "api", "Product.md"), "utf8"), warnings };
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
+ }
+
+ test("expose omitting create/update/delete: write routes are not documented; default is unchanged", async () => {
+ const narrowed = await pages([routesFile({ expose: ["list", "get"] }), apiDocsFile()]);
+ const page = narrowed.product;
+ expect(page).toContain("GET /products");
+ expect(page).not.toMatch(/\b(POST|PATCH|PUT|DELETE) \/products/);
+ const full = await pages([routesFile(), apiDocsFile()]);
+ expect(full.product).toMatch(/POST \/products/);
+ expect(full.product).toMatch(/DELETE \/products\/:id/);
+ });
+
+ test("an owned routes copy with no mountsRoutes marker is warned about", async () => {
+ const { mountsRoutes: _marker, ...stale } = routesFile();
+ const { warnings } = await pages([stale, apiDocsFile()]);
+ expect(warnings.join("\n")).toContain("mountsRoutes");
+ });
+});
diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts
index 69e2521b8..934358a69 100644
--- a/server/typescript/packages/test-generators/src/generators/routes.ts
+++ b/server/typescript/packages/test-generators/src/generators/routes.ts
@@ -735,7 +735,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
name: "routes-file",
// Read by the api reference (`apiDocsFile()`): it lists 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 } : {},
+ mountsRoutes: {
+ ...(opts?.expose !== undefined && { expose: opts.expose }),
+ ...(opts?.filter !== undefined && { filter: opts.filter }),
+ },
// 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