@enzopita/adonisjs-openapi 0.1.0 → 0.2.1

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.
package/README.md CHANGED
@@ -7,8 +7,13 @@ Generate an OpenAPI 3.1 document from an AdonisJS 7 app, with no annotations. Th
7
7
  | Paths and operations | Named routes from the router |
8
8
  | Request body, query and path params | The VineJS validator passed to `request.validateUsing()` (formats, bounds and enums included) |
9
9
  | Responses | The return type of the controller method, such as `serialize(UserTransformer.transform(user))` |
10
+ | Status codes | Response helpers such as `response.created()`, plus `@throws` tags in the doc comment |
11
+ | `summary`, `description`, `tags` | The doc comment on the controller method and its class |
12
+ | Named schemas | The transformer types `node ace codegen` writes to `.adonisjs/client/data.d.ts` |
10
13
  | `422` response | Added to every route that validates its input |
11
- | `security` | Named middleware on the route, mapped in the config |
14
+ | `401` response and `security` | Named middleware on the route, mapped in the config |
15
+
16
+ Controllers, validators and transformers import nothing from this package. Everything it reads is code an AdonisJS app already has, or a doc comment.
12
17
 
13
18
  ## Install
14
19
 
@@ -16,7 +21,7 @@ Generate an OpenAPI 3.1 document from an AdonisJS 7 app, with no annotations. Th
16
21
  node ace add @enzopita/adonisjs-openapi
17
22
  ```
18
23
 
19
- The command installs the package, registers its ace command in `adonisrc.ts`, and creates `config/openapi.ts`. The package needs `@adonisjs/assembler`, which every AdonisJS app has as a dev dependency.
24
+ The command installs the package and creates `config/openapi.ts`. In `adonisrc.ts`, it registers the ace command and the build hook that writes the document during `node ace build`, and adds the document to `metaFiles`. The package needs `@adonisjs/assembler`, which every AdonisJS app has as a dev dependency.
20
25
 
21
26
  ## Generate the document
22
27
 
@@ -34,8 +39,13 @@ import { defineConfig } from '@enzopita/adonisjs-openapi'
34
39
 
35
40
  export default defineConfig({
36
41
  output: 'openapi.json',
37
- info: { title: 'Acme API', version: '1.0.0' },
42
+ info: {
43
+ title: 'Acme API',
44
+ version: '1.0.0',
45
+ license: { name: 'MIT', identifier: 'MIT' },
46
+ },
38
47
  servers: [{ url: 'https://api.acme.test' }],
48
+ tags: [{ name: 'Users', description: 'Public user profiles' }],
39
49
  routes: { except: ['admin.'] },
40
50
  securitySchemes: {
41
51
  accessToken: { type: 'http', scheme: 'bearer' },
@@ -47,11 +57,203 @@ export default defineConfig({
47
57
  ```
48
58
 
49
59
  - `routes` takes `only` or `except`, with the same matchers as Tuyau's `generateRegistry()`: a substring, a regular expression, or a function of the route name.
50
- - `middlewareSecurity` maps a named middleware, such as `middleware.auth()`, to a key of `securitySchemes`.
60
+ - `info` is the OpenAPI Info Object: `title` and `version`, and optionally `summary`, `description`, `termsOfService`, `contact` and `license`.
61
+ - `tags` is copied to the document's top-level `tags`. Use it to describe the tags operations use and to set their order.
62
+ - `middlewareSecurity` maps a named middleware, such as `middleware.auth()`, to a key of `securitySchemes`. Routes behind it get the scheme in `security` and a `401` response. A route behind several mapped middleware, such as `middleware.auth()` and `middleware.tenant()`, requires all their schemes together: they share one Security Requirement Object.
63
+
64
+ ## Serve a docs page
65
+
66
+ `openapiRoutes()` registers two routes: a page that renders the document with [Scalar](https://github.com/scalar/scalar), and the document itself.
67
+
68
+ ```ts
69
+ // start/routes.ts
70
+ import { middleware } from '#start/kernel'
71
+ import { openapiRoutes } from '@enzopita/adonisjs-openapi/routes'
72
+
73
+ openapiRoutes().use(middleware.auth({ guards: ['basic'] }))
74
+ ```
75
+
76
+ | Route | Name | Response |
77
+ | ------------------------ | ------------------ | ----------------------------------- |
78
+ | `GET /docs` | `openapi.ui` | The HTML page |
79
+ | `GET /docs/openapi.json` | `openapi.document` | The document, as `application/json` |
80
+
81
+ `openapiRoutes()` returns the route group, so `.use()`, `.domain()`, `.prefix()` and `.as()` work on it. `.as('admin')` names the routes `admin.openapi.ui` and `admin.openapi.document`.
82
+
83
+ The page loads the document from a URL relative to the page, so it keeps working behind a proxy that serves the app under a path prefix. The generator leaves both routes out of the document, as it does every closure route. Tuyau keeps named closure routes, so they appear in its client registry. To leave them out, exclude them where `adonisrc.ts` calls `generateRegistry()`:
84
+
85
+ ```ts
86
+ generateRegistry({ routes: { except: [/^openapi\./] } })
87
+ ```
88
+
89
+ ### Turn the page on or off
90
+
91
+ The routes exist only where `openapiRoutes()` runs. To serve the page outside production only:
92
+
93
+ ```ts
94
+ import app from '@adonisjs/core/services/app'
95
+
96
+ if (!app.inProduction) openapiRoutes()
97
+ ```
98
+
99
+ To turn it on with an environment variable, declare `OPENAPI_DOCS: Env.schema.boolean.optional()` in `start/env.ts`, then:
100
+
101
+ ```ts
102
+ import env from '#start/env'
103
+
104
+ if (env.get('OPENAPI_DOCS')) openapiRoutes().use(middleware.auth({ guards: ['basic'] }))
105
+ ```
106
+
107
+ To remove the page, delete the call. No other file of the app imports `@enzopita/adonisjs-openapi/routes`.
108
+
109
+ ### Options
110
+
111
+ | Option | Default | Description |
112
+ | ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
113
+ | `prefix` | `'/docs'` | Path of the page. The document is at `<prefix>/openapi.json`. |
114
+ | `ui` | `'scalar'` | `'scalar'`, `'swagger-ui'`, `'redoc'`, or a function that returns the page HTML. |
115
+ | `uiOptions` | `{}` | Passed as JSON to the UI's init call: `Scalar.createApiReference()`, `SwaggerUIBundle()` or Redoc's `init()`. |
116
+ | `cdn` | jsDelivr, see below | `{ script }` for Scalar and Redoc, `{ script, style }` for Swagger UI. Replaces the default URLs, for example to serve the files from `public/`. |
117
+ | `title` | `info.title` from the config | The page `<title>`. |
118
+ | `source` | `'file'` in production, `'live'` otherwise | `'live'` generates the document on each request. `'file'` serves the file `node ace openapi:generate` wrote. |
119
+ | `file` | `output` from the config | The file `source: 'file'` reads, relative to the app root. |
120
+ | `transform` | none | `(document, ctx) => document`, applied to each response. It receives a copy, so it can edit the document in place. |
121
+
122
+ The default assets come from jsDelivr, pinned to a major version:
123
+
124
+ | UI | Files |
125
+ | ------------ | ------------------------------------------------------------------------------- |
126
+ | `scalar` | `@scalar/api-reference@1/dist/browser/standalone.js` |
127
+ | `swagger-ui` | `swagger-ui-dist@5/swagger-ui-bundle.js` and `swagger-ui-dist@5/swagger-ui.css` |
128
+ | `redoc` | `redoc@3/bundles/redoc.standalone.js` |
129
+
130
+ A function `ui` receives `title`, `documentUrl` and `nonce`, and returns the whole page. `documentUrl` is relative to the page. `nonce` is set when Shield's CSP is enabled.
131
+
132
+ ```ts
133
+ openapiRoutes({
134
+ ui: ({ title, documentUrl }) => `<!doctype html>
135
+ <title>${title}</title>
136
+ <redoc spec-url="${documentUrl}"></redoc>
137
+ <script type="module" src="https://cdn.redoc.ly/redoc/v3.x/bundles/redoc.standalone.js"></script>`,
138
+ })
139
+ ```
140
+
141
+ `transform` can fill in what the config cannot know, such as the host a request came to:
142
+
143
+ ```ts
144
+ openapiRoutes({
145
+ transform: (document, { request }) => ({
146
+ ...document,
147
+ servers: [{ url: `${request.protocol()}://${request.host()}` }],
148
+ }),
149
+ })
150
+ ```
151
+
152
+ ### Where the document comes from
153
+
154
+ With `source: 'live'`, each request runs the generator in the app process. The page always matches the routes, with no command to run. The generator reads the TypeScript sources and needs `@adonisjs/assembler`, which the dev server and `node ace test` have.
155
+
156
+ With `source: 'file'`, the route serves the file `node ace openapi:generate` wrote. In production, the route reads the file once and keeps it in memory. Elsewhere, it reads the file on each request. When the file is missing, the route responds with a `500` error that names `node ace openapi:generate` and the build hook.
157
+
158
+ ### Ship the document with the build
159
+
160
+ A `node ace build` output has no TypeScript sources, and a production install has no `@adonisjs/assembler`. In production, the page serves the file instead. `node ace add` registers what writes the file and copies it into `build/`:
161
+
162
+ ```ts
163
+ // adonisrc.ts
164
+ export default defineConfig({
165
+ metaFiles: [{ pattern: 'openapi.json', reloadServer: false }],
166
+ hooks: {
167
+ buildStarting: [() => import('@enzopita/adonisjs-openapi/build_hook')],
168
+ },
169
+ })
170
+ ```
171
+
172
+ The hook runs `node ace openapi:generate` before `node ace build` compiles the app, and `metaFiles` copies `openapi.json` into `build/`. If you change `output` in the config, change the `metaFiles` pattern to match.
173
+
174
+ Without the hook, you can commit the generated file and keep it in `metaFiles`. Run `node ace openapi:generate` again each time the routes change.
175
+
176
+ ### Protect the page
177
+
178
+ A browser opens the page with a plain navigation. It sends cookies and HTTP basic credentials, but it has no way to add an `Authorization: Bearer` header. A guard that reads a bearer token, such as the access tokens guard, answers `401` to every visit from a browser. Use a guard that the browser can satisfy:
179
+
180
+ - The basic auth guard. On a `401`, the browser shows its own login prompt. Serve the page over HTTPS, because basic auth sends the password with every request.
181
+ - The session guard, if the app already has a login page.
182
+
183
+ The basic auth guard needs an entry in `config/auth.ts`:
184
+
185
+ ```ts
186
+ import { basicAuthGuard, basicAuthUserProvider } from '@adonisjs/auth/basic_auth'
187
+
188
+ const authConfig = defineConfig({
189
+ guards: {
190
+ basic: basicAuthGuard({
191
+ provider: basicAuthUserProvider({ model: () => import('#models/user') }),
192
+ }),
193
+ },
194
+ })
195
+ ```
196
+
197
+ ### Content Security Policy
198
+
199
+ Shield's CSP is off in a new app. If you turn it on, the page needs the directives below on top of `defaultSrc: ["'self'"]`. The page sets Shield's nonce on its scripts, so `@nonce` allows them. Each UI loads in Chromium with no CSP violation under these directives.
200
+
201
+ | UI | Directives |
202
+ | ------------ | ------------------------------------------------------------------------------------------------------------------ |
203
+ | `scalar` | `scriptSrc: ['@nonce']`, `styleSrc: ["'unsafe-inline'"]`, `fontSrc: ['https://fonts.scalar.com']` |
204
+ | `swagger-ui` | `scriptSrc: ['@nonce']`, `styleSrc: ['https://cdn.jsdelivr.net']`, `imgSrc: ['data:']` |
205
+ | `redoc` | `scriptSrc: ['@nonce']`, `styleSrc: ["'unsafe-inline'"]`, `fontSrc: ['data:']`, `imgSrc: ['https://cdn.redoc.ly']` |
206
+
207
+ Shield applies one policy to every route, so these directives apply to the whole app. A custom `cdn` replaces `https://cdn.jsdelivr.net` with the host it points to.
208
+
209
+ ## Document an operation
210
+
211
+ The generator reads the doc comment on the controller method, the same way the NestJS Swagger plugin does:
212
+
213
+ ```ts
214
+ /**
215
+ * @tags Users
216
+ */
217
+ export default class UsersController {
218
+ /**
219
+ * Show a user
220
+ *
221
+ * Looks the user up by its numeric id.
222
+ *
223
+ * @throws {404} User not found
224
+ */
225
+ async show({ request, serialize }: HttpContext) {
226
+ const { params } = await request.validateUsing(showUserValidator)
227
+
228
+ return serialize(UserTransformer.transform(await User.findOrFail(params.id)))
229
+ }
230
+ }
231
+ ```
232
+
233
+ - The first paragraph is the `summary`. The next paragraphs and `@remarks` are the `description`.
234
+ - `@deprecated` marks the operation as deprecated.
235
+ - `@tags Users, Admin` sets the operation tags. On the class, it applies to every method. A method's own `@tags` replaces the class tags. Without either, the tag is the first segment of the route name.
236
+ - `@throws {404} User not found` adds a `404` response with that description. Without a description, the standard status text is used. Tags whose type is not a three-digit code are ignored.
237
+
238
+ ## Status codes
239
+
240
+ A response comes from each status code the handler can return:
241
+
242
+ - `return serialize(...)` or a plain object is a `200`.
243
+ - `return response.created(await serialize(...))` is a `201` with the serialized body. Every `HttpResponse` helper that sets a 2xx, 4xx or 5xx status works this way, such as `noContent()`, `accepted()` or `notFound(body)`. This uses the same types Tuyau adds to `HttpResponse`, and works without Tuyau too.
244
+ - `204`, `205` and `304` responses, and helpers called without a body, have no `content`.
245
+ - A `@throws` tag adds its status code when the return type has not.
246
+ - Routes that validate their input get a `422`. Routes behind a middleware mapped in `middlewareSecurity` get a `401`.
247
+
248
+ ## Named schemas
249
+
250
+ When `.adonisjs/client/data.d.ts` exists, every type in its `Data` namespace becomes a schema in `components.schemas`, named by its path below `Data`: `User`, or `Admin.User` for a nested namespace. Responses reference them with `$ref`, including items of a collection and transformers nested in an object passed to `serialize()`. The `422` body is the `ValidationError` schema.
251
+
252
+ `node ace codegen` writes that file when `indexEntities()` in `adonisrc.ts` has transformers enabled. Without it, schemas are inlined.
51
253
 
52
254
  ## How types become schemas
53
255
 
54
- - A route must have a name and a controller handler. Closure routes are skipped, as they are in Tuyau.
256
+ - A route must have a name and a controller handler. Closure routes are skipped.
55
257
  - On `GET` routes, the validator fields become query parameters. On other methods they become the body, and the validator's `query` and `params` objects become query and path parameters.
56
258
  - A body with a `vine.file()` field is sent as `multipart/form-data`.
57
259
  - When VineJS cannot describe a field (a file, a custom rule), the generator uses the field's TypeScript type.
@@ -60,6 +262,7 @@ export default defineConfig({
60
262
 
61
263
  ## Limitations
62
264
 
63
- - Schemas are inlined. Transformers do not become named components yet.
64
- - Operations have no `summary` or `description` yet.
65
- - Status codes other than `200` and `422` come only from return types that carry a `__status`, which no AdonisJS 7.6 API produces yet.
265
+ - A status code set with `response.status(201)` is not detected, because the return type does not carry it. Return a helper such as `response.created(body)` instead.
266
+ - A thrown exception has no return type either. Add a `@throws` tag for each status code it can produce, such as the `404` of `findOrFail()`.
267
+ - A nested transformer is matched to its named schema by shape. Two transformers with the same output share the first schema name.
268
+ - Every type in `Data` is written to `components.schemas`, even if no route returns it.
@@ -4,5 +4,13 @@ export async function configure(command) {
4
4
  await codemods.makeUsingStub(stubsRoot, 'config/openapi.stub', {});
5
5
  await codemods.updateRcFile(rcFile => {
6
6
  rcFile.addCommand('@enzopita/adonisjs-openapi/commands');
7
+ rcFile.addAssemblerHook('buildStarting', '@enzopita/adonisjs-openapi/build_hook');
8
+ rcFile.addMetaFile('openapi.json');
7
9
  });
10
+ command.ui
11
+ .instructions()
12
+ .heading('To serve the docs page, register its routes in start/routes.ts')
13
+ .add(`import { openapiRoutes } from '@enzopita/adonisjs-openapi/routes'`)
14
+ .add(`openapiRoutes()`)
15
+ .render();
8
16
  }
package/build/index.d.ts CHANGED
@@ -1,4 +1,9 @@
1
+ import type { generateDocument as generate } from './src/generate.ts';
1
2
  export { configure } from './configure.ts';
2
3
  export { defineConfig } from './src/define_config.ts';
3
- export { generateDocument } from './src/generate.ts';
4
4
  export { stubsRoot } from './stubs/main.ts';
5
+ /**
6
+ * Loads the generator on call. `config/openapi.ts` imports this entry on every boot, and the
7
+ * generator pulls in the TypeScript compiler, and the assembler that a production install leaves out.
8
+ */
9
+ export declare function generateDocument(...args: Parameters<typeof generate>): Promise<import("./src/generate.ts").GeneratedDocument>;
package/build/index.js CHANGED
@@ -1,4 +1,11 @@
1
1
  export { configure } from "./configure.js";
2
2
  export { defineConfig } from "./src/define_config.js";
3
- export { generateDocument } from "./src/generate.js";
4
3
  export { stubsRoot } from "./stubs/main.js";
4
+ /**
5
+ * Loads the generator on call. `config/openapi.ts` imports this entry on every boot, and the
6
+ * generator pulls in the TypeScript compiler, and the assembler that a production install leaves out.
7
+ */
8
+ export async function generateDocument(...args) {
9
+ const { generateDocument: run } = await import("./src/generate.js");
10
+ return run(...args);
11
+ }
@@ -0,0 +1,7 @@
1
+ import type { Bundler } from '@adonisjs/assembler';
2
+ /**
3
+ * Runs before `node ace build` compiles the app and copies the meta files, so the
4
+ * document lands in the build folder when its path is listed in `metaFiles`.
5
+ */
6
+ declare const generateOnBuild: (bundler: Bundler) => void | PromiseLike<void>;
7
+ export default generateOnBuild;
@@ -0,0 +1,18 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { hooks } from '@adonisjs/core/app';
4
+ /**
5
+ * Runs before `node ace build` compiles the app and copies the meta files, so the
6
+ * document lands in the build folder when its path is listed in `metaFiles`.
7
+ */
8
+ const generateOnBuild = hooks.buildStarting(async (bundler) => {
9
+ bundler.ui.logger.info('generating the OpenAPI document', { suffix: 'openapi:generate' });
10
+ const child = spawn(process.execPath, ['ace', 'openapi:generate'], {
11
+ cwd: bundler.cwdPath,
12
+ stdio: 'inherit',
13
+ });
14
+ const [code] = await once(child, 'close');
15
+ if (code !== 0)
16
+ throw new Error(`"node ace openapi:generate" exited with code ${code}`);
17
+ });
18
+ export default generateOnBuild;
@@ -31,11 +31,13 @@ export async function collectRoutes(appRoot, router, config) {
31
31
  const names = [...route.middleware.all()].flatMap(entry => 'name' in entry && entry.name ? [entry.name] : []);
32
32
  middlewareByRoute.set(`${route.domain}:${route.pattern}:${route.methods.join(',')}`, names);
33
33
  }
34
- // Round-tripped through JSON like the dev server and `node ace codegen` do, which drops closure handlers.
35
- const serialized = JSON.parse(JSON.stringify(live));
34
+ // A closure has no types to read, and the scanner keeps it when it is named.
35
+ const controllerRoutes = Object.values(live).map(routes => routes.filter(route => !(route.handler instanceof Function)));
36
+ // Round-tripped through JSON like the dev server and `node ace codegen` do.
37
+ const serialized = JSON.parse(JSON.stringify(controllerRoutes));
36
38
  const scanner = new RoutesScanner(appRoot, []);
37
39
  scanner.filter(route => isIncluded(route.name ?? '', config.routes));
38
- for (const routes of Object.values(serialized))
40
+ for (const routes of serialized)
39
41
  await scanner.scan(routes);
40
42
  return scanner.getScannedRoutes().map(route => {
41
43
  const pattern = toOpenApiPath(route.pattern);
@@ -55,6 +57,10 @@ export async function collectRoutes(appRoot, router, config) {
55
57
  specifier: validator.import.specifier,
56
58
  exportName: validator.import.value,
57
59
  },
60
+ controller: route.controller && {
61
+ path: route.controller.path,
62
+ method: route.controller.method,
63
+ },
58
64
  };
59
65
  });
60
66
  }
@@ -6,6 +6,7 @@ export function defineConfig(config = {}) {
6
6
  output: 'openapi.json',
7
7
  info: { title: 'API', version: '1.0.0' },
8
8
  servers: [],
9
+ tags: [],
9
10
  routes: {},
10
11
  securitySchemes: {},
11
12
  middlewareSecurity: {},
@@ -0,0 +1,8 @@
1
+ import type { DocsPage, UiOptions } from '../types.ts';
2
+ export declare function escapeHtml(text: string): string;
3
+ /**
4
+ * `<` is escaped so that a `</script>` inside a string cannot close the script it is embedded in.
5
+ */
6
+ export declare function scriptJson(value: UiOptions | string): string;
7
+ export declare function script(page: DocsPage, attributes: string, code?: string): string;
8
+ export declare function htmlPage(page: DocsPage, head: string[], body: string[]): string;
@@ -0,0 +1,37 @@
1
+ const ENTITIES = new Map([
2
+ ['&', '&amp;'],
3
+ ['<', '&lt;'],
4
+ ['>', '&gt;'],
5
+ ['"', '&quot;'],
6
+ ["'", '&#39;'],
7
+ ]);
8
+ export function escapeHtml(text) {
9
+ return text.replace(/[&<>"']/g, char => ENTITIES.get(char) ?? char);
10
+ }
11
+ /**
12
+ * `<` is escaped so that a `</script>` inside a string cannot close the script it is embedded in.
13
+ */
14
+ export function scriptJson(value) {
15
+ return JSON.stringify(value).replace(/</g, '\\u003c');
16
+ }
17
+ export function script(page, attributes, code = '') {
18
+ const nonce = page.nonce ? ` nonce="${escapeHtml(page.nonce)}"` : '';
19
+ return `<script${nonce}${attributes}>${code}</script>`;
20
+ }
21
+ export function htmlPage(page, head, body) {
22
+ return [
23
+ '<!doctype html>',
24
+ '<html lang="en">',
25
+ '<head>',
26
+ '<meta charset="utf-8" />',
27
+ '<meta name="viewport" content="width=device-width, initial-scale=1" />',
28
+ `<title>${escapeHtml(page.title)}</title>`,
29
+ ...head,
30
+ '</head>',
31
+ '<body>',
32
+ ...body,
33
+ '</body>',
34
+ '</html>',
35
+ '',
36
+ ].join('\n');
37
+ }
@@ -0,0 +1,14 @@
1
+ import type { DocsPage, UiOptions } from '../types.ts';
2
+ declare const cdn: {
3
+ script: string;
4
+ };
5
+ export declare const redoc: {
6
+ cdn: {
7
+ script: string;
8
+ };
9
+ /**
10
+ * Redoc 3 ships its standalone bundle as an ES module, so `init` is imported instead of read from `window`.
11
+ */
12
+ render(page: DocsPage, assets: typeof cdn, uiOptions: UiOptions): string;
13
+ };
14
+ export {};
@@ -0,0 +1,17 @@
1
+ import { htmlPage, script, scriptJson } from "./html.js";
2
+ const cdn = {
3
+ script: 'https://cdn.jsdelivr.net/npm/redoc@3/bundles/redoc.standalone.js',
4
+ };
5
+ export const redoc = {
6
+ cdn,
7
+ /**
8
+ * Redoc 3 ships its standalone bundle as an ES module, so `init` is imported instead of read from `window`.
9
+ */
10
+ render(page, assets, uiOptions) {
11
+ const init = [
12
+ `import { init } from ${scriptJson(assets.script)}`,
13
+ `init(${scriptJson(page.documentUrl)}, ${scriptJson(uiOptions)}, document.getElementById('redoc'))`,
14
+ ].join('\n');
15
+ return htmlPage(page, [], ['<div id="redoc"></div>', script(page, ' type="module"', init)]);
16
+ },
17
+ };
@@ -0,0 +1,6 @@
1
+ import type { DocsOptions } from '../types.ts';
2
+ /**
3
+ * Registers the docs page and the document it renders, as closure routes so the
4
+ * generator leaves them out of the document.
5
+ */
6
+ export declare function openapiRoutes(options?: DocsOptions): import("@adonisjs/core/http").RouteGroup;
@@ -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
+ }
@@ -50,28 +50,35 @@ async function validatorSchemas(app, routes, warnings) {
50
50
  }
51
51
  return schemas;
52
52
  }
53
- function responsesFor(route, types, response, warnings) {
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) {
54
56
  const responses = {};
55
- const describe = (code, schema) => {
56
- responses[code] = {
57
- description: STATUS_CODES[Number(code)] ?? 'Response',
58
- content: { 'application/json': { schema } },
59
- };
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) } } };
60
63
  };
61
64
  const members = response?.isUnion() ? response.types : response ? [response] : [];
62
65
  for (const member of members) {
63
66
  const status = types.property(member, '__status');
64
67
  const payload = types.property(member, '__response');
65
68
  if (status && payload) {
66
- describe(status.isNumberLiteral() ? String(status.value) : 'default', types.schemas.convert(payload));
69
+ describe(status.isNumberLiteral() ? String(status.value) : 'default', payload);
67
70
  }
68
71
  else if (!(member.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))) {
69
- describe('200', types.schemas.convert(member));
72
+ describe('200', member);
70
73
  }
71
74
  }
75
+ for (const [code, description] of Object.entries(thrown))
76
+ responses[code] ??= { description };
72
77
  const validationError = route.validator && types.validationError();
73
- if (validationError)
74
- describe('422', types.schemas.convert(validationError));
78
+ if (validationError && !responses['422'])
79
+ describe('422', validationError);
80
+ if (secured && !responses['401'])
81
+ describe('401');
75
82
  if (!Object.keys(responses).some(code => code.startsWith('2') || code === 'default')) {
76
83
  warnings.push(`${route.name}: the handler's return type is unknown, returning serialize(...) makes it typed`);
77
84
  responses['200'] = { description: 'OK' };
@@ -108,13 +115,16 @@ export async function generateDocument(app, router, config) {
108
115
  const body = readsQueryOnly ? undefined : fields;
109
116
  const query = readsQueryOnly ? fields : input?.properties?.query;
110
117
  const parameters = parametersFor(route, query, input?.properties?.params);
111
- const security = route.middleware.flatMap(name => {
112
- const scheme = config.middlewareSecurity[name];
113
- return scheme ? [{ [scheme]: [] }] : [];
114
- });
118
+ // Entries of `security` are alternatives, so the schemes a route requires together share one entry.
119
+ const schemes = route.middleware.flatMap(name => config.middlewareSecurity[name] ?? []);
120
+ const security = schemes.length
121
+ ? [Object.fromEntries(schemes.map(scheme => [scheme, []]))]
122
+ : [];
123
+ const { responses: thrown, ...docs } = types.docs(route);
115
124
  const operation = {
116
- tags: [route.name.split('.')[0] ?? route.name],
117
- responses: responsesFor(route, types, response, warnings),
125
+ ...docs,
126
+ tags: docs.tags ?? [route.name.split('.')[0] ?? route.name],
127
+ responses: responsesFor(route, types, response, thrown, security.length > 0, warnings),
118
128
  };
119
129
  if (parameters.length)
120
130
  operation.parameters = parameters;
@@ -144,5 +154,7 @@ export async function generateDocument(app, router, config) {
144
154
  };
145
155
  if (config.servers.length)
146
156
  document.servers = config.servers;
157
+ if (config.tags.length)
158
+ document.tags = config.tags;
147
159
  return { document, warnings };
148
160
  }
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript';
2
+ import { type OperationDocs } from './operation_docs.ts';
2
3
  import { TypeToSchema } from './type_to_schema.ts';
3
4
  import type { RouteOperation } from './types.ts';
4
5
  /**
@@ -21,5 +22,9 @@ export declare class RouteTypes {
21
22
  response: ts.Type | undefined;
22
23
  };
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;
24
29
  property(type: ts.Type, name: string): ts.Type | undefined;
25
30
  }
@@ -1,6 +1,48 @@
1
1
  import ts from 'typescript';
2
+ import { existsSync } from 'node:fs';
2
3
  import { join, relative } from 'node:path';
4
+ import { operationDocs } from "./operation_docs.js";
3
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
+ };
4
46
  /**
5
47
  * Resolves the request and response types of every route with the app's own
6
48
  * tsconfig, through one in-memory module that names them all.
@@ -8,10 +50,12 @@ import { TypeToSchema } from "./type_to_schema.js";
8
50
  export class RouteTypes {
9
51
  checker;
10
52
  schemas;
53
+ #program;
11
54
  #routes;
12
55
  #validationError;
13
56
  constructor(appRoot, routes) {
14
57
  const entryFile = join(appRoot, '.adonisjs/openapi/routes.ts');
58
+ const dataFile = join(appRoot, '.adonisjs/client/data.d.ts');
15
59
  const config = ts.getParsedCommandLineOfConfigFile(join(appRoot, 'tsconfig.json'), {}, {
16
60
  ...ts.sys,
17
61
  onUnRecoverableConfigFileDiagnostic: diagnostic => {
@@ -30,15 +74,37 @@ export class RouteTypes {
30
74
  host.fileExists = fileName => fileName === entryFile || fileExists(fileName);
31
75
  // The app's own files carry the module augmentations (container bindings, auth guards) the types depend on.
32
76
  const program = ts.createProgram({
33
- rootNames: [...config.fileNames, entryFile],
77
+ rootNames: [...config.fileNames, entryFile, ...(existsSync(dataFile) ? [dataFile] : [])],
34
78
  options: { ...config.options, noEmit: true },
35
79
  host,
36
80
  });
81
+ this.#program = program;
37
82
  this.checker = program.getTypeChecker();
38
- this.schemas = new TypeToSchema(this.checker);
39
83
  const entry = program.getSourceFile(entryFile);
40
84
  this.#routes = entry.statements.find(ts.isInterfaceDeclaration);
41
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, []) : [];
42
108
  }
43
109
  /**
44
110
  * The types of one route's `request` and `response` members.
@@ -55,6 +121,22 @@ export class RouteTypes {
55
121
  validationError() {
56
122
  return this.#validationError && this.checker.getTypeAtLocation(this.#validationError);
57
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
+ }
58
140
  property(type, name) {
59
141
  const symbol = this.checker.getPropertyOfType(type, name);
60
142
  return symbol && this.checker.getTypeOfSymbol(symbol);
@@ -76,5 +158,12 @@ function renderEntry(entryFile, appRoot, routes) {
76
158
  const validationError = routes.some(route => route.validator)
77
159
  ? `import type { SimpleError } from '@vinejs/vine/types'\nexport type ValidationError = { errors: SimpleError[] }`
78
160
  : '';
79
- return [...imports, validationError, `export interface Routes {\n${members.join('\n')}\n}`].join('\n');
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');
80
169
  }
@@ -8,7 +8,11 @@ export declare class TypeToSchema {
8
8
  #private;
9
9
  readonly components: Record<string, JsonSchema>;
10
10
  readonly unsupported: Set<string>;
11
- constructor(checker: ts.TypeChecker);
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>);
12
16
  convert(type: ts.Type, stack?: ts.Type[]): JsonSchema;
13
17
  /**
14
18
  * Whether `undefined` is one of the members, which makes an object property optional.
@@ -9,10 +9,24 @@ const BINARY = new Set(['MultipartFile', 'File', 'Blob']);
9
9
  export class TypeToSchema {
10
10
  #checker;
11
11
  #recursive = new Map();
12
+ #named;
13
+ #namesBySchema = new Map();
12
14
  components = {};
13
15
  unsupported = new Set();
14
- constructor(checker) {
16
+ /**
17
+ * Every named type becomes a component up front: a transformer nested in another
18
+ * resolves to a different type than its `Data` alias, and is matched by its schema.
19
+ */
20
+ constructor(checker, named = new Map()) {
15
21
  this.#checker = checker;
22
+ this.#named = named;
23
+ for (const [type, name] of named) {
24
+ const schema = this.#properties(type, []);
25
+ const serialized = JSON.stringify(schema);
26
+ this.components[name] = schema;
27
+ if (!this.#namesBySchema.has(serialized))
28
+ this.#namesBySchema.set(serialized, name);
29
+ }
16
30
  }
17
31
  convert(type, stack = []) {
18
32
  const flags = type.flags;
@@ -93,8 +107,21 @@ export class TypeToSchema {
93
107
  return { anyOf: schemas };
94
108
  }
95
109
  #object(type, name, stack) {
110
+ const named = this.#named.get(type);
111
+ if (named)
112
+ return { $ref: `#/components/schemas/${named}` };
96
113
  if (stack.includes(type))
97
114
  return { $ref: `#/components/schemas/${this.#recursiveName(type, name)}` };
115
+ const schema = this.#properties(type, stack);
116
+ const ref = this.#recursive.get(type);
117
+ if (ref) {
118
+ this.components[ref] = schema;
119
+ return { $ref: `#/components/schemas/${ref}` };
120
+ }
121
+ const shared = this.#namesBySchema.get(JSON.stringify(schema));
122
+ return shared ? { $ref: `#/components/schemas/${shared}` } : schema;
123
+ }
124
+ #properties(type, stack) {
98
125
  const next = [...stack, type];
99
126
  const properties = {};
100
127
  const required = [];
@@ -115,11 +142,6 @@ export class TypeToSchema {
115
142
  };
116
143
  if (required.length)
117
144
  schema.required = required;
118
- const ref = this.#recursive.get(type);
119
- if (ref) {
120
- this.components[ref] = schema;
121
- return { $ref: `#/components/schemas/${ref}` };
122
- }
123
145
  return schema;
124
146
  }
125
147
  #recursiveName(type, name) {
@@ -1,3 +1,4 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
1
2
  export type JsonValue = string | number | boolean | null;
2
3
  /**
3
4
  * The JSON Schema keywords the generator reads or writes. Keywords Vine adds
@@ -39,12 +40,31 @@ export interface OpenApiConfig {
39
40
  info: {
40
41
  title: string;
41
42
  version: string;
43
+ summary?: string;
42
44
  description?: string;
45
+ termsOfService?: string;
46
+ contact?: {
47
+ name?: string;
48
+ url?: string;
49
+ email?: string;
50
+ };
51
+ license?: {
52
+ name: string;
53
+ url?: string;
54
+ identifier?: string;
55
+ };
43
56
  };
44
57
  servers: {
45
58
  url: string;
46
59
  description?: string;
47
60
  }[];
61
+ /**
62
+ * Written as the document's top-level `tags`, to describe and order the tags operations use.
63
+ */
64
+ tags: {
65
+ name: string;
66
+ description?: string;
67
+ }[];
48
68
  /**
49
69
  * Same filters as Tuyau's `generateRegistry({ routes })`. Only one of them can be set.
50
70
  */
@@ -76,6 +96,10 @@ export interface RouteOperation {
76
96
  specifier: string;
77
97
  exportName: string;
78
98
  };
99
+ controller?: {
100
+ path: string;
101
+ method: string;
102
+ };
79
103
  }
80
104
  export interface OpenApiParameter {
81
105
  name: string;
@@ -91,7 +115,10 @@ export interface OpenApiResponse {
91
115
  }
92
116
  export interface OpenApiOperation {
93
117
  operationId: string;
118
+ summary?: string;
119
+ description?: string;
94
120
  tags: string[];
121
+ deprecated?: true;
95
122
  parameters?: OpenApiParameter[];
96
123
  requestBody?: {
97
124
  required: true;
@@ -106,9 +133,74 @@ export interface OpenApiDocument {
106
133
  openapi: '3.1.0';
107
134
  info: OpenApiConfig['info'];
108
135
  servers?: OpenApiConfig['servers'];
136
+ tags?: OpenApiConfig['tags'];
109
137
  paths: Record<string, Partial<Record<HttpMethod, OpenApiOperation>>>;
110
138
  components: {
111
139
  schemas: Record<string, JsonSchema>;
112
140
  securitySchemes: OpenApiConfig['securitySchemes'];
113
141
  };
114
142
  }
143
+ /**
144
+ * What a docs UI needs to render its page.
145
+ */
146
+ export interface DocsPage {
147
+ title: string;
148
+ /**
149
+ * Relative to the page, so the page keeps working behind a proxy that adds a path prefix.
150
+ */
151
+ documentUrl: string;
152
+ /**
153
+ * The CSP nonce Shield sets on the response, when CSP is enabled.
154
+ */
155
+ nonce?: string;
156
+ }
157
+ export type Json = JsonValue | Json[] | {
158
+ [key: string]: Json;
159
+ };
160
+ /**
161
+ * Serialized as JSON into the UI's own init call.
162
+ */
163
+ export type UiOptions = {
164
+ [key: string]: Json;
165
+ };
166
+ export type DocsUi = {
167
+ ui?: 'scalar';
168
+ uiOptions?: UiOptions;
169
+ cdn?: {
170
+ script?: string;
171
+ };
172
+ } | {
173
+ ui: 'swagger-ui';
174
+ uiOptions?: UiOptions;
175
+ cdn?: {
176
+ script?: string;
177
+ style?: string;
178
+ };
179
+ } | {
180
+ ui: 'redoc';
181
+ uiOptions?: UiOptions;
182
+ cdn?: {
183
+ script?: string;
184
+ };
185
+ } | {
186
+ ui: (page: DocsPage) => string;
187
+ uiOptions?: never;
188
+ cdn?: never;
189
+ };
190
+ export type DocsOptions = DocsUi & {
191
+ /**
192
+ * Path of the page. The document is served at `<prefix>/openapi.json`.
193
+ */
194
+ prefix?: string;
195
+ title?: string;
196
+ /**
197
+ * `live` generates the document on each request and needs the app's TypeScript
198
+ * sources. `file` serves the file `node ace openapi:generate` wrote.
199
+ */
200
+ source?: 'live' | 'file';
201
+ /**
202
+ * The file `source: 'file'` reads, relative to the app root. Defaults to the config `output`.
203
+ */
204
+ file?: string;
205
+ transform?: (document: OpenApiDocument, ctx: HttpContext) => OpenApiDocument | Promise<OpenApiDocument>;
206
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enzopita/adonisjs-openapi",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generate an OpenAPI document from the routes, validators and transformers of an AdonisJS app",
5
5
  "keywords": [
6
6
  "adonisjs",
@@ -25,6 +25,8 @@
25
25
  "exports": {
26
26
  ".": "./build/index.js",
27
27
  "./commands": "./build/commands/main.js",
28
+ "./routes": "./build/src/docs/routes.js",
29
+ "./build_hook": "./build/src/build_hook.js",
28
30
  "./types": "./build/src/types.js"
29
31
  },
30
32
  "publishConfig": {