@fluixi/api 0.0.0-stage → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/dist/client.cjs +103 -0
  4. package/dist/client.d.ts +95 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +87 -0
  7. package/dist/client.mjs +82 -0
  8. package/dist/errors.cjs +63 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +55 -0
  12. package/dist/errors.mjs +42 -0
  13. package/dist/index.cjs +667 -0
  14. package/dist/index.d.ts +34 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +26 -0
  17. package/dist/index.mjs +5442 -0
  18. package/dist/input.cjs +91 -0
  19. package/dist/input.d.ts +15 -0
  20. package/dist/input.d.ts.map +1 -0
  21. package/dist/input.js +73 -0
  22. package/dist/input.mjs +68 -0
  23. package/dist/middleware.cjs +41 -0
  24. package/dist/middleware.d.ts +9 -0
  25. package/dist/middleware.d.ts.map +1 -0
  26. package/dist/middleware.js +19 -0
  27. package/dist/middleware.mjs +20 -0
  28. package/dist/openapi.cjs +282 -0
  29. package/dist/openapi.d.ts +60 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +312 -0
  32. package/dist/openapi.mjs +259 -0
  33. package/dist/rate-limit.cjs +158 -0
  34. package/dist/rate-limit.d.ts +69 -0
  35. package/dist/rate-limit.d.ts.map +1 -0
  36. package/dist/rate-limit.js +121 -0
  37. package/dist/rate-limit.mjs +4935 -0
  38. package/dist/route.cjs +192 -0
  39. package/dist/route.d.ts +97 -0
  40. package/dist/route.d.ts.map +1 -0
  41. package/dist/route.js +98 -0
  42. package/dist/route.mjs +169 -0
  43. package/dist/security.cjs +222 -0
  44. package/dist/security.d.ts +64 -0
  45. package/dist/security.d.ts.map +1 -0
  46. package/dist/security.js +135 -0
  47. package/dist/security.mjs +4997 -0
  48. package/dist/standard-schema.cjs +18 -0
  49. package/dist/standard-schema.d.ts +39 -0
  50. package/dist/standard-schema.d.ts.map +1 -0
  51. package/dist/standard-schema.js +6 -0
  52. package/dist/standard-schema.mjs +0 -0
  53. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  54. package/package.json +54 -3
@@ -0,0 +1,60 @@
1
+ /**
2
+ * An OpenAPI 3.1 document, written from the route contracts.
3
+ *
4
+ * Nothing is described by hand: parameters, request bodies, responses and error responses all
5
+ * come from the schemas a route already validates with, so the document cannot drift from what
6
+ * the server does. Postman, Insomnia, Bruno and code generators import it.
7
+ *
8
+ * JSON Schema comes from the validator itself, through the Standard JSON Schema interface
9
+ * (`~standard.jsonSchema`, implemented by Zod 4). A validator without it can be given a converter
10
+ * with `setJsonSchemaConverter`; a schema nothing can convert is described as any value and
11
+ * marked `x-fluixi-unconverted`, so the gap shows in the document instead of breaking it.
12
+ */
13
+ import type { StandardSchemaV1 } from './standard-schema.js';
14
+ type JsonSchema = Record<string, unknown>;
15
+ type Io = 'input' | 'output';
16
+ /** What a route table entry needs to be described: its path template and its method exports. */
17
+ export interface DescribedRoute {
18
+ path?: string;
19
+ mod: Record<string, unknown>;
20
+ }
21
+ export interface OpenApiInfo {
22
+ title: string;
23
+ version: string;
24
+ description?: string;
25
+ /** Where the API runs. The first one is what tools like Postman use as the base URL. */
26
+ servers?: {
27
+ url: string;
28
+ description?: string;
29
+ }[];
30
+ }
31
+ export type JsonSchemaConverter = (schema: StandardSchemaV1, io: Io) => JsonSchema | undefined;
32
+ /** For a validator that does not implement Standard JSON Schema (Valibot: `@valibot/to-json-schema`). */
33
+ export declare function setJsonSchemaConverter(converter: JsonSchemaConverter | undefined): void;
34
+ type OpenApiSource = () => Record<string, unknown> | Promise<Record<string, unknown>>;
35
+ /**
36
+ * A handler whose routes a library describes itself: an auth engine, a webhook receiver. The
37
+ * library's own OpenAPI document replaces this route in the API's, with its paths under the
38
+ * route's path, minus a trailing parameter:
39
+ *
40
+ * ```ts
41
+ * // src/api/auth/[...all].ts, described as /api/auth/sign-in/email and the rest
42
+ * export const GET = describedBy(() => auth.api.generateOpenAPISchema(), (request) => auth.handler(request));
43
+ * export const POST = GET;
44
+ * ```
45
+ */
46
+ export declare function describedBy<H extends (...args: any[]) => unknown>(document: OpenApiSource, handler: H): H;
47
+ export declare function createOpenApiDocument(routes: readonly DescribedRoute[], info: OpenApiInfo): Record<string, unknown>;
48
+ /**
49
+ * A route that serves the document: `export const GET = openApiRoute(() => apiRoutes, info)` in
50
+ * `src/api/openapi.json.ts`. The table is read per request, since this route is in it.
51
+ */
52
+ export declare function openApiRoute(routes: () => readonly DescribedRoute[], info: OpenApiInfo): () => Promise<Response>;
53
+ /**
54
+ * Adds another document's paths under `mount`, its tags, and its components. A component whose
55
+ * name is taken by a different one is renamed after the mount (`/api/auth` gives `AuthUser`),
56
+ * with its references, and so is a clashing operation id (`authGetOrganization`).
57
+ */
58
+ export declare function includeDocument(target: Record<string, unknown>, source: Record<string, unknown>, mount: string): void;
59
+ export {};
60
+ //# sourceMappingURL=openapi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAG7D,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC1C,KAAK,EAAE,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE7B,gGAAgG;AAChG,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wFAAwF;IACxF,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACnD;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,EAAE,EAAE,EAAE,KAAK,UAAU,GAAG,SAAS,CAAC;AAI/F,yGAAyG;AACzG,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,mBAAmB,GAAG,SAAS,GAAG,IAAI,CAEvF;AAUD,KAAK,aAAa,GAAG,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEtF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAGzG;AAkBD,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAqBnH;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,EAAE,IAAI,EAAE,WAAW,SAE3D,OAAO,CAAC,QAAQ,CAAC,CAS5C;AAiBD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA+DrH"}
@@ -0,0 +1,312 @@
1
+ import { contractOf } from './route.js';
2
+ let convert;
3
+ /** For a validator that does not implement Standard JSON Schema (Valibot: `@valibot/to-json-schema`). */
4
+ export function setJsonSchemaConverter(converter) {
5
+ convert = converter;
6
+ }
7
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
8
+ /** Marks a handler that serves the description itself, which the description leaves out. */
9
+ const DESCRIBES = Symbol.for('fluixi.api.describes');
10
+ /** Holds the document of a handler that hands its requests to a library, set by `describedBy`. */
11
+ const DESCRIBED_BY = Symbol.for('fluixi.api.described-by');
12
+ /**
13
+ * A handler whose routes a library describes itself: an auth engine, a webhook receiver. The
14
+ * library's own OpenAPI document replaces this route in the API's, with its paths under the
15
+ * route's path, minus a trailing parameter:
16
+ *
17
+ * ```ts
18
+ * // src/api/auth/[...all].ts, described as /api/auth/sign-in/email and the rest
19
+ * export const GET = describedBy(() => auth.api.generateOpenAPISchema(), (request) => auth.handler(request));
20
+ * export const POST = GET;
21
+ * ```
22
+ */
23
+ export function describedBy(document, handler) {
24
+ Object.defineProperty(handler, DESCRIBED_BY, { value: document });
25
+ return handler;
26
+ }
27
+ const ERROR_SCHEMA = {
28
+ type: 'object',
29
+ required: ['error'],
30
+ properties: {
31
+ error: {
32
+ type: 'object',
33
+ required: ['code', 'message'],
34
+ properties: {
35
+ code: { type: 'string', description: 'Machine-readable, stable: `validation_failed`, `not_found`, `internal`.' },
36
+ message: { type: 'string', description: 'For people. May change between versions.' },
37
+ details: { description: 'For `validation_failed`, the list of `{ path, message }` issues.' },
38
+ },
39
+ },
40
+ },
41
+ };
42
+ export function createOpenApiDocument(routes, info) {
43
+ const paths = {};
44
+ const securitySchemes = {};
45
+ for (const route of routes) {
46
+ if (!route.path)
47
+ continue;
48
+ for (const method of METHODS) {
49
+ const handler = route.mod[method.toUpperCase()];
50
+ if (typeof handler !== 'function')
51
+ continue;
52
+ const marks = handler;
53
+ if (marks[DESCRIBES] || marks[DESCRIBED_BY])
54
+ continue;
55
+ (paths[route.path] ??= {})[method] = operation(method, route.path, contractOf(handler), securitySchemes);
56
+ }
57
+ }
58
+ const { servers, ...rest } = info;
59
+ return {
60
+ openapi: '3.1.0',
61
+ info: rest,
62
+ ...(servers?.length ? { servers } : {}),
63
+ paths,
64
+ components: { schemas: { Error: ERROR_SCHEMA }, ...(Object.keys(securitySchemes).length ? { securitySchemes } : {}) },
65
+ };
66
+ }
67
+ /**
68
+ * A route that serves the document: `export const GET = openApiRoute(() => apiRoutes, info)` in
69
+ * `src/api/openapi.json.ts`. The table is read per request, since this route is in it.
70
+ */
71
+ export function openApiRoute(routes, info) {
72
+ let cached;
73
+ const handler = async () => {
74
+ cached ??= describe(routes(), info).catch((error) => {
75
+ cached = undefined;
76
+ throw error;
77
+ });
78
+ return new Response(await cached, { headers: { 'content-type': 'application/json; charset=utf-8' } });
79
+ };
80
+ Object.defineProperty(handler, DESCRIBES, { value: true });
81
+ return handler;
82
+ }
83
+ async function describe(routes, info) {
84
+ const doc = createOpenApiDocument(routes, info);
85
+ // One document per source: GET and POST usually share the handler that carries it.
86
+ const mounts = new Map();
87
+ for (const route of routes) {
88
+ if (!route.path)
89
+ continue;
90
+ for (const handler of Object.values(route.mod)) {
91
+ const source = typeof handler === 'function' ? handler[DESCRIBED_BY] : undefined;
92
+ if (source && !mounts.has(source))
93
+ mounts.set(source, route.path.replace(/\/\{[^}]+\}$/, ''));
94
+ }
95
+ }
96
+ for (const [source, mount] of mounts)
97
+ includeDocument(doc, await source(), mount);
98
+ return JSON.stringify(doc, null, 2);
99
+ }
100
+ /**
101
+ * Adds another document's paths under `mount`, its tags, and its components. A component whose
102
+ * name is taken by a different one is renamed after the mount (`/api/auth` gives `AuthUser`),
103
+ * with its references, and so is a clashing operation id (`authGetOrganization`).
104
+ */
105
+ export function includeDocument(target, source, mount) {
106
+ const prefix = pascal(mount.split('/').filter(Boolean).pop() ?? 'included');
107
+ const components = (target.components ??= {});
108
+ const prefixed = (name) => prefix + name[0].toUpperCase() + name.slice(1);
109
+ const renames = new Map();
110
+ for (const [section, entries] of Object.entries((source.components ?? {}))) {
111
+ for (const [name, value] of Object.entries(entries)) {
112
+ const existing = components[section]?.[name];
113
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(value)) {
114
+ renames.set(`#/components/${section}/${name}`, `#/components/${section}/${prefixed(name)}`);
115
+ }
116
+ }
117
+ }
118
+ const rewrite = (node) => {
119
+ if (Array.isArray(node))
120
+ return node.map(rewrite);
121
+ if (!node || typeof node !== 'object')
122
+ return node;
123
+ const out = Object.fromEntries(Object.entries(node).map(([key, value]) => [key, key === '$ref' && typeof value === 'string' ? (renames.get(value) ?? value) : rewrite(value)]));
124
+ return 'nullable' in out ? nullable(out) : out;
125
+ };
126
+ for (const [section, entries] of Object.entries((source.components ?? {}))) {
127
+ const into = (components[section] ??= {});
128
+ for (const [name, value] of Object.entries(entries)) {
129
+ const renamed = renames.has(`#/components/${section}/${name}`) ? prefixed(name) : name;
130
+ into[renamed] = rewrite(value);
131
+ }
132
+ }
133
+ const paths = (target.paths ??= {});
134
+ // Operation ids must be unique across the document; ours are kept, an included one that clashes
135
+ // is renamed after the mount like a component.
136
+ const ids = new Set(Object.values(paths).flatMap((item) => Object.values(item).map((op) => op?.operationId)));
137
+ for (const [path, item] of Object.entries((source.paths ?? {}))) {
138
+ const described = rewrite(item);
139
+ for (const [method, op] of Object.entries(described)) {
140
+ // Requirements name a scheme instead of referencing it, so a renamed one is followed here.
141
+ if (Array.isArray(op.security)) {
142
+ op.security = op.security.map((requirement) => Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [
143
+ renames.has(`#/components/securitySchemes/${name}`) ? prefixed(name) : name,
144
+ scopes,
145
+ ])));
146
+ }
147
+ // Tools name requests after the summary; a library often gives only a description.
148
+ if (METHODS.includes(method) && !op.summary)
149
+ op.summary = summaryOf(op, method, mount + path);
150
+ if (typeof op.operationId === 'string' && ids.has(op.operationId)) {
151
+ op.operationId = prefix[0].toLowerCase() + prefix.slice(1) + op.operationId[0].toUpperCase() + op.operationId.slice(1);
152
+ }
153
+ if (typeof op.operationId === 'string')
154
+ ids.add(op.operationId);
155
+ }
156
+ paths[mount + path] = described;
157
+ }
158
+ const tags = (source.tags ?? []);
159
+ if (tags.length) {
160
+ const known = (target.tags ??= []);
161
+ for (const tag of tags)
162
+ if (!known.some((t) => t.name === tag.name))
163
+ known.push(tag);
164
+ }
165
+ }
166
+ /** OpenAPI 3.0's `nullable`, which 3.1 dropped, written as 3.1 says: `null` as one more type. */
167
+ function nullable(schema) {
168
+ const { nullable: isNullable, ...rest } = schema;
169
+ if (isNullable !== true)
170
+ return rest;
171
+ if (typeof rest.type === 'string')
172
+ return { ...rest, type: [rest.type, 'null'] };
173
+ if (Array.isArray(rest.type))
174
+ return rest.type.includes('null') ? rest : { ...rest, type: [...rest.type, 'null'] };
175
+ return { anyOf: [rest, { type: 'null' }] };
176
+ }
177
+ function summaryOf(op, method, path) {
178
+ const description = typeof op.description === 'string' ? op.description.split(/\.\s|\n/)[0].trim() : '';
179
+ if (description && description.length <= 60)
180
+ return description.replace(/\.$/, '');
181
+ return typeof op.operationId === 'string'
182
+ ? op.operationId.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
183
+ : summaryFor(method, path);
184
+ }
185
+ function pascal(word) {
186
+ return word
187
+ .split(/[^a-zA-Z0-9]+/)
188
+ .filter(Boolean)
189
+ .map((w) => w[0].toUpperCase() + w.slice(1))
190
+ .join('');
191
+ }
192
+ function operation(method, path, contract, schemes) {
193
+ const operationId = contract?.operationId ?? idFor(method, path);
194
+ if (!contract) {
195
+ return {
196
+ operationId,
197
+ tags: [tagFor(path)],
198
+ summary: `${method.toUpperCase()} ${path}`,
199
+ description: 'This route declares no contract, so nothing about its input or output is known.',
200
+ 'x-fluixi-contract': false,
201
+ responses: { default: { description: 'Undocumented.' } },
202
+ };
203
+ }
204
+ const parameters = [
205
+ ...parametersFrom(contract.params, 'path', pathNames(path)),
206
+ ...parametersFrom(contract.query, 'query', []),
207
+ ];
208
+ const success = contract.status ?? (contract.response ? 200 : 204);
209
+ const responses = {
210
+ [success]: contract.response
211
+ ? { description: 'Success.', content: { 'application/json': { schema: schemaFor(contract.response, 'output') } } }
212
+ : { description: 'Success, no content.' },
213
+ };
214
+ const error = (description) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } });
215
+ if (contract.params || contract.query || contract.body)
216
+ responses[400] = error('The request is not valid: `validation_failed`, `invalid_json`.');
217
+ if (contract.body) {
218
+ responses[413] = error('The body is over the limit.');
219
+ responses[415] = error('The body is not JSON.');
220
+ }
221
+ const guard = contract.guard;
222
+ if (guard) {
223
+ const refuses = guard.refuses ?? [401];
224
+ if (refuses.includes(401))
225
+ responses[401] = error('Not signed in: `unauthorized`.');
226
+ if (refuses.includes(403))
227
+ responses[403] = error('Signed in, not allowed: `forbidden`, or a more precise code.');
228
+ if (refuses.includes(503))
229
+ responses[503] = error('A service the check depends on cannot answer now; try again.');
230
+ }
231
+ if (pathNames(path).length)
232
+ responses[404] = error('Nothing at this path.');
233
+ responses[500] = error('A fault on the server. Its detail is logged, never sent.');
234
+ return {
235
+ operationId,
236
+ tags: contract.tags?.length ? contract.tags : [tagFor(path)],
237
+ // Tools name the request after it (Postman does), so there is always one.
238
+ summary: contract.summary ?? summaryFor(method, path),
239
+ ...(contract.description ? { description: contract.description } : {}),
240
+ ...(contract.deprecated ? { deprecated: true } : {}),
241
+ // Stated on every contracted route, so a public one reads as public rather than unknown.
242
+ security: security(guard, schemes),
243
+ ...(parameters.length ? { parameters } : {}),
244
+ ...(contract.body
245
+ ? { requestBody: { required: true, content: { 'application/json': { schema: schemaFor(contract.body, 'input') } } } }
246
+ : {}),
247
+ responses,
248
+ };
249
+ }
250
+ function security(guard, schemes) {
251
+ if (!guard?.security)
252
+ return [];
253
+ const { name, scheme, scopes } = guard.security;
254
+ schemes[name] = scheme;
255
+ return [{ [name]: scopes ?? [] }];
256
+ }
257
+ /** Path or query parameters, one per property of the object schema. */
258
+ function parametersFrom(schema, where, names) {
259
+ if (!schema)
260
+ return where === 'path' ? names.map((name) => ({ name, in: 'path', required: true, schema: { type: 'string' } })) : [];
261
+ const json = schemaFor(schema, 'input');
262
+ const properties = (json.properties ?? {});
263
+ const required = new Set(json.required ?? []);
264
+ return Object.entries(properties).map(([name, property]) => ({
265
+ name,
266
+ in: where,
267
+ required: where === 'path' ? true : required.has(name),
268
+ ...(typeof property.description === 'string' ? { description: property.description } : {}),
269
+ schema: property,
270
+ ...(where === 'query' && property.type === 'array' ? { style: 'form', explode: true } : {}),
271
+ }));
272
+ }
273
+ function schemaFor(schema, io) {
274
+ const standard = schema['~standard'].jsonSchema;
275
+ try {
276
+ // OpenAPI 3.1 is JSON Schema 2020-12. `unrepresentable: 'any'` turns a Date or a function
277
+ // into "any value" instead of an exception, for the libraries that read it (Zod does).
278
+ const json = standard?.[io]({ target: 'draft-2020-12', libraryOptions: { unrepresentable: 'any' } }) ?? convert?.(schema, io);
279
+ if (json) {
280
+ const { $schema: _dialect, ...rest } = json;
281
+ return rest;
282
+ }
283
+ }
284
+ catch {
285
+ // Described as any value below, rather than failing the whole document.
286
+ }
287
+ return { 'x-fluixi-unconverted': schema['~standard'].vendor };
288
+ }
289
+ function pathNames(path) {
290
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
291
+ }
292
+ /** `/api/v1/projects/{id}` groups under `projects`: the first segment after the prefix and version. */
293
+ function tagFor(path) {
294
+ const segments = path.split('/').filter((s) => s && !s.startsWith('{'));
295
+ const meaningful = segments.filter((s, i) => !(i === 0 && s === 'api') && !/^v\d+$/.test(s));
296
+ return meaningful[0] ?? 'default';
297
+ }
298
+ /** `get` `/api/v1/projects/{id}` reads "Get projects by id" when the contract gives no summary. */
299
+ function summaryFor(method, path) {
300
+ const words = idFor(method, path).replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
301
+ return words[0].toUpperCase() + words.slice(1);
302
+ }
303
+ /** `get` `/api/v1/projects/{id}` is `getProjectsById`, stable across builds. */
304
+ function idFor(method, path) {
305
+ const words = path
306
+ .split('/')
307
+ .filter((s) => s && s !== 'api' && !/^v\d+$/.test(s))
308
+ .map((s) => (s.startsWith('{') ? `By-${s.slice(1, -1)}` : s))
309
+ .flatMap((s) => s.split(/[^a-zA-Z0-9]+/))
310
+ .filter(Boolean);
311
+ return method + words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');
312
+ }
@@ -0,0 +1,259 @@
1
+ // src/input.ts
2
+ var DEFAULT_BODY_LIMIT = 1024 * 1024;
3
+
4
+ // src/route.ts
5
+ var CONTRACT = /* @__PURE__ */ Symbol.for("fluixi.api.contract");
6
+ function contractOf(handler) {
7
+ return typeof handler === "function" ? handler[CONTRACT] : void 0;
8
+ }
9
+
10
+ // src/openapi.ts
11
+ var convert;
12
+ function setJsonSchemaConverter(converter) {
13
+ convert = converter;
14
+ }
15
+ var METHODS = ["get", "post", "put", "patch", "delete"];
16
+ var DESCRIBES = /* @__PURE__ */ Symbol.for("fluixi.api.describes");
17
+ var DESCRIBED_BY = /* @__PURE__ */ Symbol.for("fluixi.api.described-by");
18
+ function describedBy(document, handler) {
19
+ Object.defineProperty(handler, DESCRIBED_BY, { value: document });
20
+ return handler;
21
+ }
22
+ var ERROR_SCHEMA = {
23
+ type: "object",
24
+ required: ["error"],
25
+ properties: {
26
+ error: {
27
+ type: "object",
28
+ required: ["code", "message"],
29
+ properties: {
30
+ code: { type: "string", description: "Machine-readable, stable: `validation_failed`, `not_found`, `internal`." },
31
+ message: { type: "string", description: "For people. May change between versions." },
32
+ details: { description: "For `validation_failed`, the list of `{ path, message }` issues." }
33
+ }
34
+ }
35
+ }
36
+ };
37
+ function createOpenApiDocument(routes, info) {
38
+ const paths = {};
39
+ const securitySchemes = {};
40
+ for (const route of routes) {
41
+ if (!route.path) continue;
42
+ for (const method of METHODS) {
43
+ const handler = route.mod[method.toUpperCase()];
44
+ if (typeof handler !== "function") continue;
45
+ const marks = handler;
46
+ if (marks[DESCRIBES] || marks[DESCRIBED_BY]) continue;
47
+ (paths[route.path] ??= {})[method] = operation(method, route.path, contractOf(handler), securitySchemes);
48
+ }
49
+ }
50
+ const { servers, ...rest } = info;
51
+ return {
52
+ openapi: "3.1.0",
53
+ info: rest,
54
+ ...servers?.length ? { servers } : {},
55
+ paths,
56
+ components: { schemas: { Error: ERROR_SCHEMA }, ...Object.keys(securitySchemes).length ? { securitySchemes } : {} }
57
+ };
58
+ }
59
+ function openApiRoute(routes, info) {
60
+ let cached;
61
+ const handler = async () => {
62
+ cached ??= describe(routes(), info).catch((error) => {
63
+ cached = void 0;
64
+ throw error;
65
+ });
66
+ return new Response(await cached, { headers: { "content-type": "application/json; charset=utf-8" } });
67
+ };
68
+ Object.defineProperty(handler, DESCRIBES, { value: true });
69
+ return handler;
70
+ }
71
+ async function describe(routes, info) {
72
+ const doc = createOpenApiDocument(routes, info);
73
+ const mounts = /* @__PURE__ */ new Map();
74
+ for (const route of routes) {
75
+ if (!route.path) continue;
76
+ for (const handler of Object.values(route.mod)) {
77
+ const source = typeof handler === "function" ? handler[DESCRIBED_BY] : void 0;
78
+ if (source && !mounts.has(source)) mounts.set(source, route.path.replace(/\/\{[^}]+\}$/, ""));
79
+ }
80
+ }
81
+ for (const [source, mount] of mounts) includeDocument(doc, await source(), mount);
82
+ return JSON.stringify(doc, null, 2);
83
+ }
84
+ function includeDocument(target, source, mount) {
85
+ const prefix = pascal(mount.split("/").filter(Boolean).pop() ?? "included");
86
+ const components = target.components ??= {};
87
+ const prefixed = (name) => prefix + name[0].toUpperCase() + name.slice(1);
88
+ const renames = /* @__PURE__ */ new Map();
89
+ for (const [section, entries] of Object.entries(source.components ?? {})) {
90
+ for (const [name, value] of Object.entries(entries)) {
91
+ const existing = components[section]?.[name];
92
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(value)) {
93
+ renames.set(`#/components/${section}/${name}`, `#/components/${section}/${prefixed(name)}`);
94
+ }
95
+ }
96
+ }
97
+ const rewrite = (node) => {
98
+ if (Array.isArray(node)) return node.map(rewrite);
99
+ if (!node || typeof node !== "object") return node;
100
+ const out = Object.fromEntries(
101
+ Object.entries(node).map(([key, value]) => [key, key === "$ref" && typeof value === "string" ? renames.get(value) ?? value : rewrite(value)])
102
+ );
103
+ return "nullable" in out ? nullable(out) : out;
104
+ };
105
+ for (const [section, entries] of Object.entries(source.components ?? {})) {
106
+ const into = components[section] ??= {};
107
+ for (const [name, value] of Object.entries(entries)) {
108
+ const renamed = renames.has(`#/components/${section}/${name}`) ? prefixed(name) : name;
109
+ into[renamed] = rewrite(value);
110
+ }
111
+ }
112
+ const paths = target.paths ??= {};
113
+ const ids = new Set(Object.values(paths).flatMap((item) => Object.values(item).map((op) => op?.operationId)));
114
+ for (const [path, item] of Object.entries(source.paths ?? {})) {
115
+ const described = rewrite(item);
116
+ for (const [method, op] of Object.entries(described)) {
117
+ if (Array.isArray(op.security)) {
118
+ op.security = op.security.map(
119
+ (requirement) => Object.fromEntries(
120
+ Object.entries(requirement).map(([name, scopes]) => [
121
+ renames.has(`#/components/securitySchemes/${name}`) ? prefixed(name) : name,
122
+ scopes
123
+ ])
124
+ )
125
+ );
126
+ }
127
+ if (METHODS.includes(method) && !op.summary) op.summary = summaryOf(op, method, mount + path);
128
+ if (typeof op.operationId === "string" && ids.has(op.operationId)) {
129
+ op.operationId = prefix[0].toLowerCase() + prefix.slice(1) + op.operationId[0].toUpperCase() + op.operationId.slice(1);
130
+ }
131
+ if (typeof op.operationId === "string") ids.add(op.operationId);
132
+ }
133
+ paths[mount + path] = described;
134
+ }
135
+ const tags = source.tags ?? [];
136
+ if (tags.length) {
137
+ const known = target.tags ??= [];
138
+ for (const tag of tags) if (!known.some((t) => t.name === tag.name)) known.push(tag);
139
+ }
140
+ }
141
+ function nullable(schema) {
142
+ const { nullable: isNullable, ...rest } = schema;
143
+ if (isNullable !== true) return rest;
144
+ if (typeof rest.type === "string") return { ...rest, type: [rest.type, "null"] };
145
+ if (Array.isArray(rest.type)) return rest.type.includes("null") ? rest : { ...rest, type: [...rest.type, "null"] };
146
+ return { anyOf: [rest, { type: "null" }] };
147
+ }
148
+ function summaryOf(op, method, path) {
149
+ const description = typeof op.description === "string" ? op.description.split(/\.\s|\n/)[0].trim() : "";
150
+ if (description && description.length <= 60) return description.replace(/\.$/, "");
151
+ return typeof op.operationId === "string" ? op.operationId.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()) : summaryFor(method, path);
152
+ }
153
+ function pascal(word) {
154
+ return word.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
155
+ }
156
+ function operation(method, path, contract, schemes) {
157
+ const operationId = contract?.operationId ?? idFor(method, path);
158
+ if (!contract) {
159
+ return {
160
+ operationId,
161
+ tags: [tagFor(path)],
162
+ summary: `${method.toUpperCase()} ${path}`,
163
+ description: "This route declares no contract, so nothing about its input or output is known.",
164
+ "x-fluixi-contract": false,
165
+ responses: { default: { description: "Undocumented." } }
166
+ };
167
+ }
168
+ const parameters = [
169
+ ...parametersFrom(contract.params, "path", pathNames(path)),
170
+ ...parametersFrom(contract.query, "query", [])
171
+ ];
172
+ const success = contract.status ?? (contract.response ? 200 : 204);
173
+ const responses = {
174
+ [success]: contract.response ? { description: "Success.", content: { "application/json": { schema: schemaFor(contract.response, "output") } } } : { description: "Success, no content." }
175
+ };
176
+ const error = (description) => ({ description, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } });
177
+ if (contract.params || contract.query || contract.body) responses[400] = error("The request is not valid: `validation_failed`, `invalid_json`.");
178
+ if (contract.body) {
179
+ responses[413] = error("The body is over the limit.");
180
+ responses[415] = error("The body is not JSON.");
181
+ }
182
+ const guard = contract.guard;
183
+ if (guard) {
184
+ const refuses = guard.refuses ?? [401];
185
+ if (refuses.includes(401)) responses[401] = error("Not signed in: `unauthorized`.");
186
+ if (refuses.includes(403)) responses[403] = error("Signed in, not allowed: `forbidden`, or a more precise code.");
187
+ if (refuses.includes(503)) responses[503] = error("A service the check depends on cannot answer now; try again.");
188
+ }
189
+ if (pathNames(path).length) responses[404] = error("Nothing at this path.");
190
+ responses[500] = error("A fault on the server. Its detail is logged, never sent.");
191
+ return {
192
+ operationId,
193
+ tags: contract.tags?.length ? contract.tags : [tagFor(path)],
194
+ // Tools name the request after it (Postman does), so there is always one.
195
+ summary: contract.summary ?? summaryFor(method, path),
196
+ ...contract.description ? { description: contract.description } : {},
197
+ ...contract.deprecated ? { deprecated: true } : {},
198
+ // Stated on every contracted route, so a public one reads as public rather than unknown.
199
+ security: security(guard, schemes),
200
+ ...parameters.length ? { parameters } : {},
201
+ ...contract.body ? { requestBody: { required: true, content: { "application/json": { schema: schemaFor(contract.body, "input") } } } } : {},
202
+ responses
203
+ };
204
+ }
205
+ function security(guard, schemes) {
206
+ if (!guard?.security) return [];
207
+ const { name, scheme, scopes } = guard.security;
208
+ schemes[name] = scheme;
209
+ return [{ [name]: scopes ?? [] }];
210
+ }
211
+ function parametersFrom(schema, where, names) {
212
+ if (!schema) return where === "path" ? names.map((name) => ({ name, in: "path", required: true, schema: { type: "string" } })) : [];
213
+ const json = schemaFor(schema, "input");
214
+ const properties = json.properties ?? {};
215
+ const required = new Set(json.required ?? []);
216
+ return Object.entries(properties).map(([name, property]) => ({
217
+ name,
218
+ in: where,
219
+ required: where === "path" ? true : required.has(name),
220
+ ...typeof property.description === "string" ? { description: property.description } : {},
221
+ schema: property,
222
+ ...where === "query" && property.type === "array" ? { style: "form", explode: true } : {}
223
+ }));
224
+ }
225
+ function schemaFor(schema, io) {
226
+ const standard = schema["~standard"].jsonSchema;
227
+ try {
228
+ const json = standard?.[io]({ target: "draft-2020-12", libraryOptions: { unrepresentable: "any" } }) ?? convert?.(schema, io);
229
+ if (json) {
230
+ const { $schema: _dialect, ...rest } = json;
231
+ return rest;
232
+ }
233
+ } catch {
234
+ }
235
+ return { "x-fluixi-unconverted": schema["~standard"].vendor };
236
+ }
237
+ function pathNames(path) {
238
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
239
+ }
240
+ function tagFor(path) {
241
+ const segments = path.split("/").filter((s) => s && !s.startsWith("{"));
242
+ const meaningful = segments.filter((s, i) => !(i === 0 && s === "api") && !/^v\d+$/.test(s));
243
+ return meaningful[0] ?? "default";
244
+ }
245
+ function summaryFor(method, path) {
246
+ const words = idFor(method, path).replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
247
+ return words[0].toUpperCase() + words.slice(1);
248
+ }
249
+ function idFor(method, path) {
250
+ const words = path.split("/").filter((s) => s && s !== "api" && !/^v\d+$/.test(s)).map((s) => s.startsWith("{") ? `By-${s.slice(1, -1)}` : s).flatMap((s) => s.split(/[^a-zA-Z0-9]+/)).filter(Boolean);
251
+ return method + words.map((w) => w[0].toUpperCase() + w.slice(1)).join("");
252
+ }
253
+ export {
254
+ createOpenApiDocument,
255
+ describedBy,
256
+ includeDocument,
257
+ openApiRoute,
258
+ setJsonSchemaConverter
259
+ };