@enzopita/adonisjs-openapi 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/build/commands/generate_openapi.d.ts +8 -0
  4. package/build/commands/generate_openapi.js +49 -0
  5. package/build/commands/main.d.ts +4 -0
  6. package/build/commands/main.js +7 -0
  7. package/build/configure.d.ts +2 -0
  8. package/build/configure.js +16 -0
  9. package/build/index.d.ts +9 -0
  10. package/build/index.js +11 -0
  11. package/build/src/build_hook.d.ts +7 -0
  12. package/build/src/build_hook.js +18 -0
  13. package/build/src/collect_routes.d.ts +7 -0
  14. package/build/src/collect_routes.js +66 -0
  15. package/build/src/define_config.d.ts +2 -0
  16. package/build/src/define_config.js +15 -0
  17. package/build/src/docs/html.d.ts +8 -0
  18. package/build/src/docs/html.js +37 -0
  19. package/build/src/docs/redoc.d.ts +14 -0
  20. package/build/src/docs/redoc.js +17 -0
  21. package/build/src/docs/routes.d.ts +6 -0
  22. package/build/src/docs/routes.js +81 -0
  23. package/build/src/docs/scalar.d.ts +11 -0
  24. package/build/src/docs/scalar.js +15 -0
  25. package/build/src/docs/swagger_ui.d.ts +13 -0
  26. package/build/src/docs/swagger_ui.js +16 -0
  27. package/build/src/docs/ui.d.ts +2 -0
  28. package/build/src/docs/ui.js +13 -0
  29. package/build/src/generate.d.ts +8 -0
  30. package/build/src/generate.js +159 -0
  31. package/build/src/operation_docs.d.ts +12 -0
  32. package/build/src/operation_docs.js +51 -0
  33. package/build/src/route_types.d.ts +30 -0
  34. package/build/src/route_types.js +169 -0
  35. package/build/src/type_to_schema.d.ts +21 -0
  36. package/build/src/type_to_schema.js +157 -0
  37. package/build/src/types.d.ts +206 -0
  38. package/build/src/types.js +1 -0
  39. package/build/stubs/config/openapi.stub +15 -0
  40. package/build/stubs/main.d.ts +1 -0
  41. package/build/stubs/main.js +1 -0
  42. package/package.json +57 -3
@@ -0,0 +1,157 @@
1
+ import ts from 'typescript';
2
+ const OMITTED = ts.TypeFlags.Undefined | ts.TypeFlags.Void;
3
+ // `vine.file()` infers `MultipartFile` on the server and `File | Blob` for clients.
4
+ const BINARY = new Set(['MultipartFile', 'File', 'Blob']);
5
+ /**
6
+ * Converts a type the checker resolved into JSON Schema (draft 2020-12, as OpenAPI 3.1 uses).
7
+ * Serialized output types are plain data, so methods are dropped and dates become strings.
8
+ */
9
+ export class TypeToSchema {
10
+ #checker;
11
+ #recursive = new Map();
12
+ #named;
13
+ #namesBySchema = new Map();
14
+ components = {};
15
+ unsupported = new Set();
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()) {
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
+ }
30
+ }
31
+ convert(type, stack = []) {
32
+ const flags = type.flags;
33
+ if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
34
+ return {};
35
+ if (flags & ts.TypeFlags.Never)
36
+ return { not: {} };
37
+ if (type.isStringLiteral())
38
+ return { type: 'string', const: type.value };
39
+ if (type.isNumberLiteral())
40
+ return { type: 'number', const: type.value };
41
+ if (flags & ts.TypeFlags.BooleanLiteral)
42
+ return { type: 'boolean', const: this.#checker.typeToString(type) === 'true' };
43
+ if (flags & (ts.TypeFlags.String | ts.TypeFlags.TemplateLiteral))
44
+ return { type: 'string' };
45
+ if (flags & ts.TypeFlags.Number)
46
+ return { type: 'number' };
47
+ if (flags & ts.TypeFlags.BigInt)
48
+ return { type: 'integer' };
49
+ if (flags & ts.TypeFlags.Boolean)
50
+ return { type: 'boolean' };
51
+ if (flags & ts.TypeFlags.Null)
52
+ return { type: 'null' };
53
+ if (flags & OMITTED)
54
+ return { not: {} };
55
+ if (type.isUnion())
56
+ return this.#union(type, stack);
57
+ const name = (type.aliasSymbol ?? type.getSymbol())?.getName();
58
+ if (name === 'Date' || name === 'DateTime')
59
+ return { type: 'string', format: 'date-time' };
60
+ if (name && BINARY.has(name))
61
+ return { type: 'string', format: 'binary' };
62
+ if (this.#checker.isArrayType(type)) {
63
+ // SAFETY: isArrayType only holds for TypeReference instances.
64
+ const [item] = this.#checker.getTypeArguments(type);
65
+ return { type: 'array', items: item ? this.convert(item, stack) : {} };
66
+ }
67
+ if (this.#checker.isTupleType(type)) {
68
+ // SAFETY: isTupleType only holds for TypeReference instances.
69
+ const items = this.#checker
70
+ .getTypeArguments(type)
71
+ .map(item => this.convert(item, stack));
72
+ return { type: 'array', prefixItems: items, minItems: items.length, maxItems: items.length };
73
+ }
74
+ if (flags & ts.TypeFlags.Object || type.isIntersection())
75
+ return this.#object(type, name, stack);
76
+ this.unsupported.add(this.#checker.typeToString(type));
77
+ return {};
78
+ }
79
+ /**
80
+ * Whether `undefined` is one of the members, which makes an object property optional.
81
+ */
82
+ acceptsUndefined(type) {
83
+ return type.isUnion()
84
+ ? type.types.some(member => member.flags & OMITTED)
85
+ : Boolean(type.flags & OMITTED);
86
+ }
87
+ #union(type, stack) {
88
+ const members = type.types.filter(member => !(member.flags & OMITTED));
89
+ const booleans = members.filter(member => member.flags & ts.TypeFlags.BooleanLiteral);
90
+ const converted = members
91
+ .filter(member => !(member.flags & ts.TypeFlags.BooleanLiteral))
92
+ .map(member => this.convert(member, stack));
93
+ if (booleans.length === 2)
94
+ converted.push({ type: 'boolean' });
95
+ else
96
+ converted.push(...booleans.map(member => this.convert(member, stack)));
97
+ const schemas = [...new Map(converted.map(schema => [JSON.stringify(schema), schema])).values()];
98
+ if (schemas.length === 1)
99
+ return schemas[0];
100
+ if (schemas.every(schema => 'const' in schema || schema.type === 'null')) {
101
+ const types = [...new Set(schemas.flatMap(schema => schema.type ?? []))];
102
+ return {
103
+ type: types.length === 1 ? types[0] : types,
104
+ enum: schemas.map(schema => schema.const ?? null),
105
+ };
106
+ }
107
+ return { anyOf: schemas };
108
+ }
109
+ #object(type, name, stack) {
110
+ const named = this.#named.get(type);
111
+ if (named)
112
+ return { $ref: `#/components/schemas/${named}` };
113
+ if (stack.includes(type))
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) {
125
+ const next = [...stack, type];
126
+ const properties = {};
127
+ const required = [];
128
+ for (const property of this.#checker.getPropertiesOfType(type)) {
129
+ const propertyType = this.#checker.getTypeOfSymbol(property);
130
+ if (propertyType.getCallSignatures().length)
131
+ continue;
132
+ properties[property.getName()] = this.convert(propertyType, next);
133
+ const optional = property.flags & ts.SymbolFlags.Optional || this.acceptsUndefined(propertyType);
134
+ if (!optional)
135
+ required.push(property.getName());
136
+ }
137
+ const [index] = this.#checker.getIndexInfosOfType(type);
138
+ const schema = {
139
+ type: 'object',
140
+ properties,
141
+ additionalProperties: index ? this.convert(index.type, next) : false,
142
+ };
143
+ if (required.length)
144
+ schema.required = required;
145
+ return schema;
146
+ }
147
+ #recursiveName(type, name) {
148
+ const existing = this.#recursive.get(type);
149
+ if (existing)
150
+ return existing;
151
+ const base = name && !name.startsWith('__') ? name : 'Recursive';
152
+ const ref = this.components[base] ? `${base}${this.#recursive.size}` : base;
153
+ this.#recursive.set(type, ref);
154
+ this.components[ref] = {};
155
+ return ref;
156
+ }
157
+ }
@@ -0,0 +1,206 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ export type JsonValue = string | number | boolean | null;
3
+ /**
4
+ * The JSON Schema keywords the generator reads or writes. Keywords Vine adds
5
+ * (`minLength`, `pattern`...) pass through untouched.
6
+ */
7
+ export interface JsonSchema {
8
+ $ref?: string;
9
+ type?: string | string[];
10
+ format?: string;
11
+ const?: JsonValue;
12
+ enum?: JsonValue[];
13
+ properties?: Record<string, JsonSchema>;
14
+ required?: string[];
15
+ additionalProperties?: boolean | JsonSchema;
16
+ items?: JsonSchema;
17
+ prefixItems?: JsonSchema[];
18
+ minItems?: number;
19
+ maxItems?: number;
20
+ anyOf?: JsonSchema[];
21
+ not?: JsonSchema;
22
+ }
23
+ export type RouteMatcher = string | RegExp | ((routeName: string) => boolean);
24
+ export type SecurityScheme = {
25
+ type: 'http';
26
+ scheme: 'bearer' | 'basic';
27
+ bearerFormat?: string;
28
+ description?: string;
29
+ } | {
30
+ type: 'apiKey';
31
+ name: string;
32
+ in: 'header' | 'query' | 'cookie';
33
+ description?: string;
34
+ };
35
+ export interface OpenApiConfig {
36
+ /**
37
+ * Where the document is written, relative to the app root.
38
+ */
39
+ output: string;
40
+ info: {
41
+ title: string;
42
+ version: string;
43
+ summary?: string;
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
+ };
56
+ };
57
+ servers: {
58
+ url: string;
59
+ description?: string;
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
+ }[];
68
+ /**
69
+ * Same filters as Tuyau's `generateRegistry({ routes })`. Only one of them can be set.
70
+ */
71
+ routes: {
72
+ only?: RouteMatcher[];
73
+ except?: RouteMatcher[];
74
+ };
75
+ securitySchemes: Record<string, SecurityScheme>;
76
+ /**
77
+ * Maps a named middleware (as in `middleware.auth()`) to a key of `securitySchemes`.
78
+ */
79
+ middlewareSecurity: Record<string, string>;
80
+ }
81
+ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options';
82
+ /**
83
+ * One named route as the generator sees it: where it lives, and the TypeScript
84
+ * expressions the routes scanner extracted for its input and output.
85
+ */
86
+ export interface RouteOperation {
87
+ name: string;
88
+ methods: HttpMethod[];
89
+ path: string;
90
+ pathParams: string[];
91
+ middleware: string[];
92
+ requestType?: string;
93
+ responseType?: string;
94
+ imports: string[];
95
+ validator?: {
96
+ specifier: string;
97
+ exportName: string;
98
+ };
99
+ controller?: {
100
+ path: string;
101
+ method: string;
102
+ };
103
+ }
104
+ export interface OpenApiParameter {
105
+ name: string;
106
+ in: 'path' | 'query';
107
+ required: boolean;
108
+ schema: JsonSchema;
109
+ }
110
+ export interface OpenApiResponse {
111
+ description: string;
112
+ content?: Record<string, {
113
+ schema: JsonSchema;
114
+ }>;
115
+ }
116
+ export interface OpenApiOperation {
117
+ operationId: string;
118
+ summary?: string;
119
+ description?: string;
120
+ tags: string[];
121
+ deprecated?: true;
122
+ parameters?: OpenApiParameter[];
123
+ requestBody?: {
124
+ required: true;
125
+ content: Record<string, {
126
+ schema: JsonSchema;
127
+ }>;
128
+ };
129
+ responses: Record<string, OpenApiResponse>;
130
+ security?: Record<string, string[]>[];
131
+ }
132
+ export interface OpenApiDocument {
133
+ openapi: '3.1.0';
134
+ info: OpenApiConfig['info'];
135
+ servers?: OpenApiConfig['servers'];
136
+ tags?: OpenApiConfig['tags'];
137
+ paths: Record<string, Partial<Record<HttpMethod, OpenApiOperation>>>;
138
+ components: {
139
+ schemas: Record<string, JsonSchema>;
140
+ securitySchemes: OpenApiConfig['securitySchemes'];
141
+ };
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
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ {{{
2
+ exports({ to: app.configPath('openapi.ts') })
3
+ }}}
4
+ import { defineConfig } from '@enzopita/adonisjs-openapi'
5
+
6
+ export default defineConfig({
7
+ output: 'openapi.json',
8
+ info: { title: 'API', version: '1.0.0' },
9
+ securitySchemes: {
10
+ accessToken: { type: 'http', scheme: 'bearer' },
11
+ },
12
+ middlewareSecurity: {
13
+ auth: 'accessToken',
14
+ },
15
+ })
@@ -0,0 +1 @@
1
+ export declare const stubsRoot: string;
@@ -0,0 +1 @@
1
+ export const stubsRoot = import.meta.dirname;
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@enzopita/adonisjs-openapi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Generate an OpenAPI document from the routes, validators and transformers of an AdonisJS app",
5
+ "keywords": [
6
+ "adonisjs",
7
+ "openapi",
8
+ "swagger",
9
+ "tuyau",
10
+ "vinejs"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Enzo Pita <enzo@enzopita.com>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/enzopita/adonisjs-packages.git",
17
+ "directory": "packages/openapi"
18
+ },
19
+ "files": [
20
+ "build",
21
+ "!build/bin",
22
+ "!build/tests"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./build/index.js",
27
+ "./commands": "./build/commands/main.js",
28
+ "./routes": "./build/src/docs/routes.js",
29
+ "./build_hook": "./build/src/build_hook.js",
30
+ "./types": "./build/src/types.js"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "typescript": "~6.0.3"
37
+ },
38
+ "devDependencies": {
39
+ "@adonisjs/assembler": "^8.5.2",
40
+ "@adonisjs/core": "^7.6.0",
41
+ "@adonisjs/tsconfig": "^2.0.0",
42
+ "@japa/assert": "^4.2.0",
43
+ "@japa/runner": "^5.3.0",
44
+ "@poppinss/ts-exec": "^1.4.4",
45
+ "@types/node": "~26.6.5",
46
+ "@vinejs/vine": "^4.4.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@adonisjs/assembler": "^8.5.0",
50
+ "@adonisjs/core": "^7.6.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=24.0.0"
54
+ },
55
+ "scripts": {
56
+ "build": "rm -rf build && tsc -p tsconfig.build.json && cp -r stubs/config build/stubs/",
57
+ "test": "pnpm run build && node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts",
58
+ "typecheck": "tsc --noEmit"
59
+ }
6
60
  }