Skip to content

feat: initial openapi package #652

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/openapi/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests
7 changes: 7 additions & 0 deletions packages/openapi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from './src/service';
export * from './src/module';
export * from './src/document';
export * from './src/types';
export * from './src/annotations';

export type { RegistrableSchema } from './src/schema-registry';
68 changes: 68 additions & 0 deletions packages/openapi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@deepkit/openapi",
"version": "0.0.1",
"type": "commonjs",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/cjs/index.d.ts",
"exports": {
".": {
"types": "./dist/cjs/index.d.ts",
"require": "./dist/cjs/index.js",
"default": "./dist/esm/index.js"
}
},
"repository": "https://github.com/deepkit/deepkit-framework",
"author": "Marc J. Schmidt <[email protected]>",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "echo '{\"type\": \"module\"}' > ./dist/esm/package.json"
},
"dependencies": {
"camelcase": "8.0.0",
"lodash.clonedeepwith": "4.5.0",
"send": "1.2.0",
"swagger-ui-dist": "5.22.0",
"yaml": "2.8.0"
},
"peerDependencies": {
"@deepkit/core": "^1.0.5",
"@deepkit/event": "^1.0.8",
"@deepkit/http": "^1.0.9",
"@deepkit/injector": "1.0.8",
"@deepkit/type": "^1.0.8"
},
"devDependencies": {
"@deepkit/core": "^1.0.5",
"@deepkit/event": "^1.0.8",
"@deepkit/http": "^1.0.9",
"@deepkit/injector": "^1.0.8",
"@deepkit/type": "^1.0.8",
"@types/lodash": "4.17.17",
"@types/lodash.clonedeepwith": "4.5.9",
"@types/send": "0.17.4"
},
"jest": {
"testEnvironment": "node",
"transform": {
"^.+\\.(ts|tsx)$": [
"ts-jest",
{
"tsconfig": "<rootDir>/tsconfig.spec.json"
}
]
},
"moduleNameMapper": {
"(.+)\\.js": "$1"
},
"testMatch": [
"**/tests/**/*.spec.ts"
],
"setupFiles": [
"<rootDir>/../../jest-setup-runtime.js"
]
}
}
7 changes: 7 additions & 0 deletions packages/openapi/src/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TypeAnnotation } from '@deepkit/core';

export type Format<Name extends string> = TypeAnnotation<'openapi:format', Name>;
export type Default<Value extends string | number | (() => any)> = TypeAnnotation<'openapi:default', Value>;
export type Description<Text extends string> = TypeAnnotation<'openapi:description', Text>;
export type Deprecated = TypeAnnotation<'openapi:deprecated', true>;
export type Name<Text extends string> = TypeAnnotation<'openapi:name', Text>;
9 changes: 9 additions & 0 deletions packages/openapi/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { OpenAPICoreConfig } from './document.js';

export class OpenAPIConfig extends OpenAPICoreConfig {
title = 'OpenAPI';
description = '';
version = '1.0.0';
// Prefix for all OpenAPI related controllers
prefix = '/openapi/';
}
40 changes: 40 additions & 0 deletions packages/openapi/src/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { HttpController, HttpControllerDecorator, httpAction, httpClass } from '@deepkit/http';
import {
DualDecorator,
PropertyDecoratorFn,
ReceiveType,
UnionToIntersection,
createClassDecoratorContext,
mergeDecorator,
} from '@deepkit/type';

class HttpOpenApiController extends HttpController {
name: string;
}

class HttpOpenApiControllerDecorator extends HttpControllerDecorator {
override t = new HttpOpenApiController();

// TODO: add name directly on HttpControllerDecorator
name(name: string) {
this.t.name = name;
}
}

export const httpOpenApiController = createClassDecoratorContext(HttpOpenApiControllerDecorator);

//this workaround is necessary since generic functions are lost during a mapped type and changed ReturnType
type HttpMerge<U> = {
[K in keyof U]: K extends 'response'
? <T2>(statusCode: number, description?: string, type?: ReceiveType<T2>) => PropertyDecoratorFn & U
: U[K] extends (...a: infer A) => infer R
? R extends DualDecorator
? (...a: A) => PropertyDecoratorFn & R & U
: (...a: A) => R
: never;
};
type MergedHttp<T extends any[]> = HttpMerge<Omit<UnionToIntersection<T[number]>, '_fetch' | 't'>>;

export const http = mergeDecorator(httpClass, httpOpenApiController, httpAction) as any as MergedHttp<
[typeof httpOpenApiController, typeof httpAction]
>;
246 changes: 246 additions & 0 deletions packages/openapi/src/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import camelCase from 'camelcase';
import cloneDeepWith from 'lodash.clonedeepwith';

import { ClassType } from '@deepkit/core';
import { RouteConfig, parseRouteControllerAction } from '@deepkit/http';
import { ScopedLogger } from '@deepkit/logger';
import { ReflectionKind, serialize } from '@deepkit/type';

import { OpenAPIConfig } from './config';
import { httpOpenApiController } from './decorator.js';
import { OpenApiControllerNameConflictError, OpenApiOperationNameConflictError, OpenApiTypeError } from './errors';
import { ParametersResolver } from './parameters-resolver';
import { SchemaKeyFn, SchemaRegistry } from './schema-registry';
import { resolveTypeSchema } from './type-schema-resolver';
import {
HttpMethod,
OpenAPI,
Operation,
RequestMediaTypeName,
Responses,
Schema,
SerializedOpenAPI,
Tag,
} from './types';
import { resolveOpenApiPath } from './utils';

export class OpenAPICoreConfig {
customSchemaKeyFn?: SchemaKeyFn;
contentTypes?: RequestMediaTypeName[];
}

export class OpenAPIDocument {
schemaRegistry: SchemaRegistry;

operations: Operation[] = [];

tags: Tag[] = [];

errors: OpenApiTypeError[] = [];

constructor(
private routes: RouteConfig[],
private log: ScopedLogger,
private config: OpenAPIConfig,
) {
this.schemaRegistry = new SchemaRegistry(this.config.customSchemaKeyFn);
}

getControllerName(controller: ClassType) {
const t = httpOpenApiController._fetch(controller);
return t?.name || camelCase(controller.name.replace(/Controller$/, ''));
}

registerTag(controller: ClassType) {
const name = this.getControllerName(controller);
const newTag = {
__controller: controller,
name,
};
const currentTag = this.tags.find(tag => tag.name === name);
if (currentTag) {
if (currentTag.__controller !== controller) {
throw new OpenApiControllerNameConflictError(controller, currentTag.__controller, name);
}
} else {
this.tags.push(newTag);
}

return newTag;
}

getDocument(): OpenAPI {
for (const route of this.routes) {
this.registerRouteSafe(route);
}

const openapi: OpenAPI = {
openapi: '3.0.3',
info: {
title: this.config.title,
description: this.config.description,
version: this.config.version,
},
servers: [],
paths: {},
components: {},
};

for (const operation of this.operations) {
const openApiPath = resolveOpenApiPath(operation.__path);

if (!openapi.paths[openApiPath]) {
openapi.paths[openApiPath] = {};
}
openapi.paths[openApiPath][operation.__method as HttpMethod] = operation;
}

for (const [key, schema] of this.schemaRegistry.store) {
openapi.components.schemas = openapi.components.schemas ?? {};
openapi.components.schemas[key] = {
...schema.schema,
__isComponent: true,
};
}

return openapi;
}

serializeDocument(): SerializedOpenAPI {
const clonedDocument = cloneDeepWith(this.getDocument(), c => {
if (c && typeof c === 'object') {
if (c.__type === 'schema' && c.__registryKey && !c.__isComponent) {
const ret = {
$ref: `#/components/schemas/${c.__registryKey}`,
};

if (c.nullable) {
return {
nullable: true,
allOf: [ret],
};
}

return ret;
}
}

return c;
});

return serialize<OpenAPI>(clonedDocument);
}

registerRouteSafe(route: RouteConfig) {
try {
this.registerRoute(route);
} catch (err) {
// FIXME: determine why infinite loop is occurring
if (err instanceof RangeError && err.message.includes('Maximum call stack size exceeded')) {
console.error('Maximum call stack size exceeded', route.getFullPath());
return;
}
this.log.error(`Failed to register route ${route.httpMethods.join(',')} ${route.getFullPath()}`, err);
}
}

registerRoute(route: RouteConfig) {
if (route.action.type !== 'controller') {
throw new Error('Sorry, only controller routes are currently supported!');
}

const controller = route.action.controller;
const tag = this.registerTag(controller);
const parsedRoute = parseRouteControllerAction(route);

for (const method of route.httpMethods) {
const parametersResolver = new ParametersResolver(
parsedRoute,
this.schemaRegistry,
this.config.contentTypes,
).resolve();
this.errors.push(...parametersResolver.errors);

const responses = this.resolveResponses(route);

if (route.action.type !== 'controller') {
throw new Error('Only controller routes are currently supported!');
}

const slash = route.path.length === 0 || route.path.startsWith('/') ? '' : '/';

if (parametersResolver.parameters === null) {
throw new Error('Parameters resolver returned null');
}

const operation: Operation = {
__path: `${route.baseUrl}${slash}${route.path}`,
__method: method.toLowerCase(),
tags: [tag.name],
operationId: camelCase([method, tag.name, route.action.methodName]),
responses,
description: route.description,
summary: route.name,
};
if (parametersResolver.parameters.length > 0) {
operation.parameters = parametersResolver.parameters;
}
if (parametersResolver.requestBody) {
operation.requestBody = parametersResolver.requestBody;
}

if (this.operations.find(p => p.__path === operation.__path && p.__method === operation.__method)) {
throw new OpenApiOperationNameConflictError(operation.__path, operation.__method);
}

this.operations.push(operation);
}
}

resolveResponses(route: RouteConfig) {
const responses: Responses = {};

// First get the response type of the method
if (route.returnType) {
const schemaResult = resolveTypeSchema(
route.returnType.kind === ReflectionKind.promise ? route.returnType.type : route.returnType,
this.schemaRegistry,
);

this.errors.push(...schemaResult.errors);

responses[200] = {
description: route.description,
content: {
'application/json': {
schema: schemaResult.result,
},
},
};
}

// Annotated responses have higher priority
for (const response of route.responses) {
let schema: Schema | undefined;
if (response.type) {
const schemaResult = resolveTypeSchema(response.type, this.schemaRegistry);
schema = schemaResult.result;
this.errors.push(...schemaResult.errors);
}

if (!responses[response.statusCode]) {
responses[response.statusCode] = {
description: response.description,
content: { 'application/json': schema ? { schema } : undefined },
};
}

responses[response.statusCode].description ||= response.description;
if (schema) {
responses[response.statusCode].content['application/json']!.schema = schema;
}
}

return responses;
}
}
Loading