@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Enzo Pita <enzo@enzopita.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,264 @@
1
- # Temporary Holding Version
1
+ # @enzopita/adonisjs-openapi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Generate an OpenAPI 3.1 document from an AdonisJS 7 app, with no annotations. The generator reads the same sources as [Tuyau](https://github.com/Julien-R44/tuyau):
4
+
5
+ | OpenAPI | Source |
6
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
7
+ | Paths and operations | Named routes from the router |
8
+ | Request body, query and path params | The VineJS validator passed to `request.validateUsing()` (formats, bounds and enums included) |
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` |
13
+ | `422` response | Added to every route that validates its input |
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.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ node ace add @enzopita/adonisjs-openapi
22
+ ```
23
+
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.
25
+
26
+ ## Generate the document
27
+
28
+ ```sh
29
+ node ace openapi:generate
30
+ ```
31
+
32
+ The command boots the app the way `node ace codegen` does, so no database connection or queue worker starts. It writes `openapi.json` at the app root. To write it somewhere else, pass `--output=public/openapi.json` or set `output` in the config.
33
+
34
+ ## Configure
35
+
36
+ ```ts
37
+ // config/openapi.ts
38
+ import { defineConfig } from '@enzopita/adonisjs-openapi'
39
+
40
+ export default defineConfig({
41
+ output: 'openapi.json',
42
+ info: {
43
+ title: 'Acme API',
44
+ version: '1.0.0',
45
+ license: { name: 'MIT', identifier: 'MIT' },
46
+ },
47
+ servers: [{ url: 'https://api.acme.test' }],
48
+ tags: [{ name: 'Users', description: 'Public user profiles' }],
49
+ routes: { except: ['admin.'] },
50
+ securitySchemes: {
51
+ accessToken: { type: 'http', scheme: 'bearer' },
52
+ },
53
+ middlewareSecurity: {
54
+ auth: 'accessToken',
55
+ },
56
+ })
57
+ ```
58
+
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.
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.
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.
84
+
85
+ ### Turn the page on or off
86
+
87
+ The routes exist only where `openapiRoutes()` runs. To serve the page outside production only:
88
+
89
+ ```ts
90
+ import app from '@adonisjs/core/services/app'
91
+
92
+ if (!app.inProduction) openapiRoutes()
93
+ ```
94
+
95
+ To turn it on with an environment variable, declare `OPENAPI_DOCS: Env.schema.boolean.optional()` in `start/env.ts`, then:
96
+
97
+ ```ts
98
+ import env from '#start/env'
99
+
100
+ if (env.get('OPENAPI_DOCS')) openapiRoutes().use(middleware.auth({ guards: ['basic'] }))
101
+ ```
102
+
103
+ To remove the page, delete the call. No other file of the app imports `@enzopita/adonisjs-openapi/routes`.
104
+
105
+ ### Options
106
+
107
+ | Option | Default | Description |
108
+ | ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
109
+ | `prefix` | `'/docs'` | Path of the page. The document is at `<prefix>/openapi.json`. |
110
+ | `ui` | `'scalar'` | `'scalar'`, `'swagger-ui'`, `'redoc'`, or a function that returns the page HTML. |
111
+ | `uiOptions` | `{}` | Passed as JSON to the UI's init call: `Scalar.createApiReference()`, `SwaggerUIBundle()` or Redoc's `init()`. |
112
+ | `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/`. |
113
+ | `title` | `info.title` from the config | The page `<title>`. |
114
+ | `source` | `'file'` in production, `'live'` otherwise | `'live'` generates the document on each request. `'file'` serves the file `node ace openapi:generate` wrote. |
115
+ | `file` | `output` from the config | The file `source: 'file'` reads, relative to the app root. |
116
+ | `transform` | none | `(document, ctx) => document`, applied to each response. It receives a copy, so it can edit the document in place. |
117
+
118
+ The default assets come from jsDelivr, pinned to a major version:
119
+
120
+ | UI | Files |
121
+ | ------------ | ------------------------------------------------------------------------------- |
122
+ | `scalar` | `@scalar/api-reference@1/dist/browser/standalone.js` |
123
+ | `swagger-ui` | `swagger-ui-dist@5/swagger-ui-bundle.js` and `swagger-ui-dist@5/swagger-ui.css` |
124
+ | `redoc` | `redoc@3/bundles/redoc.standalone.js` |
125
+
126
+ 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.
127
+
128
+ ```ts
129
+ openapiRoutes({
130
+ ui: ({ title, documentUrl }) => `<!doctype html>
131
+ <title>${title}</title>
132
+ <redoc spec-url="${documentUrl}"></redoc>
133
+ <script type="module" src="https://cdn.redoc.ly/redoc/v3.x/bundles/redoc.standalone.js"></script>`,
134
+ })
135
+ ```
136
+
137
+ `transform` can fill in what the config cannot know, such as the host a request came to:
138
+
139
+ ```ts
140
+ openapiRoutes({
141
+ transform: (document, { request }) => ({
142
+ ...document,
143
+ servers: [{ url: `${request.protocol()}://${request.host()}` }],
144
+ }),
145
+ })
146
+ ```
147
+
148
+ ### Where the document comes from
149
+
150
+ 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.
151
+
152
+ 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.
153
+
154
+ ### Ship the document with the build
155
+
156
+ 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/`:
157
+
158
+ ```ts
159
+ // adonisrc.ts
160
+ export default defineConfig({
161
+ metaFiles: [{ pattern: 'openapi.json', reloadServer: false }],
162
+ hooks: {
163
+ buildStarting: [() => import('@enzopita/adonisjs-openapi/build_hook')],
164
+ },
165
+ })
166
+ ```
167
+
168
+ 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.
169
+
170
+ 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.
171
+
172
+ ### Protect the page
173
+
174
+ 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:
175
+
176
+ - 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.
177
+ - The session guard, if the app already has a login page.
178
+
179
+ The basic auth guard needs an entry in `config/auth.ts`:
180
+
181
+ ```ts
182
+ import { basicAuthGuard, basicAuthUserProvider } from '@adonisjs/auth/basic_auth'
183
+
184
+ const authConfig = defineConfig({
185
+ guards: {
186
+ basic: basicAuthGuard({
187
+ provider: basicAuthUserProvider({ model: () => import('#models/user') }),
188
+ }),
189
+ },
190
+ })
191
+ ```
192
+
193
+ ### Content Security Policy
194
+
195
+ 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.
196
+
197
+ | UI | Directives |
198
+ | ------------ | ------------------------------------------------------------------------------------------------------------------ |
199
+ | `scalar` | `scriptSrc: ['@nonce']`, `styleSrc: ["'unsafe-inline'"]`, `fontSrc: ['https://fonts.scalar.com']` |
200
+ | `swagger-ui` | `scriptSrc: ['@nonce']`, `styleSrc: ['https://cdn.jsdelivr.net']`, `imgSrc: ['data:']` |
201
+ | `redoc` | `scriptSrc: ['@nonce']`, `styleSrc: ["'unsafe-inline'"]`, `fontSrc: ['data:']`, `imgSrc: ['https://cdn.redoc.ly']` |
202
+
203
+ 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.
204
+
205
+ ## Document an operation
206
+
207
+ The generator reads the doc comment on the controller method, the same way the NestJS Swagger plugin does:
208
+
209
+ ```ts
210
+ /**
211
+ * @tags Users
212
+ */
213
+ export default class UsersController {
214
+ /**
215
+ * Show a user
216
+ *
217
+ * Looks the user up by its numeric id.
218
+ *
219
+ * @throws {404} User not found
220
+ */
221
+ async show({ request, serialize }: HttpContext) {
222
+ const { params } = await request.validateUsing(showUserValidator)
223
+
224
+ return serialize(UserTransformer.transform(await User.findOrFail(params.id)))
225
+ }
226
+ }
227
+ ```
228
+
229
+ - The first paragraph is the `summary`. The next paragraphs and `@remarks` are the `description`.
230
+ - `@deprecated` marks the operation as deprecated.
231
+ - `@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.
232
+ - `@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.
233
+
234
+ ## Status codes
235
+
236
+ A response comes from each status code the handler can return:
237
+
238
+ - `return serialize(...)` or a plain object is a `200`.
239
+ - `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.
240
+ - `204`, `205` and `304` responses, and helpers called without a body, have no `content`.
241
+ - A `@throws` tag adds its status code when the return type has not.
242
+ - Routes that validate their input get a `422`. Routes behind a middleware mapped in `middlewareSecurity` get a `401`.
243
+
244
+ ## Named schemas
245
+
246
+ 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.
247
+
248
+ `node ace codegen` writes that file when `indexEntities()` in `adonisrc.ts` has transformers enabled. Without it, schemas are inlined.
249
+
250
+ ## How types become schemas
251
+
252
+ - A route must have a name and a controller handler. Closure routes are skipped, as they are in Tuyau.
253
+ - 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.
254
+ - A body with a `vine.file()` field is sent as `multipart/form-data`.
255
+ - When VineJS cannot describe a field (a file, a custom rule), the generator uses the field's TypeScript type.
256
+ - `Date` and Luxon `DateTime` values become `{ type: 'string', format: 'date-time' }`.
257
+ - A handler that returns a model or an untyped object produces a `200` response with no schema and a warning. Return `serialize(...)` to type it.
258
+
259
+ ## Limitations
260
+
261
+ - 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.
262
+ - 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()`.
263
+ - A nested transformer is matched to its named schema by shape. Two transformers with the same output share the first schema name.
264
+ - Every type in `Data` is written to `components.schemas`, even if no route returns it.
@@ -0,0 +1,8 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ export default class GenerateOpenApi extends BaseCommand {
3
+ #private;
4
+ static commandName: string;
5
+ static description: string;
6
+ output?: string;
7
+ run(): Promise<void>;
8
+ }
@@ -0,0 +1,49 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { dirname, join } from 'node:path';
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { BaseCommand, flags } from '@adonisjs/core/ace';
11
+ import { defineConfig } from "../src/define_config.js";
12
+ export default class GenerateOpenApi extends BaseCommand {
13
+ static commandName = 'openapi:generate';
14
+ static description = 'Generate an OpenAPI document from the routes, validators and transformers';
15
+ /**
16
+ * Same warm-up as `node ace codegen`: the app is assembled like the web
17
+ * environment, but never becomes ready, so no long-running side effect starts.
18
+ */
19
+ async #warmUpApp() {
20
+ this.app.setEnvironment('web');
21
+ this.app.setMode('warmup');
22
+ await this.app.boot();
23
+ await this.app.warmUp();
24
+ const router = await this.app.container.make('router');
25
+ router.commit();
26
+ return router;
27
+ }
28
+ async run() {
29
+ const router = await this.#warmUpApp();
30
+ const config = this.app.config.get('openapi') ?? defineConfig();
31
+ const startTime = process.hrtime();
32
+ // Loaded on run: it pulls in the TypeScript compiler, which every other ace command would pay for.
33
+ const { generateDocument } = await import("../src/generate.js");
34
+ const { document, warnings } = await generateDocument(this.app, router, config);
35
+ for (const warning of warnings)
36
+ this.logger.warning(warning);
37
+ const output = join(fileURLToPath(this.app.appRoot), this.output ?? config.output);
38
+ await mkdir(dirname(output), { recursive: true });
39
+ await writeFile(output, `${JSON.stringify(document, null, 2)}\n`);
40
+ this.logger.success(`OpenAPI document written to ${this.app.relativePath(output)}`, {
41
+ startTime,
42
+ });
43
+ }
44
+ }
45
+ __decorate([
46
+ flags.string({
47
+ description: 'Output file, relative to the app root. Defaults to config/openapi.ts "output"',
48
+ })
49
+ ], GenerateOpenApi.prototype, "output", void 0);
@@ -0,0 +1,4 @@
1
+ import type { CommandMetaData } from '@adonisjs/core/types/ace';
2
+ import GenerateOpenApi from './generate_openapi.ts';
3
+ export declare function getMetaData(): Promise<CommandMetaData[]>;
4
+ export declare function getCommand(metaData: CommandMetaData): Promise<typeof GenerateOpenApi | null>;
@@ -0,0 +1,7 @@
1
+ import GenerateOpenApi from "./generate_openapi.js";
2
+ export async function getMetaData() {
3
+ return [{ ...GenerateOpenApi.serialize(), filePath: 'generate_openapi.js' }];
4
+ }
5
+ export async function getCommand(metaData) {
6
+ return metaData.commandName === GenerateOpenApi.commandName ? GenerateOpenApi : null;
7
+ }
@@ -0,0 +1,2 @@
1
+ import type Configure from '@adonisjs/core/commands/configure';
2
+ export declare function configure(command: Configure): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { stubsRoot } from "./stubs/main.js";
2
+ export async function configure(command) {
3
+ const codemods = await command.createCodemods();
4
+ await codemods.makeUsingStub(stubsRoot, 'config/openapi.stub', {});
5
+ await codemods.updateRcFile(rcFile => {
6
+ rcFile.addCommand('@enzopita/adonisjs-openapi/commands');
7
+ rcFile.addAssemblerHook('buildStarting', '@enzopita/adonisjs-openapi/build_hook');
8
+ rcFile.addMetaFile('openapi.json');
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();
16
+ }
@@ -0,0 +1,9 @@
1
+ import type { generateDocument as generate } from './src/generate.ts';
2
+ export { configure } from './configure.ts';
3
+ export { defineConfig } from './src/define_config.ts';
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 ADDED
@@ -0,0 +1,11 @@
1
+ export { configure } from "./configure.js";
2
+ export { defineConfig } from "./src/define_config.js";
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;
@@ -0,0 +1,7 @@
1
+ import type { Router } from '@adonisjs/core/http';
2
+ import type { OpenApiConfig, RouteOperation } from './types.ts';
3
+ /**
4
+ * Scans the committed router with the same routes scanner the dev server feeds
5
+ * Tuyau, so both see the same request and response types.
6
+ */
7
+ export declare function collectRoutes(appRoot: string, router: Router, config: OpenApiConfig): Promise<RouteOperation[]>;
@@ -0,0 +1,66 @@
1
+ import { RoutesScanner } from '@adonisjs/assembler/routes_scanner';
2
+ const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options']);
3
+ function matches(routeName, matcher) {
4
+ if (matcher instanceof RegExp)
5
+ return matcher.test(routeName);
6
+ if (matcher instanceof Function)
7
+ return matcher(routeName);
8
+ return routeName.includes(matcher);
9
+ }
10
+ function isIncluded(routeName, filters) {
11
+ if (filters.only)
12
+ return filters.only.some(matcher => matches(routeName, matcher));
13
+ if (filters.except)
14
+ return !filters.except.some(matcher => matches(routeName, matcher));
15
+ return true;
16
+ }
17
+ /**
18
+ * `/users/:id/:slug?` becomes `/users/{id}/{slug}`, the only form OpenAPI paths accept.
19
+ */
20
+ function toOpenApiPath(pattern) {
21
+ return pattern.replace(/:([^/?]+)\??/g, '{$1}').replace(/\*/g, '{wildcard}');
22
+ }
23
+ /**
24
+ * Scans the committed router with the same routes scanner the dev server feeds
25
+ * Tuyau, so both see the same request and response types.
26
+ */
27
+ export async function collectRoutes(appRoot, router, config) {
28
+ const live = router.toJSON();
29
+ const middlewareByRoute = new Map();
30
+ for (const route of Object.values(live).flat()) {
31
+ const names = [...route.middleware.all()].flatMap(entry => 'name' in entry && entry.name ? [entry.name] : []);
32
+ middlewareByRoute.set(`${route.domain}:${route.pattern}:${route.methods.join(',')}`, names);
33
+ }
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));
38
+ const scanner = new RoutesScanner(appRoot, []);
39
+ scanner.filter(route => isIncluded(route.name ?? '', config.routes));
40
+ for (const routes of serialized)
41
+ await scanner.scan(routes);
42
+ return scanner.getScannedRoutes().map(route => {
43
+ const pattern = toOpenApiPath(route.pattern);
44
+ const [validator] = route.validators ?? [];
45
+ return {
46
+ name: route.name,
47
+ methods: route.methods
48
+ .map(method => method.toLowerCase())
49
+ .filter((method) => METHODS.has(method)),
50
+ path: pattern,
51
+ pathParams: [...pattern.matchAll(/\{([^}]+)\}/g)].map(match => match[1]),
52
+ middleware: middlewareByRoute.get(`${route.domain}:${route.pattern}:${route.methods.join(',')}`) ?? [],
53
+ requestType: route.request?.type,
54
+ responseType: route.response?.type,
55
+ imports: [...(route.request?.imports ?? []), ...(route.response?.imports ?? [])],
56
+ validator: validator && {
57
+ specifier: validator.import.specifier,
58
+ exportName: validator.import.value,
59
+ },
60
+ controller: route.controller && {
61
+ path: route.controller.path,
62
+ method: route.controller.method,
63
+ },
64
+ };
65
+ });
66
+ }
@@ -0,0 +1,2 @@
1
+ import type { OpenApiConfig } from './types.ts';
2
+ export declare function defineConfig(config?: Partial<OpenApiConfig>): OpenApiConfig;
@@ -0,0 +1,15 @@
1
+ export function defineConfig(config = {}) {
2
+ if (config.routes?.only && config.routes.except) {
3
+ throw new Error('openapi: use either "routes.only" or "routes.except", not both');
4
+ }
5
+ return {
6
+ output: 'openapi.json',
7
+ info: { title: 'API', version: '1.0.0' },
8
+ servers: [],
9
+ tags: [],
10
+ routes: {},
11
+ securitySchemes: {},
12
+ middlewareSecurity: {},
13
+ ...config,
14
+ };
15
+ }
@@ -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;