Skip to content

Commit 2f4b579

Browse files
dmealingclaude
andcommitted
feat(runtime-ts): mount GET /_meta on fastify and hono
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent 680e81c commit 2f4b579

3 files changed

Lines changed: 111 additions & 0 deletions

File tree

‎server/typescript/packages/runtime-ts/src/fastify/index.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,43 @@ export function mountDeleteRoute(opts: SingleVerbOptions): void {
256256
// shared (deprecated) helper so the three adapters can't silently diverge.
257257
// The mounts above no longer use it: ObjectManager coerces ids per metadata.
258258
export { parseId } from "../drizzle-fastify/util.js";
259+
260+
// ---------------------------------------------------------------------------
261+
// Metadata endpoint
262+
// ---------------------------------------------------------------------------
263+
264+
import type { MetaData } from "@metaobjectsdev/metadata";
265+
import { META_ROUTE_PATH, metaJson } from "../meta-endpoint.js";
266+
267+
export interface MetaRouteOptions {
268+
fastify: FastifyInstance;
269+
/** The loaded model root. */
270+
root: MetaData;
271+
/** Mounted under this prefix; defaults to "" (the bare `/_meta`). */
272+
prefix?: string;
273+
}
274+
275+
/**
276+
* Mount `GET {prefix}/_meta`, serving the model as effective canonical JSON.
277+
*
278+
* Opt-in by design. `/_meta` publishes the SHAPE of the model — entity and field
279+
* names, types, validators, layouts — though no row data. Guard it with your own
280+
* middleware exactly as you would the generated routes (#367):
281+
*
282+
* app.register(async (s) => {
283+
* s.addHook("preHandler", requireAuth);
284+
* mountMetaRoute({ fastify: s, root, prefix: "/api" });
285+
* });
286+
*
287+
* The body is serialized once per call. If that ever matters, cache it in the
288+
* host — the model is immutable after load.
289+
*/
290+
export function mountMetaRoute(opts: MetaRouteOptions): void {
291+
const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
292+
opts.fastify.get(path, async (_req, reply) => {
293+
return reply
294+
.code(200)
295+
.header("content-type", "application/json; charset=utf-8")
296+
.send(metaJson(opts.root));
297+
});
298+
}

‎server/typescript/packages/runtime-ts/src/hono/index.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,30 @@ function extractRowCount(result: unknown): number {
320320
}
321321

322322
export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
323+
324+
// ---------------------------------------------------------------------------
325+
// Metadata endpoint
326+
// ---------------------------------------------------------------------------
327+
328+
import type { MetaData } from "@metaobjectsdev/metadata";
329+
import { META_ROUTE_PATH, metaJson } from "../meta-endpoint.js";
330+
331+
export interface HonoMetaRouteOptions {
332+
app: AnyHono;
333+
root: MetaData;
334+
prefix?: string;
335+
}
336+
337+
/**
338+
* Mount `GET {prefix}/_meta` on Hono. Same contract and same opt-in caveat as
339+
* the Fastify twin; guard the mount path with middleware before calling this:
340+
*
341+
* app.use(`/api${META_ROUTE_PATH}`, requireAuth);
342+
* mountMetaRouteHono({ app, root, prefix: "/api" });
343+
*/
344+
export function mountMetaRouteHono(opts: HonoMetaRouteOptions): void {
345+
const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
346+
opts.app.get(path, (c) =>
347+
c.body(metaJson(opts.root), 200, { "content-type": "application/json; charset=utf-8" }),
348+
);
349+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, test, expect } from "bun:test";
2+
import Fastify from "fastify";
3+
import { Hono } from "hono";
4+
import { metaJson } from "../src/meta-endpoint.js";
5+
import { mountMetaRoute } from "../src/fastify/index.js";
6+
import { mountMetaRouteHono } from "../src/hono/index.js";
7+
import { loadTestModel } from "./helpers/load-test-model.js";
8+
9+
describe("GET /_meta (fastify)", () => {
10+
test("serves the effective canonical JSON under the prefix", async () => {
11+
const root = await loadTestModel();
12+
const app = Fastify();
13+
mountMetaRoute({ fastify: app, root, prefix: "/api" });
14+
await app.ready();
15+
16+
const res = await app.inject({ method: "GET", url: "/api/_meta" });
17+
18+
expect(res.statusCode).toBe(200);
19+
expect(res.headers["content-type"]).toContain("application/json");
20+
expect(res.body).toBe(metaJson(root));
21+
});
22+
23+
test("mounts at the bare path when no prefix is given", async () => {
24+
const root = await loadTestModel();
25+
const app = Fastify();
26+
mountMetaRoute({ fastify: app, root });
27+
await app.ready();
28+
29+
expect((await app.inject({ method: "GET", url: "/_meta" })).statusCode).toBe(200);
30+
});
31+
});
32+
33+
describe("GET /_meta (hono)", () => {
34+
test("serves the same bytes as the fastify mount", async () => {
35+
const root = await loadTestModel();
36+
const app = new Hono();
37+
mountMetaRouteHono({ app, root, prefix: "/api" });
38+
39+
const res = await app.request("/api/_meta");
40+
41+
expect(res.status).toBe(200);
42+
expect(await res.text()).toBe(metaJson(root));
43+
});
44+
});

0 commit comments

Comments
 (0)