@enzopita/adonisjs-openapi 0.1.0 → 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.
- package/README.md +206 -7
- package/build/configure.js +8 -0
- package/build/index.d.ts +6 -1
- package/build/index.js +8 -1
- package/build/src/build_hook.d.ts +7 -0
- package/build/src/build_hook.js +18 -0
- package/build/src/collect_routes.js +9 -3
- package/build/src/define_config.js +1 -0
- package/build/src/docs/html.d.ts +8 -0
- package/build/src/docs/html.js +37 -0
- package/build/src/docs/redoc.d.ts +14 -0
- package/build/src/docs/redoc.js +17 -0
- package/build/src/docs/routes.d.ts +6 -0
- package/build/src/docs/routes.js +81 -0
- package/build/src/docs/scalar.d.ts +11 -0
- package/build/src/docs/scalar.js +15 -0
- package/build/src/docs/swagger_ui.d.ts +13 -0
- package/build/src/docs/swagger_ui.js +16 -0
- package/build/src/docs/ui.d.ts +2 -0
- package/build/src/docs/ui.js +13 -0
- package/build/src/generate.js +23 -12
- package/build/src/operation_docs.d.ts +12 -0
- package/build/src/operation_docs.js +51 -0
- package/build/src/route_types.d.ts +5 -0
- package/build/src/route_types.js +92 -3
- package/build/src/type_to_schema.d.ts +5 -1
- package/build/src/type_to_schema.js +28 -6
- package/build/src/types.d.ts +92 -0
- package/package.json +3 -1
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`
|
|
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
|
|
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: {
|
|
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,7 +57,195 @@ 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
|
-
- `
|
|
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.
|
|
51
249
|
|
|
52
250
|
## How types become schemas
|
|
53
251
|
|
|
@@ -60,6 +258,7 @@ export default defineConfig({
|
|
|
60
258
|
|
|
61
259
|
## Limitations
|
|
62
260
|
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
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.
|
package/build/configure.js
CHANGED
|
@@ -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
|
-
//
|
|
35
|
-
const
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
['&', '&'],
|
|
3
|
+
['<', '<'],
|
|
4
|
+
['>', '>'],
|
|
5
|
+
['"', '"'],
|
|
6
|
+
["'", '''],
|
|
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,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
|
+
}
|
package/build/src/generate.js
CHANGED
|
@@ -50,28 +50,35 @@ async function validatorSchemas(app, routes, warnings) {
|
|
|
50
50
|
}
|
|
51
51
|
return schemas;
|
|
52
52
|
}
|
|
53
|
-
|
|
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,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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' };
|
|
@@ -112,9 +119,11 @@ export async function generateDocument(app, router, config) {
|
|
|
112
119
|
const scheme = config.middlewareSecurity[name];
|
|
113
120
|
return scheme ? [{ [scheme]: [] }] : [];
|
|
114
121
|
});
|
|
122
|
+
const { responses: thrown, ...docs } = types.docs(route);
|
|
115
123
|
const operation = {
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
...docs,
|
|
125
|
+
tags: docs.tags ?? [route.name.split('.')[0] ?? route.name],
|
|
126
|
+
responses: responsesFor(route, types, response, thrown, security.length > 0, warnings),
|
|
118
127
|
};
|
|
119
128
|
if (parameters.length)
|
|
120
129
|
operation.parameters = parameters;
|
|
@@ -144,5 +153,7 @@ export async function generateDocument(app, router, config) {
|
|
|
144
153
|
};
|
|
145
154
|
if (config.servers.length)
|
|
146
155
|
document.servers = config.servers;
|
|
156
|
+
if (config.tags.length)
|
|
157
|
+
document.tags = config.tags;
|
|
147
158
|
return { document, warnings };
|
|
148
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
|
+
}
|
|
@@ -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
|
}
|
package/build/src/route_types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/build/src/types.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|