diff --git a/packages/openapi/.npmignore b/packages/openapi/.npmignore new file mode 100644 index 000000000..2b29f2764 --- /dev/null +++ b/packages/openapi/.npmignore @@ -0,0 +1 @@ +tests diff --git a/packages/openapi/index.ts b/packages/openapi/index.ts new file mode 100644 index 000000000..feb8ab403 --- /dev/null +++ b/packages/openapi/index.ts @@ -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'; diff --git a/packages/openapi/package.json b/packages/openapi/package.json new file mode 100644 index 000000000..ad5d50d3a --- /dev/null +++ b/packages/openapi/package.json @@ -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 ", + "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": "/tsconfig.spec.json" + } + ] + }, + "moduleNameMapper": { + "(.+)\\.js": "$1" + }, + "testMatch": [ + "**/tests/**/*.spec.ts" + ], + "setupFiles": [ + "/../../jest-setup-runtime.js" + ] + } +} diff --git a/packages/openapi/src/annotations.ts b/packages/openapi/src/annotations.ts new file mode 100644 index 000000000..0129b2681 --- /dev/null +++ b/packages/openapi/src/annotations.ts @@ -0,0 +1,7 @@ +import { TypeAnnotation } from '@deepkit/core'; + +export type Format = TypeAnnotation<'openapi:format', Name>; +export type Default any)> = TypeAnnotation<'openapi:default', Value>; +export type Description = TypeAnnotation<'openapi:description', Text>; +export type Deprecated = TypeAnnotation<'openapi:deprecated', true>; +export type Name = TypeAnnotation<'openapi:name', Text>; diff --git a/packages/openapi/src/config.ts b/packages/openapi/src/config.ts new file mode 100644 index 000000000..02343813a --- /dev/null +++ b/packages/openapi/src/config.ts @@ -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/'; +} diff --git a/packages/openapi/src/decorator.ts b/packages/openapi/src/decorator.ts new file mode 100644 index 000000000..da62efee5 --- /dev/null +++ b/packages/openapi/src/decorator.ts @@ -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 = { + [K in keyof U]: K extends 'response' + ? (statusCode: number, description?: string, type?: ReceiveType) => PropertyDecoratorFn & U + : U[K] extends (...a: infer A) => infer R + ? R extends DualDecorator + ? (...a: A) => PropertyDecoratorFn & R & U + : (...a: A) => R + : never; +}; +type MergedHttp = HttpMerge, '_fetch' | 't'>>; + +export const http = mergeDecorator(httpClass, httpOpenApiController, httpAction) as any as MergedHttp< + [typeof httpOpenApiController, typeof httpAction] +>; diff --git a/packages/openapi/src/document.ts b/packages/openapi/src/document.ts new file mode 100644 index 000000000..7ace9f4a7 --- /dev/null +++ b/packages/openapi/src/document.ts @@ -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(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; + } +} diff --git a/packages/openapi/src/errors.ts b/packages/openapi/src/errors.ts new file mode 100644 index 000000000..e9857720d --- /dev/null +++ b/packages/openapi/src/errors.ts @@ -0,0 +1,65 @@ +import { ClassType, getClassName } from '@deepkit/core'; +import { Type, stringifyType } from '@deepkit/type'; + +export class OpenApiError extends Error {} + +export class OpenApiTypeError extends OpenApiError {} + +export class OpenApiTypeNotSupportedError extends OpenApiTypeError { + constructor( + public type: Type, + public reason = '', + ) { + super(`${stringifyType(type)} is not supported. ${reason}`); + } +} + +export class OpenApiLiteralNotSupportedError extends OpenApiTypeError { + constructor(public typeName: string) { + super(`${typeName} is not supported. `); + } +} + +export class OpenApiTypeErrors extends OpenApiError { + constructor( + public errors: OpenApiTypeError[], + message: string, + ) { + super(message); + } +} + +export class OpenApiSchemaNameConflictError extends OpenApiError { + constructor( + public newType: Type, + public oldType: Type, + name: string, + ) { + super( + `"${stringifyType(newType)}" and "${stringifyType( + oldType, + )}" are different, but their schema is both named as ${JSON.stringify(name)}. Try to fix the naming of related types, or rename them using 'YourClass & Name'`, + ); + } +} + +export class OpenApiControllerNameConflictError extends OpenApiError { + constructor( + public newController: ClassType, + public oldController: ClassType, + name: string, + ) { + super( + `${getClassName(newController)} and ${getClassName(oldController)} are both tagged as ${name}. Please consider renaming them.`, + ); + } +} + +export class OpenApiOperationNameConflictError extends OpenApiError { + constructor( + public fullPath: string, + public method: string, + ) { + super(`Operation ${method} ${fullPath} is repeated. Please consider renaming them. `); + } +} diff --git a/packages/openapi/src/module.ts b/packages/openapi/src/module.ts new file mode 100644 index 000000000..acdc76368 --- /dev/null +++ b/packages/openapi/src/module.ts @@ -0,0 +1,34 @@ +import { createModuleClass } from '@deepkit/app'; +import { HttpRouteFilter } from '@deepkit/http'; + +import { OpenAPIConfig } from './config'; +import { OpenAPIService } from './service'; +import { OpenApiStaticRewritingListener } from './static-rewriting-listener'; +import { SerializedOpenAPI } from './types'; + +export class OpenAPIModule extends createModuleClass({ + config: OpenAPIConfig, + providers: [OpenAPIService], + exports: [OpenAPIService], + listeners: [OpenApiStaticRewritingListener], +}) { + protected routeFilter = new HttpRouteFilter().excludeRoutes({ + group: 'app-static', + }); + + configureOpenApiFunction: (openApi: SerializedOpenAPI) => void = () => {}; + + configureOpenApi(configure: (openApi: SerializedOpenAPI) => void) { + this.configureOpenApiFunction = configure; + return this; + } + + configureHttpRouteFilter(configure: (filter: HttpRouteFilter) => void) { + configure(this.routeFilter); + return this; + } + + override process() { + this.addProvider({ provide: HttpRouteFilter, useValue: this.routeFilter }); + } +} diff --git a/packages/openapi/src/parameters-resolver.ts b/packages/openapi/src/parameters-resolver.ts new file mode 100644 index 000000000..e797c08cb --- /dev/null +++ b/packages/openapi/src/parameters-resolver.ts @@ -0,0 +1,107 @@ +import { ReflectionKind } from '@deepkit/type'; + +import { OpenApiError, OpenApiTypeError } from './errors'; +import { SchemaRegistry } from './schema-registry'; +import { resolveTypeSchema } from './type-schema-resolver'; +import { MediaType, Parameter, ParsedRoute, RequestBody, RequestMediaTypeName } from './types'; + +export class ParametersResolver { + parameters: Parameter[] = []; + requestBody?: RequestBody; + errors: OpenApiTypeError[] = []; + + constructor( + private parsedRoute: ParsedRoute, + private schemeRegistry: SchemaRegistry, + private contentTypes?: RequestMediaTypeName[], + ) {} + + resolve() { + for (const parameter of this.parsedRoute.getParameters()) { + const type = parameter.getType(); + + if (parameter.query) { + const schemaResult = resolveTypeSchema(type, this.schemeRegistry); + + this.errors.push(...schemaResult.errors); + + this.parameters.push({ + in: 'query', + name: parameter.getName(), + schema: schemaResult.result, + required: !parameter.parameter.isOptional(), + }); + } else if (parameter.queries) { + if (type.kind !== ReflectionKind.class && type.kind !== ReflectionKind.objectLiteral) { + throw new OpenApiError('HttpQueries should be either class or object literal. '); + } + + const schemaResult = resolveTypeSchema(type, this.schemeRegistry); + + this.errors.push(...schemaResult.errors); + + for (const [name, property] of Object.entries(schemaResult.result.properties!)) { + if (!this.parameters.find(p => p.name === name)) { + this.parameters.push({ + in: 'query', + name, + schema: property, + required: schemaResult.result.required?.includes(name), + }); + } else { + this.errors.push( + new OpenApiTypeError( + `Parameter name ${JSON.stringify(name)} is repeated. Please consider renaming them. `, + ), + ); + } + } + } else if (parameter.isPartOfPath()) { + const schemaResult = resolveTypeSchema(type, this.schemeRegistry); + + this.errors.push(...schemaResult.errors); + + this.parameters.push({ + in: 'path', + name: parameter.getName(), + schema: schemaResult.result, + required: true, + }); + } else if (parameter.body || parameter.bodyValidation) { + if ( + type.kind !== ReflectionKind.array && + type.kind !== ReflectionKind.class && + type.kind !== ReflectionKind.objectLiteral + ) { + throw new OpenApiError( + 'HttpBody or HttpBodyValidation should be either array, class, or object literal.', + ); + } + + const bodySchema = resolveTypeSchema(type, this.schemeRegistry); + + this.errors.push(...bodySchema.errors); + + const contentTypes = this.contentTypes ?? [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data', + ]; + + this.requestBody = { + content: Object.fromEntries( + contentTypes.map(contentType => [ + contentType, + { + schema: bodySchema.result, + }, + ]), + ) as Record, + required: !parameter.parameter.isOptional(), + }; + } + } + + return this; + } +} diff --git a/packages/openapi/src/schema-registry.ts b/packages/openapi/src/schema-registry.ts new file mode 100644 index 000000000..3f9c23e56 --- /dev/null +++ b/packages/openapi/src/schema-registry.ts @@ -0,0 +1,109 @@ +import camelcase from 'camelcase'; + +import { + ReflectionKind, + Type, + TypeClass, + TypeEnum, + TypeObjectLiteral, + TypeUnion, + isSameType, + metaAnnotation, + stringifyType, +} from '@deepkit/type'; + +import { OpenApiSchemaNameConflictError } from './errors.js'; +import { TypeSchemaResolver } from './type-schema-resolver.js'; +import { Schema } from './types.js'; + +export interface SchemeEntry { + name: string; + schema: Schema; + type: Type; +} + +export type RegistrableSchema = TypeClass | TypeObjectLiteral | TypeEnum | TypeUnion; + +export type SchemaKeyFn = (t: RegistrableSchema) => string | undefined; + +export class SchemaRegistry { + store: Map = new Map(); + types: WeakMap = new WeakMap(); + + constructor(private customSchemaKeyFn?: SchemaKeyFn) {} + + getSchemaKey(t: RegistrableSchema): string { + const nameAnnotation = metaAnnotation.getAnnotations(t).find(t => t.name === 'openapi:name'); + + // Handle user preferred name + if (nameAnnotation?.options.kind === ReflectionKind.literal) { + return nameAnnotation.options.literal as string; + } + + // HttpQueries + if (t.typeName === 'HttpQueries' || t.typeName === 'HttpBody' || t.typeName === 'HttpBodyValidation') { + return this.getSchemaKey( + ((t as RegistrableSchema).typeArguments?.[0] ?? + (t as RegistrableSchema).originTypes?.[0]) as RegistrableSchema, + ); + } + + if (this.customSchemaKeyFn) { + const customName = this.customSchemaKeyFn(t); + if (customName) return customName; + } + + const rootName = t.kind === ReflectionKind.class ? t.classType.name : (t.typeName ?? ''); + + const args = t.kind === ReflectionKind.class ? (t.arguments ?? []) : (t.typeArguments ?? []); + + return camelcase([rootName, ...args.map(a => this.getTypeKey(a))], { + pascalCase: true, + }); + } + + getTypeKey(t: Type): string { + if ( + t.kind === ReflectionKind.string || + t.kind === ReflectionKind.number || + t.kind === ReflectionKind.bigint || + t.kind === ReflectionKind.boolean || + t.kind === ReflectionKind.null || + t.kind === ReflectionKind.undefined + ) { + return stringifyType(t); + } + + if ( + t.kind === ReflectionKind.class || + t.kind === ReflectionKind.objectLiteral || + t.kind === ReflectionKind.enum || + t.kind === ReflectionKind.union + ) { + return this.getSchemaKey(t); + } + + if (t.kind === ReflectionKind.array) { + return camelcase([this.getTypeKey(t.type), 'Array'], { + pascalCase: false, + }); + } + + return ''; + } + + registerSchema(name: string, type: Type, schema: Schema) { + const currentEntry = this.store.get(name); + + if (currentEntry && !isSameType(type, currentEntry?.type)) { + throw new OpenApiSchemaNameConflictError(type, currentEntry.type, name); + } + + this.store.set(name, { + type, + name, + schema, + }); + schema.__registryKey = name; + } +} diff --git a/packages/openapi/src/service.ts b/packages/openapi/src/service.ts new file mode 100644 index 000000000..dfe8f9404 --- /dev/null +++ b/packages/openapi/src/service.ts @@ -0,0 +1,21 @@ +import { HttpRouteFilter, HttpRouterFilterResolver } from '@deepkit/http'; +import { ScopedLogger } from '@deepkit/logger'; + +import { OpenAPIConfig } from './config.js'; +import { OpenAPIDocument } from './document.js'; +import { SerializedOpenAPI } from './types.js'; + +export class OpenAPIService { + constructor( + private routerFilter: HttpRouteFilter, + protected filterResolver: HttpRouterFilterResolver, + private logger: ScopedLogger, + private config: OpenAPIConfig, + ) {} + + serialize(): SerializedOpenAPI { + const routes = this.filterResolver.resolve(this.routerFilter.model); + const openApiDocument = new OpenAPIDocument(routes, this.logger, this.config); + return openApiDocument.serializeDocument(); + } +} diff --git a/packages/openapi/src/static-rewriting-listener.ts b/packages/openapi/src/static-rewriting-listener.ts new file mode 100644 index 000000000..e3a562146 --- /dev/null +++ b/packages/openapi/src/static-rewriting-listener.ts @@ -0,0 +1,131 @@ +import { stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import send from 'send'; +import { stringify } from 'yaml'; + +import { asyncOperation, urlJoin } from '@deepkit/core'; +import { eventDispatcher } from '@deepkit/event'; +import { HttpRequest, HttpResponse, RouteConfig, httpWorkflow, normalizeDirectory } from '@deepkit/http'; + +import { OpenAPIConfig } from './config'; +import { OpenAPIModule } from './module'; +import { OpenAPIService } from './service'; +import { SerializedOpenAPI } from './types'; + +export class OpenApiStaticRewritingListener { + private serialized?: SerializedOpenAPI; + private serializedYaml?: string; + private serializedJson?: string; + + constructor( + private openApi: OpenAPIService, + private config: OpenAPIConfig, + private module: OpenAPIModule, + ) {} + + serialize() { + if (this.serialized) return this.serialized; + const openApi = this.openApi.serialize(); + + openApi.info.title = this.config.title; + openApi.info.description = this.config.description; + openApi.info.version = this.config.version; + + this.module.configureOpenApiFunction(openApi); + this.serialized = openApi; + return openApi; + } + + get staticDirectory() { + return dirname(require.resolve('swagger-ui-dist')); + } + + get prefix() { + return normalizeDirectory(this.config.prefix); + } + + get swaggerInitializer() { + return ` + window.onload = function() { + window.ui = SwaggerUIBundle({ + url: ${JSON.stringify(`${this.prefix}openapi.yml`)}, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + }); + + }; + `; + } + + async serve(path: string, request: HttpRequest, response: HttpResponse): Promise { + if (path.endsWith('/swagger-initializer.js')) { + response.setHeader('content-type', 'application/javascript; charset=utf-8'); + response.end(this.swaggerInitializer); + } else if (path.endsWith('/openapi.json')) { + const s = this.serializedJson ?? JSON.stringify(this.serialize(), undefined, 2); + if (!this.serializedJson) this.serializedJson = s; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(s); + } else if (path.endsWith('/openapi.yaml') || path.endsWith('/openapi.yml')) { + const s = + this.serializedYaml ?? + stringify(this.serialize(), { + aliasDuplicateObjects: false, + }); + if (!this.serializedYaml) this.serializedYaml = s; + response.setHeader('content-type', 'text/yaml; charset=utf-8'); + response.end(s); + } else { + await asyncOperation(async resolve => { + const relativePath = urlJoin('/', request.url!.substring(this.prefix.length)); + if (relativePath === '') { + response.setHeader('location', `${this.prefix}index.html`); + response.status(301); + return; + } + const finalLocalPath = join(this.staticDirectory, relativePath); + + const statResult = await stat(finalLocalPath); + if (statResult.isFile()) { + const res = send(request, path, { root: this.staticDirectory }); + res.pipe(response); + res.on('end', resolve); + } else { + response.write(`The static path ${request.url} is not found.`); + response.status(404); + } + }); + } + } + + @eventDispatcher.listen(httpWorkflow.onRoute, 101) + onRoute(event: typeof httpWorkflow.onRoute.event) { + if (event.sent) return; + if (event.route) return; + + if (!event.request.url?.startsWith(this.prefix)) return; + + const relativePath = urlJoin('/', event.url.substring(this.prefix.length)); + + event.routeFound( + new RouteConfig('static', ['GET'], event.url, { + type: 'controller', + controller: OpenApiStaticRewritingListener, + module: this.module, + methodName: 'serve', + }), + () => ({ + arguments: [relativePath, event.request, event.response], + parameters: {}, + }), + ); + } +} diff --git a/packages/openapi/src/type-schema-resolver.ts b/packages/openapi/src/type-schema-resolver.ts new file mode 100644 index 000000000..5d95b165c --- /dev/null +++ b/packages/openapi/src/type-schema-resolver.ts @@ -0,0 +1,324 @@ +import { getParentClass } from '@deepkit/core'; +import { + ReflectionKind, + Type, + TypeClass, + TypeEnum, + TypeLiteral, + TypeObjectLiteral, + hasTypeInformation, + isDateType, + reflect, + validationAnnotation, +} from '@deepkit/type'; + +import { + OpenApiLiteralNotSupportedError, + OpenApiTypeError, + OpenApiTypeErrors, + OpenApiTypeNotSupportedError, +} from './errors'; +import { RegistrableSchema, SchemaRegistry } from './schema-registry'; +import { AnySchema, Schema } from './types'; +import { validators } from './validators'; + +// FIXME: handle circular dependencies between types, such as back references for entities +export class TypeSchemaResolver { + result: Schema = { ...AnySchema }; + errors: OpenApiTypeError[] = []; + + constructor( + public t: Type, + public schemaRegistry: SchemaRegistry, + ) {} + + resolveBasic() { + switch (this.t.kind) { + case ReflectionKind.never: + this.result.not = AnySchema; + return; + case ReflectionKind.any: + case ReflectionKind.unknown: + case ReflectionKind.void: + this.result = AnySchema; + return; + case ReflectionKind.object: + this.result.type = 'object'; + return; + case ReflectionKind.string: + this.result.type = 'string'; + return; + case ReflectionKind.number: + this.result.type = 'number'; + return; + case ReflectionKind.boolean: + this.result.type = 'boolean'; + return; + case ReflectionKind.bigint: + this.result.type = 'number'; + return; + case ReflectionKind.undefined: + case ReflectionKind.null: + this.result.nullable = true; + return; + case ReflectionKind.literal: { + const type = mapSimpleLiteralToType(this.t.literal); + if (type) { + this.result.type = type; + this.result.enum = [this.t.literal as any]; + } else { + this.errors.push(new OpenApiLiteralNotSupportedError(typeof this.t.literal)); + } + return; + } + case ReflectionKind.templateLiteral: + this.result.type = 'string'; + this.errors.push( + new OpenApiTypeNotSupportedError(this.t, 'Literal is treated as string for simplicity'), + ); + + return; + case ReflectionKind.class: + case ReflectionKind.objectLiteral: + this.resolveClassOrObjectLiteral(); + return; + case ReflectionKind.array: { + this.result.type = 'array'; + const itemsResult = resolveTypeSchema(this.t.type, this.schemaRegistry); + + this.result.items = itemsResult.result; + this.errors.push(...itemsResult.errors); + return; + } + case ReflectionKind.enum: + this.resolveEnum(); + return; + case ReflectionKind.union: + this.resolveUnion(); + return; + default: + this.errors.push(new OpenApiTypeNotSupportedError(this.t)); + return; + } + } + + resolveClassOrObjectLiteral() { + if (this.t.kind !== ReflectionKind.class && this.t.kind !== ReflectionKind.objectLiteral) { + return; + } + + // Dates will be serialized to string + if (isDateType(this.t)) { + this.result.type = 'string'; + return; + } + + this.result.type = 'object'; + + let typeClass: TypeClass | TypeObjectLiteral | undefined = this.t; + this.result.properties = {}; + + const typeClasses: (TypeClass | TypeObjectLiteral | undefined)[] = [this.t]; + + const required: string[] = []; + + if (this.t.kind === ReflectionKind.class) { + this.schemaRegistry.types.set(this.t, this); + // Build a list of inheritance, from root to current class. + while (true) { + const parentClass = getParentClass((typeClass as TypeClass).classType); + if (parentClass && hasTypeInformation(parentClass)) { + typeClass = reflect(parentClass) as TypeClass | TypeObjectLiteral; + typeClasses.unshift(typeClass); + } else { + break; + } + } + } + + // Follow the order to override properties. + for (const typeClass of typeClasses) { + for (const typeItem of typeClass!.types) { + if (typeItem.kind === ReflectionKind.property || typeItem.kind === ReflectionKind.propertySignature) { + // TODO: handle back reference / circular dependencies + const typeResolver = resolveTypeSchema(typeItem.type, this.schemaRegistry); + if (typeItem.description) { + // TODO: handle description annotation + // const descriptionAnnotation = metaAnnotation + // .getAnnotations(typeItem) + // .find(t => t.name === 'openapi:description'); + typeResolver.result.description = typeItem.description; + } + + if (!typeItem.optional && !required.includes(String(typeItem.name))) { + required.push(String(typeItem.name)); + } + + this.result.properties[String(typeItem.name)] = typeResolver.result; + this.errors.push(...typeResolver.errors); + } + } + } + + if (required.length) { + this.result.required = required; + } + + if (!this.schemaRegistry.types.has(this.t)) { + const registryKey = this.schemaRegistry.getSchemaKey(this.t); + + if (registryKey) { + this.schemaRegistry.registerSchema(registryKey, this.t, this.result); + } + } + } + + resolveEnum() { + if (this.t.kind !== ReflectionKind.enum) { + return; + } + + const types = new Set(); + + for (const value of this.t.values) { + const currentType = mapSimpleLiteralToType(value); + + if (!currentType) { + this.errors.push(new OpenApiTypeNotSupportedError(this.t, 'Enum with unsupported members')); + continue; + } + + types.add(currentType); + } + + this.result.type = types.size > 1 ? undefined : [...types.values()][0]; + this.result.enum = this.t.values as any; + + const registryKey = this.schemaRegistry.getSchemaKey(this.t); + if (registryKey) { + this.schemaRegistry.registerSchema(registryKey, this.t, this.result); + } + } + + resolveUnion() { + if (this.t.kind !== ReflectionKind.union) { + return; + } + + const hasNil = this.t.types.some(t => t.kind === ReflectionKind.null || t.kind === ReflectionKind.undefined); + if (hasNil) { + this.result.nullable = true; + this.t = { + ...this.t, + types: this.t.types.filter(t => t.kind !== ReflectionKind.null && t.kind !== ReflectionKind.undefined), + }; + } + + // if there's only one type left in the union, pull it up a level and go back to resolveBasic + if (this.t.types.length === 1) { + this.t = this.t.types[0]; + return this.resolveBasic(); + } + + // Find out whether it is a union of literals. If so, treat it as an enum + if ( + this.t.types.every( + (t): t is TypeLiteral => + t.kind === ReflectionKind.literal && + ['string', 'number'].includes(mapSimpleLiteralToType(t.literal) as any), + ) + ) { + const enumType: TypeEnum = { + ...this.t, + kind: ReflectionKind.enum, + enum: Object.fromEntries(this.t.types.map(t => [t.literal, t.literal as any])), + values: this.t.types.map(t => t.literal as any), + indexType: this.t, + }; + + const { result, errors } = resolveTypeSchema(enumType, this.schemaRegistry); + this.result = result; + this.errors.push(...errors); + if (hasNil) { + this.result.enum!.push(null); + this.result.nullable = true; + } + return; + } + + this.result.type = undefined; + this.result.oneOf = []; + + for (const t of this.t.types) { + const { result, errors } = resolveTypeSchema(t, this.schemaRegistry); + this.result.oneOf?.push(result); + this.errors.push(...errors); + } + } + + resolveValidators() { + for (const annotation of validationAnnotation.getAnnotations(this.t)) { + const { name, args } = annotation; + + const validator = validators[name]; + + if (!validator) { + this.errors.push(new OpenApiTypeNotSupportedError(this.t, `Validator ${name} is not supported. `)); + } else { + try { + this.result = validator(this.result, ...(args as [any])); + } catch (e) { + if (e instanceof OpenApiTypeNotSupportedError) { + this.errors.push(e); + } else { + throw e; + } + } + } + } + } + + resolve() { + if (this.schemaRegistry.types.has(this.t)) { + // @ts-ignore + this.result = { + $ref: `#/components/schemas/${this.schemaRegistry.getSchemaKey(this.t as RegistrableSchema)}`, + }; + return this; + } + this.resolveBasic(); + this.resolveValidators(); + + return this; + } +} + +export const mapSimpleLiteralToType = (literal: unknown) => { + if (typeof literal === 'string') { + return 'string'; + } + if (typeof literal === 'bigint') { + return 'integer'; + } + if (typeof literal === 'number') { + return 'number'; + } + if (typeof literal === 'boolean') { + return 'boolean'; + } + return undefined; +}; + +export const unwrapTypeSchema = (t: Type, _r: SchemaRegistry = new SchemaRegistry()) => { + const resolver = new TypeSchemaResolver(t, new SchemaRegistry()).resolve(); + + if (resolver.errors.length !== 0) { + throw new OpenApiTypeErrors(resolver.errors, 'Errors with input type. '); + } + + return resolver.result; +}; + +export const resolveTypeSchema = (t: Type, r: SchemaRegistry = new SchemaRegistry()) => { + return new TypeSchemaResolver(t, r).resolve(); +}; diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts new file mode 100644 index 000000000..580fb1428 --- /dev/null +++ b/packages/openapi/src/types.ts @@ -0,0 +1,144 @@ +import { ClassType } from '@deepkit/core'; +import type { parseRouteControllerAction } from '@deepkit/http'; +import { Excluded, JSONEntity } from '@deepkit/type'; + +export type SchemaMapper = (s: Schema, ...args: any[]) => Schema; + +export type SimpleType = string | number | boolean | null | bigint; + +export type Schema = { + __type: 'schema' & Excluded; + __registryKey?: string & Excluded; + __isComponent?: boolean & Excluded; + type?: string; + not?: Schema; + pattern?: string; + multipleOf?: number; + minLength?: number; + description?: string; + maxLength?: number; + minimum?: number | bigint; + exclusiveMinimum?: number | bigint; + maximum?: number | bigint; + exclusiveMaximum?: number | bigint; + enum?: SimpleType[]; + properties?: Record; + required?: string[]; + nullable?: boolean; + items?: Schema; + default?: any; + oneOf?: Schema[]; + + $ref?: string; +}; + +export const AnySchema: Schema = { __type: 'schema' }; + +export const NumberSchema: Schema = { + __type: 'schema', + type: 'number', +}; + +export const StringSchema: Schema = { + __type: 'schema', + type: 'string', +}; + +export const BooleanSchema: Schema = { + __type: 'schema', + type: 'boolean', +}; + +export type RequestMediaTypeName = 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'application/json'; + +export type Tag = { + __controller: ClassType & Excluded; + name: string; +}; + +export type OpenAPIResponse = { + description: string; + content: { + 'application/json'?: MediaType; + }; +}; + +export type Responses = Record; + +export type Operation = { + __path: string & Excluded; + __method: string & Excluded; + tags: string[]; + summary?: string; + description?: string; + operationId?: string; + deprecated?: boolean; + parameters?: Parameter[]; + requestBody?: RequestBody; + responses?: Responses; +}; + +export type RequestBody = { + content: Record; + required?: boolean; +}; + +export type MediaType = { + schema?: Schema; + example?: any; +}; + +export type Path = { + summary?: string; + description?: string; + get?: Operation; + put?: Operation; + post?: Operation; + delete?: Operation; + options?: Operation; + head?: Operation; + patch?: Operation; + trace?: Operation; +}; + +export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'; + +export type ParameterIn = 'query' | 'header' | 'path' | 'cookie'; + +export type Parameter = { + in: ParameterIn; + name: string; + required?: boolean; + deprecated?: boolean; + schema?: Schema; +}; + +export type ParsedRoute = ReturnType; + +export type ParsedRouteParameter = ParsedRoute['parameters'][number]; + +export type Info = { + title: string; + description?: string; + termsOfService?: string; + contact?: { + name: string; + }; + license?: unknown; + version: string; +}; + +export type Components = { + schemas?: Record; +}; + +// TODO: rename to Internal +export type OpenAPI = { + openapi: string; + info: Info; + servers: { url: string }[]; + paths: Record; + components: Components; +}; + +export type SerializedOpenAPI = JSONEntity; diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts new file mode 100644 index 000000000..4ab40207d --- /dev/null +++ b/packages/openapi/src/utils.ts @@ -0,0 +1,7 @@ +import '@deepkit/type'; + +export const resolveOpenApiPath = (deepkitPath: string) => { + let s = deepkitPath.replace(/:(\w+)/g, (_, name) => `\{${name}\}`); + s = !s.startsWith('/') ? '/' + s : s; + return s; +}; diff --git a/packages/openapi/src/validators.ts b/packages/openapi/src/validators.ts new file mode 100644 index 000000000..93610b569 --- /dev/null +++ b/packages/openapi/src/validators.ts @@ -0,0 +1,109 @@ +import { TypeLiteral } from '@deepkit/type'; + +import { OpenApiTypeNotSupportedError } from './errors'; +import { Schema, SchemaMapper } from './types.js'; + +export const validators: Record = { + pattern(s, type: TypeLiteral & { literal: RegExp }): Schema { + return { + ...s, + pattern: type.literal.source, + }; + }, + alpha(s): Schema { + return { + ...s, + pattern: '^[A-Za-z]+$', + }; + }, + alphanumeric(s): Schema { + return { + ...s, + pattern: '^[0-9A-Za-z]+$', + }; + }, + ascii(s): Schema { + return { + ...s, + pattern: '^[\x00-\x7F]+$', + }; + }, + dataURI(s): Schema { + return { + ...s, + pattern: '^(data:)([w/+-]*)(;charset=[w-]+|;base64){0,1},(.*)', + }; + }, + decimal(s, minDigits: TypeLiteral & { literal: number }, maxDigits: TypeLiteral & { literal: number }): Schema { + return { + ...s, + pattern: '^-?\\d+\\.\\d{' + minDigits.literal + ',' + maxDigits.literal + '}$', + }; + }, + multipleOf(s, num: TypeLiteral & { literal: number }): Schema { + if (num.literal === 0) throw new OpenApiTypeNotSupportedError(num, `multiple cannot be 0`); + + return { + ...s, + multipleOf: num.literal, + }; + }, + minLength(s, length: TypeLiteral & { literal: number }): Schema { + if (length.literal < 0) throw new OpenApiTypeNotSupportedError(length, `length cannot be less than 0`); + + return { + ...s, + minLength: length.literal, + }; + }, + maxLength(s, length: TypeLiteral & { literal: number }): Schema { + if (length.literal < 0) throw new OpenApiTypeNotSupportedError(length, `length cannot be less than 0`); + + return { + ...s, + maxLength: length.literal, + }; + }, + includes(s, include: TypeLiteral): Schema { + throw new OpenApiTypeNotSupportedError(include, `includes is not supported. `); + }, + excludes(s, exclude: TypeLiteral): Schema { + throw new OpenApiTypeNotSupportedError(exclude, `excludes is not supported. `); + }, + minimum(s, min: TypeLiteral & { literal: number | bigint }): Schema { + return { + ...s, + minimum: min.literal, + }; + }, + exclusiveMinimum(s, min: TypeLiteral & { literal: number | bigint }): Schema { + return { + ...s, + exclusiveMinimum: min.literal, + }; + }, + maximum(s, max: TypeLiteral & { literal: number | bigint }): Schema { + return { + ...s, + maximum: max.literal, + }; + }, + exclusiveMaximum(s, max: TypeLiteral & { literal: number | bigint }): Schema { + return { + ...s, + exclusiveMaximum: max.literal, + }; + }, + positive(s): Schema { + return { + ...s, + exclusiveMinimum: 0, + }; + }, + negative(s): Schema { + return { + ...s, + exclusiveMaximum: 0, + }; + }, +}; diff --git a/packages/openapi/tests/type-schema-resolver.spec.ts b/packages/openapi/tests/type-schema-resolver.spec.ts new file mode 100644 index 000000000..dab757ad3 --- /dev/null +++ b/packages/openapi/tests/type-schema-resolver.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from '@jest/globals'; + +import { MaxLength, Maximum, MinLength, Minimum, typeOf } from '@deepkit/type'; + +import { unwrapTypeSchema } from '../src/type-schema-resolver'; + +test('serialize atomic types', () => { + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'string', + }); + + expect(unwrapTypeSchema(typeOf>())).toMatchObject({ + __type: 'schema', + type: 'number', + minLength: 5, + }); + + expect(unwrapTypeSchema(typeOf>())).toMatchObject({ + __type: 'schema', + type: 'number', + maxLength: 5, + }); + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'number', + }); + + expect(unwrapTypeSchema(typeOf>())).toMatchObject({ + __type: 'schema', + type: 'number', + minimum: 5, + }); + + expect(unwrapTypeSchema(typeOf>())).toMatchObject({ + __type: 'schema', + type: 'number', + maximum: 5, + }); + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'number', + }); + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'boolean', + }); + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + nullable: true, + }); +}); + +test('serialize enum', () => { + enum E1 { + a = 'a', + b = 'b', + } + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'string', + enum: ['a', 'b'], + __registryKey: 'E1', + }); + + enum E2 { + a = 1, + b = 2, + } + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'number', + enum: [1, 2], + __registryKey: 'E2', + }); +}); + +test('serialize union', () => { + type Union = + | { + type: 'push'; + branch: string; + } + | { + type: 'commit'; + diff: string[]; + }; + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + oneOf: [ + { + __type: 'schema', + type: 'object', + properties: { + type: { __type: 'schema', type: 'string', enum: ['push'] }, + branch: { __type: 'schema', type: 'string' }, + }, + required: ['type', 'branch'], + }, + { + __type: 'schema', + type: 'object', + properties: { + type: { __type: 'schema', type: 'string', enum: ['commit'] }, + diff: { + __type: 'schema', + type: 'array', + items: { __type: 'schema', type: 'string' }, + }, + }, + required: ['type', 'diff'], + }, + ], + }); + + type EnumLike = 'red' | 'black'; + + expect(unwrapTypeSchema(typeOf())).toMatchObject({ + __type: 'schema', + type: 'string', + enum: ['red', 'black'], + __registryKey: 'EnumLike', + }); +}); + +test('serialize nullables', () => { + const t1 = unwrapTypeSchema(typeOf()); + expect(t1).toMatchObject({ + __type: 'schema', + type: 'string', + }); + expect(t1.nullable).toBeUndefined(); + + const t2 = unwrapTypeSchema(typeOf()); + expect(t2).toMatchObject({ + __type: 'schema', + type: 'string', + nullable: true, + }); + + interface ITest { + names: string[]; + } + const t3 = unwrapTypeSchema(typeOf()); + expect(t3).toMatchObject({ + __type: 'schema', + type: 'object', + }); + expect(t3.nullable).toBeUndefined(); + + const t4 = unwrapTypeSchema(typeOf()); + expect(t4).toMatchObject({ + __type: 'schema', + type: 'object', + nullable: true, + }); + + // const t5 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c'>()); + // expect(t5).toMatchObject({ + // __type: 'schema', + // type: 'string', + // enum: ['a', 'b', 'c'], + // }); + // expect(t5.nullable).toBeUndefined(); + + const t6 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c' | null>()); + expect(t6).toMatchObject({ + __type: 'schema', + type: 'string', + enum: ['a', 'b', 'c', null], + nullable: true, + }); + + const t7 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c' | undefined>()); + expect(t7).toMatchObject({ + __type: 'schema', + type: 'string', + enum: ['a', 'b', 'c', null], + nullable: true, + }); +}); diff --git a/packages/openapi/tsconfig.esm.json b/packages/openapi/tsconfig.esm.json new file mode 100644 index 000000000..7fc6e066d --- /dev/null +++ b/packages/openapi/tsconfig.esm.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "ES2020" + }, + "references": [ + { + "path": "../bson/tsconfig.esm.json" + }, + { + "path": "../core/tsconfig.esm.json" + }, + { + "path": "../core-rxjs/tsconfig.esm.json" + }, + { + "path": "../event/tsconfig.esm.json" + }, + { + "path": "../rpc/tsconfig.esm.json" + }, + { + "path": "../type/tsconfig.esm.json" + }, + { + "path": "../app/tsconfig.esm.json" + } + ] +} \ No newline at end of file diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json new file mode 100644 index 000000000..48e543c12 --- /dev/null +++ b/packages/openapi/tsconfig.json @@ -0,0 +1,50 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "moduleResolution": "node", + "target": "es2022", + "module": "CommonJS", + "esModuleInterop": true, + "outDir": "./dist/cjs", + "declaration": true, + "composite": true, + "types": [ + "node" + ] + }, + "reflection": true, + "include": [ + "src", + "index.ts" + ], + "exclude": [ + "tests" + ], + "references": [ + { + "path": "../http/tsconfig.json" + }, + { + "path": "../core/tsconfig.json" + }, + { + "path": "../event/tsconfig.json" + }, + { + "path": "../type/tsconfig.json" + }, + { + "path": "../app/tsconfig.json" + }, + { + "path": "../injector/tsconfig.json" + } + ] +} diff --git a/packages/openapi/tsconfig.spec.json b/packages/openapi/tsconfig.spec.json new file mode 100644 index 000000000..b764fc77d --- /dev/null +++ b/packages/openapi/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src", + "index.ts", + "tests" + ] +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 2198e969f..885dcc0ba 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -19,6 +19,9 @@ { "path": "packages/bson/tsconfig.esm.json" }, + { + "path": "packages/openapi/tsconfig.esm.json" + }, { "path": "packages/api-console-api/tsconfig.esm.json" }, diff --git a/tsconfig.json b/tsconfig.json index 6d31cdab3..39831c1a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,9 @@ { "path": "packages/framework/tsconfig.json" }, + { + "path": "packages/openapi/tsconfig.json" + }, { "path": "packages/type/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index 9df02bfe3..2ed6ee194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4361,6 +4361,32 @@ __metadata: languageName: unknown linkType: soft +"@deepkit/openapi@workspace:packages/openapi": + version: 0.0.0-use.local + resolution: "@deepkit/openapi@workspace:packages/openapi" + dependencies: + "@deepkit/core": "npm:^1.0.5" + "@deepkit/event": "npm:^1.0.8" + "@deepkit/http": "npm:^1.0.9" + "@deepkit/injector": "npm:^1.0.8" + "@deepkit/type": "npm:^1.0.8" + "@types/lodash": "npm:4.17.17" + "@types/lodash.clonedeepwith": "npm:4.5.9" + "@types/send": "npm:0.17.4" + camelcase: "npm:8.0.0" + lodash.clonedeepwith: "npm:4.5.0" + send: "npm:1.2.0" + swagger-ui-dist: "npm:5.22.0" + yaml: "npm: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 + languageName: unknown + linkType: soft + "@deepkit/orm-browser-api@npm:^1.0.10, @deepkit/orm-browser-api@workspace:packages/orm-browser-api": version: 0.0.0-use.local resolution: "@deepkit/orm-browser-api@workspace:packages/orm-browser-api" @@ -8499,6 +8525,13 @@ __metadata: languageName: node linkType: hard +"@scarf/scarf@npm:=1.4.0": + version: 1.4.0 + resolution: "@scarf/scarf@npm:1.4.0" + checksum: 332118bb488e7a70eaad068fb1a33f016d30442fb0498b37a80cb425c1e741853a5de1a04dce03526ed6265481ecf744aa6e13f072178d19e6b94b19f623ae1c + languageName: node + linkType: hard + "@schematics/angular@npm:19.1.6": version: 19.1.6 resolution: "@schematics/angular@npm:19.1.6" @@ -10053,6 +10086,22 @@ __metadata: languageName: node linkType: hard +"@types/lodash.clonedeepwith@npm:4.5.9": + version: 4.5.9 + resolution: "@types/lodash.clonedeepwith@npm:4.5.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 64077f4a77ab80918404af74a9186bf943a4b39589bb48f4ad09603625c2ac0df1641bf66a3254d5731a86259ba2bf6154c20ce136fd470ee4e59cee14a84abc + languageName: node + linkType: hard + +"@types/lodash@npm:*, @types/lodash@npm:4.17.17": + version: 4.17.17 + resolution: "@types/lodash@npm:4.17.17" + checksum: 8e75df02a15f04d4322c5a503e4efd0e7a92470570ce80f17e9f11ce2b1f1a7c994009c9bcff39f07e0f9ffd8ccaff09b3598997c404b801abd5a7eee5a639dc + languageName: node + linkType: hard + "@types/lz-string@npm:^1.3.34": version: 1.3.34 resolution: "@types/lz-string@npm:1.3.34" @@ -10239,7 +10288,7 @@ __metadata: languageName: node linkType: hard -"@types/send@npm:*": +"@types/send@npm:*, @types/send@npm:0.17.4": version: 0.17.4 resolution: "@types/send@npm:0.17.4" dependencies: @@ -11984,6 +12033,13 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:8.0.0": + version: 8.0.0 + resolution: "camelcase@npm:8.0.0" + checksum: 56c5fe072f0523c9908cdaac21d4a3b3fb0f608fb2e9ba90a60e792b95dd3bb3d1f3523873ab17d86d146e94171305f73ef619e2f538bd759675bc4a14b4bff3 + languageName: node + linkType: hard + "camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -15527,6 +15583,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 0557548194cb9a809a435bf92bcfbc20c89e8b5eb38861b73ced36750437251e39a111fc3a18b98531be9dd91fe1411e4969f229dc579ec0251ce6c5d4900bbc + languageName: node + linkType: hard + "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" @@ -20299,6 +20362,13 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 8d907917bc2a90fa2df842cdf5dfeaf509adc15fe0531e07bb2f6ab15992416479015828d6a74200041c492e42cce3ebf78e5ce714388a0a538ea9c53eece284 + languageName: node + linkType: hard + "mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" @@ -20308,6 +20378,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^3.0.1": + version: 3.0.1 + resolution: "mime-types@npm:3.0.1" + dependencies: + mime-db: "npm:^1.54.0" + checksum: bd8c20d3694548089cf229016124f8f40e6a60bbb600161ae13e45f793a2d5bb40f96bbc61f275836696179c77c1d6bf4967b2a75e0a8ad40fe31f4ed5be4da5 + languageName: node + linkType: hard + "mime@npm:1.6.0, mime@npm:^1.4.1": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -24455,6 +24534,25 @@ __metadata: languageName: node linkType: hard +"send@npm:1.2.0": + version: 1.2.0 + resolution: "send@npm:1.2.0" + dependencies: + debug: "npm:^4.3.5" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + mime-types: "npm:^3.0.1" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.1" + checksum: 531bcfb5616948d3468d95a1fd0adaeb0c20818ba4a500f439b800ca2117971489e02074ce32796fd64a6772ea3e7235fe0583d8241dbd37a053dc3378eff9a5 + languageName: node + linkType: hard + "send@npm:^0.18.0": version: 0.18.0 resolution: "send@npm:0.18.0" @@ -25582,6 +25680,15 @@ __metadata: languageName: node linkType: hard +"swagger-ui-dist@npm:5.22.0": + version: 5.22.0 + resolution: "swagger-ui-dist@npm:5.22.0" + dependencies: + "@scarf/scarf": "npm:=1.4.0" + checksum: ae30cdfd92f56f05c0d7775a76cc1a2d2fc19fafbb1ef8364dc4f4b1391ac541fc8c0ed5508733b42af2799345141cd1257763a276ed65f500617125b74ad1cf + languageName: node + linkType: hard + "swagger-ui-dist@npm:^4.13.2": version: 4.19.1 resolution: "swagger-ui-dist@npm:4.19.1" @@ -28058,6 +28165,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:2.8.0": + version: 2.8.0 + resolution: "yaml@npm:2.8.0" + bin: + yaml: bin.mjs + checksum: f6f7310cf7264a8107e72c1376f4de37389945d2fb4656f8060eca83f01d2d703f9d1b925dd8f39852a57034fafefde6225409ddd9f22aebfda16c6141b71858 + languageName: node + linkType: hard + "yaml@npm:^1.10.0": version: 1.10.2 resolution: "yaml@npm:1.10.2"