pike/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files 5c2d82ded0cf18c81c70d9f4a3f1de0b1688b125
102 lines · 3.4 KB · ~1.0k tok Raw History
1
/**
2
 * The API, described: every route in `routes.ts` that declares `api`, as an
3
 * OpenAPI 3.1 document. Each operation also names the contract cell it is
4
 * (`x-contract`: the resource, the verb, and the least role the table asks
5
 * for), so a change of policy is a change here too, and shows in the pull.
6
 *
7
 *   bun run contract:emit    write contract/openapi.json from the table
8
 *
9
 * `src/openapi.test.ts` fails when the committed file and the routes
10
 * disagree: the description can go stale only in a commit that fails.
11
 */
12
13
import { contract } from "./contract.ts";
14
import { description, title, version } from "./env.ts";
15
import { AUDIT_ROW, DRAWING, ERROR, routes, type Schema } from "./routes.ts";
16
import { doorOf } from "./security.ts";
17
18
// The shapes said once, under components, and referred to wherever they appear.
19
const NAMED = new Map<Schema, string>([
20
  [DRAWING, "Drawing"],
21
  [AUDIT_ROW, "AuditRow"],
22
  [ERROR, "Error"],
23
]);
24
const named = (schema: Schema): Schema => {
25
  const name = NAMED.get(schema);
26
  if (name !== undefined) return { $ref: `#/components/schemas/${name}` };
27
  return Object.fromEntries(
28
    Object.entries(schema).map(([key, value]) => [
29
      key,
30
      typeof value === "object" && value !== null && !Array.isArray(value)
31
        ? named(value as Schema)
32
        : value,
33
    ]),
34
  );
35
};
36
const content = (schema: Schema) => ({
37
  "application/json": { schema: named(schema) },
38
});
39
40
export function openapi(): Record<string, unknown> {
41
  const paths: Record<string, Record<string, unknown>> = {};
42
  for (const route of routes) {
43
    if (route.api === undefined) continue;
44
    const { summary, body, answer, status, query } = route.api;
45
    const path = route.path.replace(/:(\w+)/g, "{$1}");
46
    const params = [...route.path.matchAll(/:(\w+)/g)].map(([, name]) => ({
47
      name,
48
      in: "path",
49
      required: true,
50
      schema: { type: "integer" },
51
    }));
52
    const door = doorOf(route.method, route.path.replace(/:\w+/g, "0"));
53
    const least =
54
      door === undefined
55
        ? undefined
56
        : contract.resources[door.resource]?.can[door.verb];
57
    paths[path] ??= {};
58
    paths[path][route.method.toLowerCase()] = {
59
      summary,
60
      operationId: `${route.method.toLowerCase()}${path.replace(/[{}]/g, "").replace(/(^|[/._-])(\w)/g, (_, __, c: string) => c.toUpperCase())}`,
61
      ...(params.length > 0 || query !== undefined
62
        ? {
63
            parameters: [
64
              ...params,
65
              ...Object.entries(query ?? {}).map(([name, schema]) => ({
66
                name,
67
                in: "query",
68
                schema: named(schema),
69
              })),
70
            ],
71
          }
72
        : {}),
73
      ...(body === undefined
74
        ? {}
75
        : { requestBody: { required: true, content: content(body) } }),
76
      responses: {
77
        [String(status ?? 200)]: {
78
          description: summary,
79
          content: content(answer),
80
        },
81
        default: {
82
          description: "{ error, message }: why not",
83
          content: content(ERROR),
84
        },
85
      },
86
      ...(door === undefined ? {} : { "x-contract": { ...door, least } }),
87
    };
88
  }
89
  return {
90
    openapi: "3.1.0",
91
    info: { title, version, description },
92
    paths,
93
    components: {
94
      schemas: Object.fromEntries(
95
        [...NAMED].map(([schema, name]) => [name, schema]),
96
      ),
97
    },
98
  };
99
}
100
101
/** The file's text: stable, so the same table always writes the same bytes. */
102
export const emit = (): string => `${JSON.stringify(openapi(), null, 2)}\n`;
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts