Skip to content

Commit 6d19f6e

Browse files
committed
feat: initial openapi package
1 parent b82b334 commit 6d19f6e

24 files changed

+41847
-0
lines changed

bun.lock

Lines changed: 7444 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 32718 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/openapi/.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tests

packages/openapi/dist/.gitkeep

Whitespace-only changes.

packages/openapi/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export * from './src/service';
2+
export * from './src/module';
3+
export * from './src/document';
4+
export * from './src/types';
5+
export * from './src/annotations';
6+
7+
export type { RegistrableSchema } from './src/schema-registry';

packages/openapi/package.json

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@deepkit/openapi",
3+
"version": "0.0.1",
4+
"type": "commonjs",
5+
"main": "./dist/cjs/index.js",
6+
"module": "./dist/esm/index.js",
7+
"types": "./dist/cjs/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/cjs/index.d.ts",
11+
"require": "./dist/cjs/index.js",
12+
"default": "./dist/esm/index.js"
13+
}
14+
},
15+
"repository": "https://github.com/deepkit/deepkit-framework",
16+
"author": "Marc J. Schmidt <marc@marcjschmidt.de>",
17+
"license": "MIT",
18+
"publishConfig": {
19+
"access": "public"
20+
},
21+
"scripts": {
22+
"build": "echo '{\"type\": \"module\"}' > ./dist/esm/package.json"
23+
},
24+
"dependencies": {
25+
"camelcase": "8.0.0",
26+
"lodash.clonedeepwith": "4.5.0",
27+
"send": "1.2.0",
28+
"swagger-ui-dist": "5.22.0",
29+
"yaml": "2.8.0"
30+
},
31+
"peerDependencies": {
32+
"@deepkit/core": "^1.0.1",
33+
"@deepkit/event": "^1.0.1",
34+
"@deepkit/http": "^1.0.1",
35+
"@deepkit/injector": "^1.0.1",
36+
"@deepkit/type": "^1.0.1",
37+
"@types/lodash.clonedeepwith": "4.5.9"
38+
},
39+
"devDependencies": {
40+
"@deepkit/core": "^1.0.5",
41+
"@deepkit/event": "^1.0.8",
42+
"@deepkit/http": "^1.0.1",
43+
"@deepkit/injector": "^1.0.8",
44+
"@deepkit/type": "^1.0.8"
45+
},
46+
"jest": {
47+
"testEnvironment": "node",
48+
"transform": {
49+
"^.+\\.(ts|tsx)$": [
50+
"ts-jest",
51+
{
52+
"tsconfig": "<rootDir>/tsconfig.spec.json"
53+
}
54+
]
55+
},
56+
"moduleNameMapper": {
57+
"(.+)\\.js": "$1"
58+
},
59+
"testMatch": [
60+
"**/tests/**/*.spec.ts"
61+
],
62+
"setupFiles": [
63+
"<rootDir>/../../jest-setup-runtime.js"
64+
]
65+
}
66+
}

packages/openapi/src/annotations.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { TypeAnnotation } from '@deepkit/core';
2+
3+
export type Format<Name extends string> = TypeAnnotation<'openapi:format', Name>;
4+
export type Default<Value extends string | number | (() => any)> = TypeAnnotation<'openapi:default', Value>;
5+
export type Description<Text extends string> = TypeAnnotation<'openapi:description', Text>;
6+
export type Deprecated = TypeAnnotation<'openapi:deprecated', true>;
7+
export type Name<Text extends string> = TypeAnnotation<'openapi:name', Text>;

packages/openapi/src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { OpenAPICoreConfig } from './document';
2+
3+
export class OpenAPIConfig extends OpenAPICoreConfig {
4+
title: string = 'OpenAPI';
5+
description: string = '';
6+
version: string = '1.0.0';
7+
// Prefix for all OpenAPI related controllers
8+
prefix: string = '/openapi/';
9+
}

packages/openapi/src/document.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import camelCase from 'camelcase';
2+
// @ts-ignore
3+
import cloneDeepWith from 'lodash.clonedeepwith';
4+
5+
import { ClassType } from '@deepkit/core';
6+
import { RouteClassControllerAction, RouteConfig, parseRouteControllerAction } from '@deepkit/http';
7+
import { ScopedLogger } from '@deepkit/logger';
8+
import { ReflectionKind } from '@deepkit/type';
9+
10+
import { OpenApiControllerNameConflict, OpenApiOperationNameConflict, TypeError } from './errors';
11+
import { ParametersResolver } from './parameters-resolver';
12+
import { SchemaKeyFn, SchemaRegistry } from './schema-registry';
13+
import { resolveTypeSchema } from './type-schema-resolver';
14+
import {
15+
HttpMethod,
16+
OpenAPI,
17+
OpenAPIResponse,
18+
Operation,
19+
ParsedRoute,
20+
RequestMediaTypeName,
21+
Responses,
22+
Schema,
23+
Tag,
24+
} from './types';
25+
import { resolveOpenApiPath } from './utils';
26+
27+
export class OpenAPICoreConfig {
28+
customSchemaKeyFn?: SchemaKeyFn;
29+
contentTypes?: RequestMediaTypeName[];
30+
}
31+
32+
export class OpenAPIDocument {
33+
schemaRegistry = new SchemaRegistry(this.config.customSchemaKeyFn);
34+
35+
operations: Operation[] = [];
36+
37+
tags: Tag[] = [];
38+
39+
errors: TypeError[] = [];
40+
41+
constructor(
42+
private routes: RouteConfig[],
43+
private log: ScopedLogger,
44+
private config: OpenAPICoreConfig = {},
45+
) {}
46+
47+
getControllerName(controller: ClassType) {
48+
// TODO: Allow customized name
49+
return camelCase(controller.name.replace(/Controller$/, ''));
50+
}
51+
52+
registerTag(controller: ClassType) {
53+
const name = this.getControllerName(controller);
54+
const newTag = {
55+
__controller: controller,
56+
name,
57+
};
58+
const currentTag = this.tags.find(tag => tag.name === name);
59+
if (currentTag) {
60+
if (currentTag.__controller !== controller) {
61+
throw new OpenApiControllerNameConflict(controller, currentTag.__controller, name);
62+
}
63+
} else {
64+
this.tags.push(newTag);
65+
}
66+
67+
return newTag;
68+
}
69+
70+
getDocument(): OpenAPI {
71+
for (const route of this.routes) {
72+
this.registerRouteSafe(route);
73+
}
74+
75+
const openapi: OpenAPI = {
76+
openapi: '3.0.3',
77+
info: {
78+
title: 'OpenAPI',
79+
contact: {},
80+
license: { name: 'MIT' },
81+
version: '0.0.1',
82+
},
83+
servers: [],
84+
paths: {},
85+
components: {},
86+
};
87+
88+
for (const operation of this.operations) {
89+
const openApiPath = resolveOpenApiPath(operation.__path);
90+
91+
if (!openapi.paths[openApiPath]) {
92+
openapi.paths[openApiPath] = {};
93+
}
94+
openapi.paths[openApiPath][operation.__method as HttpMethod] = operation;
95+
}
96+
97+
for (const [key, schema] of this.schemaRegistry.store) {
98+
openapi.components.schemas = openapi.components.schemas ?? {};
99+
openapi.components.schemas[key] = {
100+
...schema.schema,
101+
__isComponent: true,
102+
};
103+
}
104+
105+
return openapi;
106+
}
107+
108+
serializeDocument(): OpenAPI {
109+
// @ts-ignore
110+
return cloneDeepWith(this.getDocument(), c => {
111+
if (c && typeof c === 'object') {
112+
if (c.__type === 'schema' && c.__registryKey && !c.__isComponent) {
113+
const ret = {
114+
$ref: `#/components/schemas/${c.__registryKey}`,
115+
};
116+
117+
if (c.nullable) {
118+
return {
119+
nullable: true,
120+
allOf: [ret],
121+
};
122+
}
123+
124+
return ret;
125+
}
126+
127+
for (const key of Object.keys(c)) {
128+
// Remove internal keys.
129+
if (key.startsWith('__')) delete c[key];
130+
}
131+
}
132+
});
133+
}
134+
135+
registerRouteSafe(route: RouteConfig) {
136+
try {
137+
this.registerRoute(route);
138+
} catch (err: any) {
139+
this.log.error(`Failed to register route ${route.httpMethods.join(',')} ${route.getFullPath()}`, err);
140+
}
141+
}
142+
143+
registerRoute(route: RouteConfig) {
144+
if (route.action.type !== 'controller') {
145+
throw new Error('Sorry, only controller routes are currently supported!');
146+
}
147+
148+
const controller = route.action.controller;
149+
const tag = this.registerTag(controller);
150+
const parsedRoute = parseRouteControllerAction(route);
151+
152+
for (const method of route.httpMethods) {
153+
const parametersResolver = new ParametersResolver(
154+
parsedRoute,
155+
this.schemaRegistry,
156+
this.config.contentTypes,
157+
).resolve();
158+
this.errors.push(...parametersResolver.errors);
159+
160+
const responses = this.resolveResponses(route);
161+
162+
if (route.action.type !== 'controller') {
163+
throw new Error('Only controller routes are currently supported!');
164+
}
165+
166+
const slash = route.path.length === 0 || route.path.startsWith('/') ? '' : '/';
167+
168+
const operation: Operation = {
169+
__path: `${route.baseUrl}${slash}${route.path}`,
170+
__method: method.toLowerCase(),
171+
tags: [tag.name],
172+
operationId: camelCase([method, tag.name, route.action.methodName]),
173+
parameters: parametersResolver.parameters.length > 0 ? parametersResolver.parameters : undefined,
174+
requestBody: parametersResolver.requestBody,
175+
responses,
176+
description: route.description,
177+
summary: route.name,
178+
};
179+
180+
if (this.operations.find(p => p.__path === operation.__path && p.__method === operation.__method)) {
181+
throw new OpenApiOperationNameConflict(operation.__path, operation.__method);
182+
}
183+
184+
this.operations.push(operation);
185+
}
186+
}
187+
188+
resolveResponses(route: RouteConfig) {
189+
const responses: Responses = {};
190+
191+
// First get the response type of the method
192+
if (route.returnType) {
193+
const schemaResult = resolveTypeSchema(
194+
route.returnType.kind === ReflectionKind.promise ? route.returnType.type : route.returnType,
195+
this.schemaRegistry,
196+
);
197+
198+
this.errors.push(...schemaResult.errors);
199+
200+
responses[200] = {
201+
description: '',
202+
content: {
203+
'application/json': {
204+
schema: schemaResult.result,
205+
},
206+
},
207+
};
208+
}
209+
210+
// Annotated responses have higher priority
211+
for (const response of route.responses) {
212+
let schema: Schema | undefined;
213+
if (response.type) {
214+
const schemaResult = resolveTypeSchema(response.type, this.schemaRegistry);
215+
schema = schemaResult.result;
216+
this.errors.push(...schemaResult.errors);
217+
}
218+
219+
if (!responses[response.statusCode]) {
220+
responses[response.statusCode] = {
221+
description: '',
222+
content: { 'application/json': schema ? { schema } : undefined },
223+
};
224+
}
225+
226+
responses[response.statusCode].description ||= response.description;
227+
if (schema) {
228+
responses[response.statusCode].content['application/json']!.schema = schema;
229+
}
230+
}
231+
232+
return responses;
233+
}
234+
}

packages/openapi/src/errors.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ClassType, getClassName } from '@deepkit/core';
2+
import { Type, stringifyType } from '@deepkit/type';
3+
4+
export class OpenApiError extends Error {}
5+
6+
export class TypeError extends OpenApiError {}
7+
8+
export class TypeNotSupported extends TypeError {
9+
constructor(
10+
public type: Type,
11+
public reason: string = '',
12+
) {
13+
super(`${stringifyType(type)} is not supported. ${reason}`);
14+
}
15+
}
16+
17+
export class LiteralSupported extends TypeError {
18+
constructor(public typeName: string) {
19+
super(`${typeName} is not supported. `);
20+
}
21+
}
22+
23+
export class TypeErrors extends OpenApiError {
24+
constructor(
25+
public errors: TypeError[],
26+
message: string,
27+
) {
28+
super(message);
29+
}
30+
}
31+
32+
export class OpenApiSchemaNameConflict extends OpenApiError {
33+
constructor(
34+
public newType: Type,
35+
public oldType: Type,
36+
public name: string,
37+
) {
38+
super(
39+
`${stringifyType(newType)} and ${stringifyType(
40+
oldType,
41+
)} are not the same, but their schema are both named as ${JSON.stringify(name)}. ` +
42+
`Try to fix the naming of related types, or rename them using 'YourClass & Name<ClassName>'`,
43+
);
44+
}
45+
}
46+
47+
export class OpenApiControllerNameConflict extends OpenApiError {
48+
constructor(
49+
public newController: ClassType,
50+
public oldController: ClassType,
51+
public name: string,
52+
) {
53+
super(
54+
`${getClassName(newController)} and ${getClassName(oldController)} are both tagged as ${name}. ` +
55+
`Please consider renaming them. `,
56+
);
57+
}
58+
}
59+
60+
export class OpenApiOperationNameConflict extends OpenApiError {
61+
constructor(
62+
public fullPath: string,
63+
public method: string,
64+
) {
65+
super(`Operation ${method} ${fullPath} is repeated. Please consider renaming them. `);
66+
}
67+
}

0 commit comments

Comments
 (0)