@enzopita/adonisjs-openapi 0.0.0-stage → 0.2.0

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/build/commands/generate_openapi.d.ts +8 -0
  4. package/build/commands/generate_openapi.js +49 -0
  5. package/build/commands/main.d.ts +4 -0
  6. package/build/commands/main.js +7 -0
  7. package/build/configure.d.ts +2 -0
  8. package/build/configure.js +16 -0
  9. package/build/index.d.ts +9 -0
  10. package/build/index.js +11 -0
  11. package/build/src/build_hook.d.ts +7 -0
  12. package/build/src/build_hook.js +18 -0
  13. package/build/src/collect_routes.d.ts +7 -0
  14. package/build/src/collect_routes.js +66 -0
  15. package/build/src/define_config.d.ts +2 -0
  16. package/build/src/define_config.js +15 -0
  17. package/build/src/docs/html.d.ts +8 -0
  18. package/build/src/docs/html.js +37 -0
  19. package/build/src/docs/redoc.d.ts +14 -0
  20. package/build/src/docs/redoc.js +17 -0
  21. package/build/src/docs/routes.d.ts +6 -0
  22. package/build/src/docs/routes.js +81 -0
  23. package/build/src/docs/scalar.d.ts +11 -0
  24. package/build/src/docs/scalar.js +15 -0
  25. package/build/src/docs/swagger_ui.d.ts +13 -0
  26. package/build/src/docs/swagger_ui.js +16 -0
  27. package/build/src/docs/ui.d.ts +2 -0
  28. package/build/src/docs/ui.js +13 -0
  29. package/build/src/generate.d.ts +8 -0
  30. package/build/src/generate.js +159 -0
  31. package/build/src/operation_docs.d.ts +12 -0
  32. package/build/src/operation_docs.js +51 -0
  33. package/build/src/route_types.d.ts +30 -0
  34. package/build/src/route_types.js +169 -0
  35. package/build/src/type_to_schema.d.ts +21 -0
  36. package/build/src/type_to_schema.js +157 -0
  37. package/build/src/types.d.ts +206 -0
  38. package/build/src/types.js +1 -0
  39. package/build/stubs/config/openapi.stub +15 -0
  40. package/build/stubs/main.d.ts +1 -0
  41. package/build/stubs/main.js +1 -0
  42. package/package.json +57 -3
@@ -0,0 +1,81 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path/posix';
3
+ import app from '@adonisjs/core/services/app';
4
+ import router from '@adonisjs/core/services/router';
5
+ import { createError } from '@adonisjs/core/exceptions';
6
+ import { defineConfig } from "../define_config.js";
7
+ import { renderPage } from "./ui.js";
8
+ const E_OPENAPI_DOCUMENT_MISSING = createError('Cannot find the OpenAPI document at "%s". Run "node ace openapi:generate", or register the "@enzopita/adonisjs-openapi/build_hook" build hook so "node ace build" writes it', 'E_OPENAPI_DOCUMENT_MISSING', 500);
9
+ function openapiConfig() {
10
+ return app.config.get('openapi') ?? defineConfig();
11
+ }
12
+ /**
13
+ * The page is served at `<prefix>` and the document at `<prefix>/openapi.json`, so the
14
+ * document URL only depends on the last segment of the URL the page was requested with.
15
+ */
16
+ function documentUrl(pagePath) {
17
+ return pagePath.endsWith('/') ? 'openapi.json' : `${basename(pagePath)}/openapi.json`;
18
+ }
19
+ /**
20
+ * Shield sets the nonce when CSP is enabled. This package does not depend on Shield, so its type is not augmented here.
21
+ */
22
+ function cspNonce(response) {
23
+ return 'nonce' in response ? String(response.nonce) : undefined;
24
+ }
25
+ async function generateLive() {
26
+ // Loaded on demand: it pulls in the assembler, a dev dependency that a production install leaves out.
27
+ const { generateDocument } = await import("../generate.js");
28
+ const { document } = await generateDocument(app, router, openapiConfig());
29
+ return document;
30
+ }
31
+ async function readGenerated(file) {
32
+ const path = app.makePath(file);
33
+ try {
34
+ return JSON.parse(await readFile(path, 'utf8'));
35
+ }
36
+ catch (error) {
37
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
38
+ throw new E_OPENAPI_DOCUMENT_MISSING([app.relativePath(path)], { cause: error });
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ function documentSource(options) {
44
+ const source = options.source ?? (app.inProduction ? 'file' : 'live');
45
+ if (source === 'live')
46
+ return generateLive;
47
+ const file = () => options.file ?? openapiConfig().output;
48
+ if (!app.inProduction)
49
+ return () => readGenerated(file());
50
+ let cached;
51
+ return async () => (cached ??= await readGenerated(file()));
52
+ }
53
+ /**
54
+ * Registers the docs page and the document it renders, as closure routes so the
55
+ * generator leaves them out of the document.
56
+ */
57
+ export function openapiRoutes(options = {}) {
58
+ const loadDocument = documentSource(options);
59
+ return router
60
+ .group(() => {
61
+ router
62
+ .get('/', ({ request, response }) => {
63
+ const page = renderPage(options, {
64
+ title: options.title ?? openapiConfig().info.title,
65
+ documentUrl: documentUrl(request.url()),
66
+ nonce: cspNonce(response),
67
+ });
68
+ response.header('content-type', 'text/html; charset=utf-8');
69
+ return page;
70
+ })
71
+ .as('openapi.ui');
72
+ router
73
+ .get('/openapi.json', async (ctx) => {
74
+ const document = await loadDocument();
75
+ // A copy, so a transform that edits the document in place cannot leak into the cached one.
76
+ return options.transform ? options.transform(structuredClone(document), ctx) : document;
77
+ })
78
+ .as('openapi.document');
79
+ })
80
+ .prefix(options.prefix ?? '/docs');
81
+ }
@@ -0,0 +1,11 @@
1
+ import type { DocsPage, UiOptions } from '../types.ts';
2
+ declare const cdn: {
3
+ script: string;
4
+ };
5
+ export declare const scalar: {
6
+ cdn: {
7
+ script: string;
8
+ };
9
+ render(page: DocsPage, assets: typeof cdn, uiOptions: UiOptions): string;
10
+ };
11
+ export {};
@@ -0,0 +1,15 @@
1
+ import { escapeHtml, htmlPage, script, scriptJson } from "./html.js";
2
+ const cdn = {
3
+ script: 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1/dist/browser/standalone.js',
4
+ };
5
+ export const scalar = {
6
+ cdn,
7
+ render(page, assets, uiOptions) {
8
+ const config = scriptJson({ url: page.documentUrl, ...uiOptions });
9
+ return htmlPage(page, [], [
10
+ '<div id="app"></div>',
11
+ script(page, ` src="${escapeHtml(assets.script)}"`),
12
+ script(page, '', `Scalar.createApiReference('#app', ${config})`),
13
+ ]);
14
+ },
15
+ };
@@ -0,0 +1,13 @@
1
+ import type { DocsPage, UiOptions } from '../types.ts';
2
+ declare const cdn: {
3
+ script: string;
4
+ style: string;
5
+ };
6
+ export declare const swaggerUi: {
7
+ cdn: {
8
+ script: string;
9
+ style: string;
10
+ };
11
+ render(page: DocsPage, assets: typeof cdn, uiOptions: UiOptions): string;
12
+ };
13
+ export {};
@@ -0,0 +1,16 @@
1
+ import { escapeHtml, htmlPage, script, scriptJson } from "./html.js";
2
+ const cdn = {
3
+ script: 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js',
4
+ style: 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css',
5
+ };
6
+ export const swaggerUi = {
7
+ cdn,
8
+ render(page, assets, uiOptions) {
9
+ const config = scriptJson({ url: page.documentUrl, dom_id: '#swagger-ui', ...uiOptions });
10
+ return htmlPage(page, [`<link rel="stylesheet" href="${escapeHtml(assets.style)}" />`], [
11
+ '<div id="swagger-ui"></div>',
12
+ script(page, ` src="${escapeHtml(assets.script)}"`),
13
+ script(page, '', `window.ui = SwaggerUIBundle(${config})`),
14
+ ]);
15
+ },
16
+ };
@@ -0,0 +1,2 @@
1
+ import type { DocsPage, DocsUi } from '../types.ts';
2
+ export declare function renderPage(options: DocsUi, page: DocsPage): string;
@@ -0,0 +1,13 @@
1
+ import { redoc } from "./redoc.js";
2
+ import { scalar } from "./scalar.js";
3
+ import { swaggerUi } from "./swagger_ui.js";
4
+ const renderers = { scalar, 'swagger-ui': swaggerUi, redoc };
5
+ function renderBuiltIn(ui, page, cdn, uiOptions) {
6
+ const renderer = renderers[ui];
7
+ return renderer.render(page, { ...renderer.cdn, ...cdn }, uiOptions);
8
+ }
9
+ export function renderPage(options, page) {
10
+ if (options.ui instanceof Function)
11
+ return options.ui(page);
12
+ return renderBuiltIn(options.ui ?? 'scalar', page, options.cdn, options.uiOptions ?? {});
13
+ }
@@ -0,0 +1,8 @@
1
+ import type { ApplicationService } from '@adonisjs/core/types';
2
+ import type { Router } from '@adonisjs/core/http';
3
+ import type { OpenApiConfig, OpenApiDocument } from './types.ts';
4
+ export interface GeneratedDocument {
5
+ document: OpenApiDocument;
6
+ warnings: string[];
7
+ }
8
+ export declare function generateDocument(app: ApplicationService, router: Router, config: OpenApiConfig): Promise<GeneratedDocument>;
@@ -0,0 +1,159 @@
1
+ import { STATUS_CODES } from 'node:http';
2
+ import { fileURLToPath } from 'node:url';
3
+ import ts from 'typescript';
4
+ import { collectRoutes } from "./collect_routes.js";
5
+ import { RouteTypes } from "./route_types.js";
6
+ const NOT_IN_BODY = new Set(['query', 'params', 'headers', 'cookies']);
7
+ /**
8
+ * Vine knows the constraints (formats, bounds, enums) but has no JSON Schema for
9
+ * files or custom rules and emits `{}` there: the TypeScript shape fills those gaps.
10
+ */
11
+ function mergeSchemas(vine, typed) {
12
+ if (!vine || !Object.keys(vine).length)
13
+ return typed;
14
+ if (!vine.properties || !typed.properties)
15
+ return vine;
16
+ const typedProperties = typed.properties;
17
+ const properties = Object.fromEntries(Object.entries(vine.properties).map(([key, schema]) => [
18
+ key,
19
+ mergeSchemas(schema, typedProperties[key] ?? {}),
20
+ ]));
21
+ return { ...vine, properties };
22
+ }
23
+ function pick(schema, keep) {
24
+ const properties = Object.entries(schema.properties ?? {}).filter(([key]) => keep(key));
25
+ if (!properties.length)
26
+ return undefined;
27
+ const { required = [], ...rest } = schema;
28
+ const kept = required.filter(keep);
29
+ return kept.length
30
+ ? { ...rest, properties: Object.fromEntries(properties), required: kept }
31
+ : { ...rest, properties: Object.fromEntries(properties) };
32
+ }
33
+ async function validatorSchemas(app, routes, warnings) {
34
+ const schemas = new Map();
35
+ for (const route of routes) {
36
+ if (!route.validator)
37
+ continue;
38
+ const { specifier, exportName } = route.validator;
39
+ try {
40
+ // SAFETY: the routes scanner only reports values passed to `request.validateUsing()`, which are Vine validators.
41
+ const module = (await app.import(specifier));
42
+ const validator = module[exportName];
43
+ if (validator)
44
+ schemas.set(route.name, validator.toJSONSchema());
45
+ }
46
+ catch (error) {
47
+ const reason = error instanceof Error ? error.message : String(error);
48
+ warnings.push(`${route.name}: cannot read the JSON Schema of ${specifier}#${exportName} (${reason})`);
49
+ }
50
+ }
51
+ return schemas;
52
+ }
53
+ const EMPTY_STATUSES = new Set(['204', '205', '304']);
54
+ const WITHOUT_BODY = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Void | ts.TypeFlags.Undefined;
55
+ function responsesFor(route, types, response, thrown, secured, warnings) {
56
+ const responses = {};
57
+ const describe = (code, payload) => {
58
+ const description = STATUS_CODES[Number(code)] ?? 'Response';
59
+ const empty = !payload || EMPTY_STATUSES.has(code) || payload.flags & WITHOUT_BODY;
60
+ responses[code] = empty
61
+ ? { description }
62
+ : { description, content: { 'application/json': { schema: types.schemas.convert(payload) } } };
63
+ };
64
+ const members = response?.isUnion() ? response.types : response ? [response] : [];
65
+ for (const member of members) {
66
+ const status = types.property(member, '__status');
67
+ const payload = types.property(member, '__response');
68
+ if (status && payload) {
69
+ describe(status.isNumberLiteral() ? String(status.value) : 'default', payload);
70
+ }
71
+ else if (!(member.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))) {
72
+ describe('200', member);
73
+ }
74
+ }
75
+ for (const [code, description] of Object.entries(thrown))
76
+ responses[code] ??= { description };
77
+ const validationError = route.validator && types.validationError();
78
+ if (validationError && !responses['422'])
79
+ describe('422', validationError);
80
+ if (secured && !responses['401'])
81
+ describe('401');
82
+ if (!Object.keys(responses).some(code => code.startsWith('2') || code === 'default')) {
83
+ warnings.push(`${route.name}: the handler's return type is unknown, returning serialize(...) makes it typed`);
84
+ responses['200'] = { description: 'OK' };
85
+ }
86
+ return responses;
87
+ }
88
+ function parametersFor(route, query, params) {
89
+ const path = route.pathParams.map((name) => ({
90
+ name,
91
+ in: 'path',
92
+ required: true,
93
+ schema: params?.properties?.[name] ?? { type: 'string' },
94
+ }));
95
+ const search = Object.entries(query?.properties ?? {}).map(([name, schema]) => ({
96
+ name,
97
+ in: 'query',
98
+ required: query?.required?.includes(name) ?? false,
99
+ schema,
100
+ }));
101
+ return [...path, ...search];
102
+ }
103
+ export async function generateDocument(app, router, config) {
104
+ const warnings = [];
105
+ const appRoot = fileURLToPath(app.appRoot);
106
+ const routes = await collectRoutes(appRoot, router, config);
107
+ const types = new RouteTypes(appRoot, routes);
108
+ const vine = await validatorSchemas(app, routes, warnings);
109
+ const paths = {};
110
+ for (const route of routes) {
111
+ const { request, response } = types.get(route.name);
112
+ const input = request && mergeSchemas(vine.get(route.name), types.schemas.convert(request));
113
+ const readsQueryOnly = route.methods.every(method => method === 'get' || method === 'options');
114
+ const fields = input && pick(input, key => !NOT_IN_BODY.has(key));
115
+ const body = readsQueryOnly ? undefined : fields;
116
+ const query = readsQueryOnly ? fields : input?.properties?.query;
117
+ const parameters = parametersFor(route, query, input?.properties?.params);
118
+ const security = route.middleware.flatMap(name => {
119
+ const scheme = config.middlewareSecurity[name];
120
+ return scheme ? [{ [scheme]: [] }] : [];
121
+ });
122
+ const { responses: thrown, ...docs } = types.docs(route);
123
+ const operation = {
124
+ ...docs,
125
+ tags: docs.tags ?? [route.name.split('.')[0] ?? route.name],
126
+ responses: responsesFor(route, types, response, thrown, security.length > 0, warnings),
127
+ };
128
+ if (parameters.length)
129
+ operation.parameters = parameters;
130
+ if (security.length)
131
+ operation.security = security;
132
+ if (body) {
133
+ const mediaType = JSON.stringify(body).includes('"format":"binary"')
134
+ ? 'multipart/form-data'
135
+ : 'application/json';
136
+ operation.requestBody = { required: true, content: { [mediaType]: { schema: body } } };
137
+ }
138
+ const methods = paths[route.path] ?? {};
139
+ paths[route.path] = methods;
140
+ for (const method of route.methods) {
141
+ const operationId = route.methods.length > 1 ? `${route.name}.${method}` : route.name;
142
+ methods[method] = { operationId, ...operation };
143
+ }
144
+ }
145
+ for (const type of types.schemas.unsupported) {
146
+ warnings.push(`no JSON Schema for the type ${type}, emitted {}`);
147
+ }
148
+ const document = {
149
+ openapi: '3.1.0',
150
+ info: config.info,
151
+ paths,
152
+ components: { schemas: types.schemas.components, securitySchemes: config.securitySchemes },
153
+ };
154
+ if (config.servers.length)
155
+ document.servers = config.servers;
156
+ if (config.tags.length)
157
+ document.tags = config.tags;
158
+ return { document, warnings };
159
+ }
@@ -0,0 +1,12 @@
1
+ import ts from 'typescript';
2
+ export interface OperationDocs {
3
+ summary?: string;
4
+ description?: string;
5
+ tags?: string[];
6
+ deprecated?: true;
7
+ /**
8
+ * Status code to description, from `@throws {404} User not found`.
9
+ */
10
+ responses: Record<string, string>;
11
+ }
12
+ export declare function operationDocs(controller: ts.ClassDeclaration, method: ts.MethodDeclaration): OperationDocs;
@@ -0,0 +1,51 @@
1
+ import { STATUS_CODES } from 'node:http';
2
+ import ts from 'typescript';
3
+ function jsDoc(node) {
4
+ return ts.getJSDocCommentsAndTags(node).filter(ts.isJSDoc).at(-1);
5
+ }
6
+ function tagText(tag) {
7
+ return ts.getTextOfJSDocComment(tag.comment)?.trim() ?? '';
8
+ }
9
+ function tagsOf(doc) {
10
+ const tag = doc?.tags?.find(candidate => candidate.tagName.text === 'tags');
11
+ return (tag &&
12
+ tagText(tag)
13
+ .split(',')
14
+ .map(name => name.trim())
15
+ .filter(Boolean));
16
+ }
17
+ /**
18
+ * `@throws {404}` parses as a JSDoc type expression holding the literal type `404`.
19
+ */
20
+ function thrownStatus(tag) {
21
+ const type = ts.isJSDocThrowsTag(tag) ? tag.typeExpression?.type : undefined;
22
+ if (!type || !ts.isLiteralTypeNode(type) || !ts.isNumericLiteral(type.literal))
23
+ return undefined;
24
+ return /^\d{3}$/.test(type.literal.text) ? type.literal.text : undefined;
25
+ }
26
+ export function operationDocs(controller, method) {
27
+ const doc = jsDoc(method);
28
+ const responses = {};
29
+ const tags = tagsOf(doc) ?? tagsOf(jsDoc(controller));
30
+ let deprecated = false;
31
+ const [summary, ...paragraphs] = (ts.getTextOfJSDocComment(doc?.comment) ?? '')
32
+ .split(/\n\s*\n/)
33
+ .map(paragraph => paragraph.trim())
34
+ .filter(Boolean);
35
+ for (const tag of doc?.tags ?? []) {
36
+ const status = thrownStatus(tag);
37
+ if (status)
38
+ responses[status] = tagText(tag) || (STATUS_CODES[Number(status)] ?? status);
39
+ else if (ts.isJSDocDeprecatedTag(tag))
40
+ deprecated = true;
41
+ else if (tag.tagName.text === 'remarks' && tagText(tag))
42
+ paragraphs.push(tagText(tag));
43
+ }
44
+ return {
45
+ ...(summary && { summary }),
46
+ ...(paragraphs.length > 0 && { description: paragraphs.join('\n\n') }),
47
+ ...(tags?.length && { tags }),
48
+ ...(deprecated && { deprecated: true }),
49
+ responses,
50
+ };
51
+ }
@@ -0,0 +1,30 @@
1
+ import ts from 'typescript';
2
+ import { type OperationDocs } from './operation_docs.ts';
3
+ import { TypeToSchema } from './type_to_schema.ts';
4
+ import type { RouteOperation } from './types.ts';
5
+ /**
6
+ * Resolves the request and response types of every route with the app's own
7
+ * tsconfig, through one in-memory module that names them all.
8
+ */
9
+ export declare class RouteTypes {
10
+ #private;
11
+ readonly checker: ts.TypeChecker;
12
+ readonly schemas: TypeToSchema;
13
+ constructor(appRoot: string, routes: RouteOperation[]);
14
+ /**
15
+ * The types of one route's `request` and `response` members.
16
+ */
17
+ get(routeName: string): {
18
+ request?: undefined;
19
+ response?: undefined;
20
+ } | {
21
+ request: ts.Type | undefined;
22
+ response: ts.Type | undefined;
23
+ };
24
+ validationError(): ts.Type | undefined;
25
+ /**
26
+ * The doc comments on the handler, read from the controller class the file exports by default.
27
+ */
28
+ docs({ controller }: RouteOperation): OperationDocs;
29
+ property(type: ts.Type, name: string): ts.Type | undefined;
30
+ }
@@ -0,0 +1,169 @@
1
+ import ts from 'typescript';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { operationDocs } from "./operation_docs.js";
5
+ import { TypeToSchema } from "./type_to_schema.js";
6
+ /**
7
+ * The `HttpResponse` helpers that set a status code, as Tuyau types them minus the
8
+ * informational and redirect ones. Declared here as well so apps without Tuyau get them.
9
+ */
10
+ const RESPONSE_HELPERS = {
11
+ ok: 200,
12
+ json: 200,
13
+ created: 201,
14
+ accepted: 202,
15
+ nonAuthoritativeInformation: 203,
16
+ noContent: 204,
17
+ resetContent: 205,
18
+ partialContent: 206,
19
+ badRequest: 400,
20
+ unauthorized: 401,
21
+ paymentRequired: 402,
22
+ forbidden: 403,
23
+ notFound: 404,
24
+ methodNotAllowed: 405,
25
+ notAcceptable: 406,
26
+ proxyAuthenticationRequired: 407,
27
+ requestTimeout: 408,
28
+ conflict: 409,
29
+ gone: 410,
30
+ lengthRequired: 411,
31
+ preconditionFailed: 412,
32
+ requestEntityTooLarge: 413,
33
+ requestUriTooLong: 414,
34
+ unsupportedMediaType: 415,
35
+ requestedRangeNotSatisfiable: 416,
36
+ expectationFailed: 417,
37
+ unprocessableEntity: 422,
38
+ tooManyRequests: 429,
39
+ internalServerError: 500,
40
+ notImplemented: 501,
41
+ badGateway: 502,
42
+ serviceUnavailable: 503,
43
+ gatewayTimeout: 504,
44
+ httpVersionNotSupported: 505,
45
+ };
46
+ /**
47
+ * Resolves the request and response types of every route with the app's own
48
+ * tsconfig, through one in-memory module that names them all.
49
+ */
50
+ export class RouteTypes {
51
+ checker;
52
+ schemas;
53
+ #program;
54
+ #routes;
55
+ #validationError;
56
+ constructor(appRoot, routes) {
57
+ const entryFile = join(appRoot, '.adonisjs/openapi/routes.ts');
58
+ const dataFile = join(appRoot, '.adonisjs/client/data.d.ts');
59
+ const config = ts.getParsedCommandLineOfConfigFile(join(appRoot, 'tsconfig.json'), {}, {
60
+ ...ts.sys,
61
+ onUnRecoverableConfigFileDiagnostic: diagnostic => {
62
+ throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
63
+ },
64
+ });
65
+ if (!config)
66
+ throw new Error(`openapi: cannot read ${join(appRoot, 'tsconfig.json')}`);
67
+ const source = renderEntry(entryFile, appRoot, routes);
68
+ const host = ts.createCompilerHost(config.options);
69
+ const getSourceFile = host.getSourceFile.bind(host);
70
+ host.getSourceFile = (fileName, languageVersion, ...rest) => fileName === entryFile
71
+ ? ts.createSourceFile(fileName, source, languageVersion)
72
+ : getSourceFile(fileName, languageVersion, ...rest);
73
+ const fileExists = host.fileExists.bind(host);
74
+ host.fileExists = fileName => fileName === entryFile || fileExists(fileName);
75
+ // The app's own files carry the module augmentations (container bindings, auth guards) the types depend on.
76
+ const program = ts.createProgram({
77
+ rootNames: [...config.fileNames, entryFile, ...(existsSync(dataFile) ? [dataFile] : [])],
78
+ options: { ...config.options, noEmit: true },
79
+ host,
80
+ });
81
+ this.#program = program;
82
+ this.checker = program.getTypeChecker();
83
+ const entry = program.getSourceFile(entryFile);
84
+ this.#routes = entry.statements.find(ts.isInterfaceDeclaration);
85
+ this.#validationError = entry.statements.find(ts.isTypeAliasDeclaration);
86
+ const validationError = this.validationError();
87
+ const named = new Map(this.#transformerTypes(program.getSourceFile(dataFile)));
88
+ if (validationError)
89
+ named.set(validationError, 'ValidationError');
90
+ this.schemas = new TypeToSchema(this.checker, named);
91
+ }
92
+ /**
93
+ * The types `node ace codegen` names in `Data`, keyed by their path below it (`Admin.User`).
94
+ */
95
+ #transformerTypes(dataFile) {
96
+ const module = dataFile && this.checker.getSymbolAtLocation(dataFile);
97
+ const data = module && this.checker.tryGetMemberInModuleExports('Data', module);
98
+ const walk = (namespace, path) => this.checker.getExportsOfModule(namespace).flatMap(symbol => {
99
+ if (symbol.name === 'Variants')
100
+ return [];
101
+ const name = [...path, symbol.name];
102
+ const nested = symbol.flags & ts.SymbolFlags.Namespace ? walk(symbol, name) : [];
103
+ if (!(symbol.flags & ts.SymbolFlags.TypeAlias))
104
+ return nested;
105
+ return [[this.checker.getDeclaredTypeOfSymbol(symbol), name.join('.')], ...nested];
106
+ });
107
+ return data ? walk(data, []) : [];
108
+ }
109
+ /**
110
+ * The types of one route's `request` and `response` members.
111
+ */
112
+ get(routeName) {
113
+ const member = this.#routes.members.find((candidate) => ts.isPropertySignature(candidate) &&
114
+ ts.isStringLiteral(candidate.name) &&
115
+ candidate.name.text === routeName);
116
+ if (!member)
117
+ return {};
118
+ const type = this.checker.getTypeAtLocation(member);
119
+ return { request: this.property(type, 'request'), response: this.property(type, 'response') };
120
+ }
121
+ validationError() {
122
+ return this.#validationError && this.checker.getTypeAtLocation(this.#validationError);
123
+ }
124
+ /**
125
+ * The doc comments on the handler, read from the controller class the file exports by default.
126
+ */
127
+ docs({ controller }) {
128
+ const file = controller && this.#program.getSourceFile(controller.path);
129
+ const module = file && this.checker.getSymbolAtLocation(file);
130
+ const exported = module && this.checker.tryGetMemberInModuleExports('default', module);
131
+ const symbol = exported && exported.flags & ts.SymbolFlags.Alias
132
+ ? this.checker.getAliasedSymbol(exported)
133
+ : exported;
134
+ const declaration = symbol?.declarations?.find(ts.isClassDeclaration);
135
+ const method = declaration?.members.find((member) => ts.isMethodDeclaration(member) &&
136
+ ts.isIdentifier(member.name) &&
137
+ member.name.text === controller?.method);
138
+ return declaration && method ? operationDocs(declaration, method) : { responses: {} };
139
+ }
140
+ property(type, name) {
141
+ const symbol = this.checker.getPropertyOfType(type, name);
142
+ return symbol && this.checker.getTypeOfSymbol(symbol);
143
+ }
144
+ }
145
+ function renderEntry(entryFile, appRoot, routes) {
146
+ const toRelative = (expression) => expression.replace(/import\('(app\/[^']+?)(\.ts)?'\)/g, (_, path) => {
147
+ const target = relative(join(entryFile, '..'), join(appRoot, path));
148
+ return `import('${target}.js')`;
149
+ });
150
+ const imports = new Set(routes.flatMap(route => route.imports));
151
+ const members = routes.map(route => {
152
+ const request = route.requestType ? `request: ${toRelative(route.requestType)}; ` : '';
153
+ const response = route.responseType
154
+ ? `response: Awaited<${toRelative(route.responseType)}>`
155
+ : '';
156
+ return ` ${JSON.stringify(route.name)}: { ${request}${response} }`;
157
+ });
158
+ const validationError = routes.some(route => route.validator)
159
+ ? `import type { SimpleError } from '@vinejs/vine/types'\nexport type ValidationError = { errors: SimpleError[] }`
160
+ : '';
161
+ const helpers = Object.entries(RESPONSE_HELPERS).map(([name, status]) => ` ${name}<T>(body?: T, etag?: boolean): { __response: T; __status: ${status} }`);
162
+ const augmentation = `declare module '@adonisjs/core/http' {\n interface HttpResponse {\n${helpers.join('\n')}\n }\n}`;
163
+ return [
164
+ ...imports,
165
+ validationError,
166
+ augmentation,
167
+ `export interface Routes {\n${members.join('\n')}\n}`,
168
+ ].join('\n');
169
+ }
@@ -0,0 +1,21 @@
1
+ import ts from 'typescript';
2
+ import type { JsonSchema } from './types.ts';
3
+ /**
4
+ * Converts a type the checker resolved into JSON Schema (draft 2020-12, as OpenAPI 3.1 uses).
5
+ * Serialized output types are plain data, so methods are dropped and dates become strings.
6
+ */
7
+ export declare class TypeToSchema {
8
+ #private;
9
+ readonly components: Record<string, JsonSchema>;
10
+ readonly unsupported: Set<string>;
11
+ /**
12
+ * Every named type becomes a component up front: a transformer nested in another
13
+ * resolves to a different type than its `Data` alias, and is matched by its schema.
14
+ */
15
+ constructor(checker: ts.TypeChecker, named?: Map<ts.Type, string>);
16
+ convert(type: ts.Type, stack?: ts.Type[]): JsonSchema;
17
+ /**
18
+ * Whether `undefined` is one of the members, which makes an object property optional.
19
+ */
20
+ acceptsUndefined(type: ts.Type): boolean;
21
+ }