From d458c9ae94ce4b64e54dcacaf1bca189f62fde15 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 09:41:16 +0200 Subject: [PATCH 01/10] feat: initial openapi package --- packages/openapi/.npmignore | 1 + packages/openapi/dist/.gitkeep | 0 packages/openapi/index.ts | 7 + packages/openapi/package.json | 66 ++++ packages/openapi/src/annotations.ts | 7 + packages/openapi/src/config.ts | 9 + packages/openapi/src/document.ts | 234 ++++++++++++++ packages/openapi/src/errors.ts | 67 ++++ packages/openapi/src/module.ts | 32 ++ packages/openapi/src/parameters-resolver.ts | 107 +++++++ packages/openapi/src/schema-registry.ts | 115 +++++++ packages/openapi/src/service.ts | 20 ++ .../openapi/src/static-rewriting-listener.ts | 118 +++++++ packages/openapi/src/type-schema-resolver.ts | 293 ++++++++++++++++++ packages/openapi/src/types.ts | 129 ++++++++ packages/openapi/src/utils.ts | 5 + packages/openapi/src/validators.ts | 109 +++++++ .../tests/type-schema-resolver.spec.ts | 180 +++++++++++ packages/openapi/tsconfig.esm.json | 30 ++ packages/openapi/tsconfig.json | 50 +++ packages/openapi/tsconfig.spec.json | 8 + yarn.lock | 98 ++++++ 22 files changed, 1685 insertions(+) create mode 100644 packages/openapi/.npmignore create mode 100644 packages/openapi/dist/.gitkeep create mode 100644 packages/openapi/index.ts create mode 100644 packages/openapi/package.json create mode 100644 packages/openapi/src/annotations.ts create mode 100644 packages/openapi/src/config.ts create mode 100644 packages/openapi/src/document.ts create mode 100644 packages/openapi/src/errors.ts create mode 100644 packages/openapi/src/module.ts create mode 100644 packages/openapi/src/parameters-resolver.ts create mode 100644 packages/openapi/src/schema-registry.ts create mode 100644 packages/openapi/src/service.ts create mode 100644 packages/openapi/src/static-rewriting-listener.ts create mode 100644 packages/openapi/src/type-schema-resolver.ts create mode 100644 packages/openapi/src/types.ts create mode 100644 packages/openapi/src/utils.ts create mode 100644 packages/openapi/src/validators.ts create mode 100644 packages/openapi/tests/type-schema-resolver.spec.ts create mode 100644 packages/openapi/tsconfig.esm.json create mode 100644 packages/openapi/tsconfig.json create mode 100644 packages/openapi/tsconfig.spec.json 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/dist/.gitkeep b/packages/openapi/dist/.gitkeep new file mode 100644 index 000000000..e69de29bb 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..7f9d76c03 --- /dev/null +++ b/packages/openapi/package.json @@ -0,0 +1,66 @@ +{ + "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.1", + "@deepkit/event": "^1.0.1", + "@deepkit/http": "^1.0.1", + "@deepkit/injector": "^1.0.1", + "@deepkit/type": "^1.0.1", + "@types/lodash.clonedeepwith": "4.5.9" + }, + "devDependencies": { + "@deepkit/core": "^1.0.5", + "@deepkit/event": "^1.0.8", + "@deepkit/http": "^1.0.1", + "@deepkit/injector": "^1.0.8", + "@deepkit/type": "^1.0.8" + }, + "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..ae0801c18 --- /dev/null +++ b/packages/openapi/src/config.ts @@ -0,0 +1,9 @@ +import { OpenAPICoreConfig } from './document'; + +export class OpenAPIConfig extends OpenAPICoreConfig { + title: string = 'OpenAPI'; + description: string = ''; + version: string = '1.0.0'; + // Prefix for all OpenAPI related controllers + prefix: string = '/openapi/'; +} diff --git a/packages/openapi/src/document.ts b/packages/openapi/src/document.ts new file mode 100644 index 000000000..049c09432 --- /dev/null +++ b/packages/openapi/src/document.ts @@ -0,0 +1,234 @@ +import camelCase from 'camelcase'; +// @ts-ignore +import cloneDeepWith from 'lodash.clonedeepwith'; + +import { ClassType } from '@deepkit/core'; +import { RouteClassControllerAction, RouteConfig, parseRouteControllerAction } from '@deepkit/http'; +import { ScopedLogger } from '@deepkit/logger'; +import { ReflectionKind } from '@deepkit/type'; + +import { OpenApiControllerNameConflict, OpenApiOperationNameConflict, TypeError } from './errors'; +import { ParametersResolver } from './parameters-resolver'; +import { SchemaKeyFn, SchemaRegistry } from './schema-registry'; +import { resolveTypeSchema } from './type-schema-resolver'; +import { + HttpMethod, + OpenAPI, + OpenAPIResponse, + Operation, + ParsedRoute, + RequestMediaTypeName, + Responses, + Schema, + Tag, +} from './types'; +import { resolveOpenApiPath } from './utils'; + +export class OpenAPICoreConfig { + customSchemaKeyFn?: SchemaKeyFn; + contentTypes?: RequestMediaTypeName[]; +} + +export class OpenAPIDocument { + schemaRegistry = new SchemaRegistry(this.config.customSchemaKeyFn); + + operations: Operation[] = []; + + tags: Tag[] = []; + + errors: TypeError[] = []; + + constructor( + private routes: RouteConfig[], + private log: ScopedLogger, + private config: OpenAPICoreConfig = {}, + ) {} + + getControllerName(controller: ClassType) { + // TODO: Allow customized name + return 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 OpenApiControllerNameConflict(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: 'OpenAPI', + contact: {}, + license: { name: 'MIT' }, + version: '0.0.1', + }, + 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(): OpenAPI { + // @ts-ignore + return 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; + } + + for (const key of Object.keys(c)) { + // Remove internal keys. + if (key.startsWith('__')) delete c[key]; + } + } + }); + } + + registerRouteSafe(route: RouteConfig) { + try { + this.registerRoute(route); + } catch (err: any) { + 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('/') ? '' : '/'; + + const operation: Operation = { + __path: `${route.baseUrl}${slash}${route.path}`, + __method: method.toLowerCase(), + tags: [tag.name], + operationId: camelCase([method, tag.name, route.action.methodName]), + parameters: parametersResolver.parameters.length > 0 ? parametersResolver.parameters : undefined, + requestBody: parametersResolver.requestBody, + responses, + description: route.description, + summary: route.name, + }; + + if (this.operations.find(p => p.__path === operation.__path && p.__method === operation.__method)) { + throw new OpenApiOperationNameConflict(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: '', + 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: '', + 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..c7a7fc92f --- /dev/null +++ b/packages/openapi/src/errors.ts @@ -0,0 +1,67 @@ +import { ClassType, getClassName } from '@deepkit/core'; +import { Type, stringifyType } from '@deepkit/type'; + +export class OpenApiError extends Error {} + +export class TypeError extends OpenApiError {} + +export class TypeNotSupported extends TypeError { + constructor( + public type: Type, + public reason: string = '', + ) { + super(`${stringifyType(type)} is not supported. ${reason}`); + } +} + +export class LiteralSupported extends TypeError { + constructor(public typeName: string) { + super(`${typeName} is not supported. `); + } +} + +export class TypeErrors extends OpenApiError { + constructor( + public errors: TypeError[], + message: string, + ) { + super(message); + } +} + +export class OpenApiSchemaNameConflict extends OpenApiError { + constructor( + public newType: Type, + public oldType: Type, + public name: string, + ) { + super( + `${stringifyType(newType)} and ${stringifyType( + oldType, + )} are not the same, but their schema are both named as ${JSON.stringify(name)}. ` + + `Try to fix the naming of related types, or rename them using 'YourClass & Name'`, + ); + } +} + +export class OpenApiControllerNameConflict extends OpenApiError { + constructor( + public newController: ClassType, + public oldController: ClassType, + public name: string, + ) { + super( + `${getClassName(newController)} and ${getClassName(oldController)} are both tagged as ${name}. ` + + `Please consider renaming them. `, + ); + } +} + +export class OpenApiOperationNameConflict 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..6887909dc --- /dev/null +++ b/packages/openapi/src/module.ts @@ -0,0 +1,32 @@ +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 { OpenAPI } 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: OpenAPI) => void = () => {}; + + configureOpenApi(configure: (openApi: OpenAPI) => void) { + this.configureOpenApiFunction = configure; + return this; + } + + configureHttpRouteFilter(configure: (filter: HttpRouteFilter) => void) { + configure(this.routeFilter); + return this; + } +} diff --git a/packages/openapi/src/parameters-resolver.ts b/packages/openapi/src/parameters-resolver.ts new file mode 100644 index 000000000..b4fb8aff1 --- /dev/null +++ b/packages/openapi/src/parameters-resolver.ts @@ -0,0 +1,107 @@ +import { ReflectionKind } from '@deepkit/type'; + +import { OpenApiError, TypeError } 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: TypeError[] = []; + + 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 TypeError( + `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..f74791124 --- /dev/null +++ b/packages/openapi/src/schema-registry.ts @@ -0,0 +1,115 @@ +import camelcase from 'camelcase'; + +import { HttpBody, HttpBodyValidation, HttpQueries } from '@deepkit/http'; +import { + ReflectionKind, + Type, + TypeClass, + TypeEnum, + TypeObjectLiteral, + TypeUnion, + isSameType, + metaAnnotation, + stringifyType, + typeOf, +} from '@deepkit/type'; + +import { OpenApiSchemaNameConflict } from './errors'; +import { Schema } from './types'; + +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(); + + 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; + } + + isSameType(t, typeOf>()); + + // 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); + } else if ( + t.kind === ReflectionKind.class || + t.kind === ReflectionKind.objectLiteral || + t.kind === ReflectionKind.enum || + t.kind === ReflectionKind.union + ) { + return this.getSchemaKey(t); + } else if (t.kind === ReflectionKind.array) { + return camelcase([this.getTypeKey(t.type), 'Array'], { + pascalCase: false, + }); + } else { + // Complex types not named + return ''; + } + } + + registerSchema(name: string, type: Type, schema: Schema) { + const currentEntry = this.store.get(name); + + if (currentEntry && !isSameType(type, currentEntry?.type)) { + throw new OpenApiSchemaNameConflict(type, currentEntry.type, name); + } + + this.store.set(name, { + type, + name, + schema: { + ...schema, + nullable: undefined, + }, + }); + schema.__registryKey = name; + } +} diff --git a/packages/openapi/src/service.ts b/packages/openapi/src/service.ts new file mode 100644 index 000000000..36e37d539 --- /dev/null +++ b/packages/openapi/src/service.ts @@ -0,0 +1,20 @@ +import { HttpRouteFilter, HttpRouterFilterResolver } from '@deepkit/http'; +import { ScopedLogger } from '@deepkit/logger'; + +import { OpenAPIConfig } from './config'; +import { OpenAPIDocument } from './document'; + +export class OpenAPIService { + constructor( + private routerFilter: HttpRouteFilter, + protected filterResolver: HttpRouterFilterResolver, + private logger: ScopedLogger, + private config: OpenAPIConfig, + ) {} + + serialize() { + 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..e58c70cb5 --- /dev/null +++ b/packages/openapi/src/static-rewriting-listener.ts @@ -0,0 +1,118 @@ +import { stat } from 'fs/promises'; +import { dirname, join } from 'path'; +import send from 'send'; +import { stringify } from 'yaml'; + +import { 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'; + +export class OpenApiStaticRewritingListener { + constructor( + private openApi: OpenAPIService, + private config: OpenAPIConfig, + private module: OpenAPIModule, + ) {} + + serialize() { + 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); + 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" + }); + + }; + `; + } + + // @ts-ignore + serve(path: string, request: HttpRequest, response: HttpResponse) { + 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 = JSON.stringify(this.serialize(), undefined, 2); + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(s); + } else if (path.endsWith('/openapi.yaml') || path.endsWith('/openapi.yml')) { + const s = stringify(this.serialize(), { + aliasDuplicateObjects: false, + }); + response.setHeader('content-type', 'text/yaml; charset=utf-8'); + response.end(s); + } else { + return new Promise(async (resolve, reject) => { + 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..dd7f4ce84 --- /dev/null +++ b/packages/openapi/src/type-schema-resolver.ts @@ -0,0 +1,293 @@ +import { getParentClass } from '@deepkit/core'; +import { + ReflectionKind, + Type, + TypeClass, + TypeEnum, + TypeLiteral, + TypeObjectLiteral, + isDateType, + reflect, + validationAnnotation, +} from '@deepkit/type'; + +import { LiteralSupported, TypeError, TypeErrors, TypeNotSupported } from './errors'; +import { SchemaRegistry } from './schema-registry'; +import { AnySchema, Schema } from './types'; +import { validators } from './validators'; + +export class TypeSchemaResolver { + result: Schema = { ...AnySchema }; + errors: TypeError[] = []; + + 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.null: + this.result.nullable = true; + return; + case ReflectionKind.undefined: + this.result.__isUndefined = 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 LiteralSupported(typeof this.t.literal)); + } + + return; + case ReflectionKind.templateLiteral: + this.result.type = 'string'; + this.errors.push(new TypeNotSupported(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 TypeNotSupported(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) { + // Build a list of inheritance, from root to current class. + while (true) { + const parentClass = getParentClass((typeClass as TypeClass).classType); + if (parentClass) { + typeClass = reflect(parentClass) as any; + 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) { + const typeResolver = resolveTypeSchema(typeItem.type, this.schemaRegistry); + + 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; + } + + 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; + } + + let types = new Set(); + + for (const value of this.t.values) { + const currentType = mapSimpleLiteralToType(value); + + if (currentType === undefined) { + this.errors.push(new TypeNotSupported(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 hasNull = this.t.types.some(t => t.kind === ReflectionKind.null); + if (hasNull) { + this.result.nullable = true; + this.t = { ...this.t, types: this.t.types.filter(t => t.kind !== ReflectionKind.null) }; + } + + // 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 (hasNull) { + 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 TypeNotSupported(this.t, `Validator ${name} is not supported. `)); + } else { + try { + this.result = validator(this.result, ...(args as [any])); + } catch (e) { + if (e instanceof TypeNotSupported) { + this.errors.push(e); + } else { + throw e; + } + } + } + } + } + + resolve() { + this.resolveBasic(); + this.resolveValidators(); + + return this; + } +} + +export const mapSimpleLiteralToType = (literal: any) => { + if (typeof literal === 'string') { + return 'string'; + } else if (typeof literal === 'bigint') { + return 'integer'; + } else if (typeof literal === 'number') { + return 'number'; + } else if (typeof literal === 'boolean') { + return 'boolean'; + } else { + return; + } +}; + +export const unwrapTypeSchema = (t: Type, r: SchemaRegistry = new SchemaRegistry()) => { + const resolver = new TypeSchemaResolver(t, new SchemaRegistry()).resolve(); + + if (resolver.errors.length === 0) { + return resolver.result; + } else { + throw new TypeErrors(resolver.errors, 'Errors with input type. '); + } +}; + +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..98b00581a --- /dev/null +++ b/packages/openapi/src/types.ts @@ -0,0 +1,129 @@ +import { ClassType } from '@deepkit/core'; +import type { parseRouteControllerAction } from '@deepkit/http'; + +export type SchemaMapper = (s: Schema, ...args: any[]) => Schema; + +export type SimpleType = string | number | boolean | null | bigint; + +export type Schema = { + __type: 'schema'; + __registryKey?: string; + __isComponent?: boolean; + __isUndefined?: boolean; + type?: string; + not?: Schema; + pattern?: string; + multipleOf?: number; + minLength?: number; + 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; + name: string; +}; + +export type OpenAPIResponse = { + description: string; + content: { + 'application/json'?: MediaType; + }; +}; + +export type Responses = Record; + +export type Operation = { + __path: string; + __method: string; + 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: {}; + license: {}; + version: string; +}; + +export type Components = { + schemas?: Record; +}; + +export type OpenAPI = { + openapi: string; + info: Info; + servers: {}[]; + paths: Record; + components: Components; +}; diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts new file mode 100644 index 000000000..d440b5f07 --- /dev/null +++ b/packages/openapi/src/utils.ts @@ -0,0 +1,5 @@ +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..5d064b15c --- /dev/null +++ b/packages/openapi/src/validators.ts @@ -0,0 +1,109 @@ +import { TypeLiteral } from '@deepkit/type'; + +import { TypeNotSupported } from './errors'; +import { Schema, SchemaMapper } from './types'; + +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 TypeNotSupported(num, `multiple cannot be 0`); + + return { + ...s, + multipleOf: num.literal, + }; + }, + minLength(s, length: TypeLiteral & { literal: number }): Schema { + if (length.literal < 0) throw new TypeNotSupported(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 TypeNotSupported(length, `length cannot be less than 0`); + + return { + ...s, + maxLength: length.literal, + }; + }, + includes(s, include: TypeLiteral): Schema { + throw new TypeNotSupported(include, `includes is not supported. `); + }, + excludes(s, exclude: TypeLiteral): Schema { + throw new TypeNotSupported(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..0966fed86 --- /dev/null +++ b/packages/openapi/tests/type-schema-resolver.spec.ts @@ -0,0 +1,180 @@ +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'], + 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/yarn.lock b/yarn.lock index 9df02bfe3..84df66e91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4361,6 +4361,30 @@ __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.1" + "@deepkit/injector": "npm:^1.0.8" + "@deepkit/type": "npm:^1.0.8" + 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.1 + "@deepkit/event": ^1.0.1 + "@deepkit/http": ^1.0.1 + "@deepkit/injector": ^1.0.1 + "@deepkit/type": ^1.0.1 + "@types/lodash.clonedeepwith": 4.5.9 + 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 +8523,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" @@ -11984,6 +12015,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 +15565,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 +20344,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 +20360,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 +24516,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 +25662,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 +28147,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" From 3151a94ce6fa6821f229b758aa24048d495faf80 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 09:48:02 +0200 Subject: [PATCH 02/10] refactor: minor improvements to serve method --- packages/openapi/src/static-rewriting-listener.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/openapi/src/static-rewriting-listener.ts b/packages/openapi/src/static-rewriting-listener.ts index e58c70cb5..94b3eb6ff 100644 --- a/packages/openapi/src/static-rewriting-listener.ts +++ b/packages/openapi/src/static-rewriting-listener.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'path'; import send from 'send'; import { stringify } from 'yaml'; -import { urlJoin } from '@deepkit/core'; +import { asyncOperation, urlJoin } from '@deepkit/core'; import { eventDispatcher } from '@deepkit/event'; import { HttpRequest, HttpResponse, RouteConfig, httpWorkflow, normalizeDirectory } from '@deepkit/http'; @@ -58,8 +58,7 @@ export class OpenApiStaticRewritingListener { `; } - // @ts-ignore - serve(path: string, request: HttpRequest, response: HttpResponse) { + 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); @@ -74,7 +73,7 @@ export class OpenApiStaticRewritingListener { response.setHeader('content-type', 'text/yaml; charset=utf-8'); response.end(s); } else { - return new Promise(async (resolve, reject) => { + await asyncOperation(async (resolve) => { const relativePath = urlJoin('/', request.url!.substring(this.prefix.length)); if (relativePath === '') { response.setHeader('location', this.prefix + 'index.html'); From 16e2157475b5a0679449af8b54ba01d2b62af703 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 09:49:02 +0200 Subject: [PATCH 03/10] chore: remove unused code --- packages/openapi/src/schema-registry.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/openapi/src/schema-registry.ts b/packages/openapi/src/schema-registry.ts index f74791124..152cb4147 100644 --- a/packages/openapi/src/schema-registry.ts +++ b/packages/openapi/src/schema-registry.ts @@ -1,6 +1,5 @@ import camelcase from 'camelcase'; -import { HttpBody, HttpBodyValidation, HttpQueries } from '@deepkit/http'; import { ReflectionKind, Type, @@ -40,8 +39,6 @@ export class SchemaRegistry { return nameAnnotation.options.literal as string; } - isSameType(t, typeOf>()); - // HttpQueries if ( t.typeName === 'HttpQueries' || From 8f43d0208e31a54e69655fc3aa1c7b559c7d700e Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:00:09 +0200 Subject: [PATCH 04/10] chore: cleanup --- packages/openapi/package.json | 7 ++++--- packages/openapi/src/document.ts | 8 +++----- packages/openapi/src/schema-registry.ts | 3 +-- yarn.lock | 19 ++++++++++++++++++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 7f9d76c03..92a2bbb48 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -33,15 +33,16 @@ "@deepkit/event": "^1.0.1", "@deepkit/http": "^1.0.1", "@deepkit/injector": "^1.0.1", - "@deepkit/type": "^1.0.1", - "@types/lodash.clonedeepwith": "4.5.9" + "@deepkit/type": "^1.0.1" }, "devDependencies": { "@deepkit/core": "^1.0.5", "@deepkit/event": "^1.0.8", "@deepkit/http": "^1.0.1", "@deepkit/injector": "^1.0.8", - "@deepkit/type": "^1.0.8" + "@deepkit/type": "^1.0.8", + "@types/lodash": "4.17.17", + "@types/lodash.clonedeepwith": "4.5.9" }, "jest": { "testEnvironment": "node", diff --git a/packages/openapi/src/document.ts b/packages/openapi/src/document.ts index 049c09432..40efac4f8 100644 --- a/packages/openapi/src/document.ts +++ b/packages/openapi/src/document.ts @@ -1,9 +1,8 @@ import camelCase from 'camelcase'; -// @ts-ignore import cloneDeepWith from 'lodash.clonedeepwith'; import { ClassType } from '@deepkit/core'; -import { RouteClassControllerAction, RouteConfig, parseRouteControllerAction } from '@deepkit/http'; +import { RouteConfig, parseRouteControllerAction } from '@deepkit/http'; import { ScopedLogger } from '@deepkit/logger'; import { ReflectionKind } from '@deepkit/type'; @@ -14,9 +13,7 @@ import { resolveTypeSchema } from './type-schema-resolver'; import { HttpMethod, OpenAPI, - OpenAPIResponse, Operation, - ParsedRoute, RequestMediaTypeName, Responses, Schema, @@ -106,7 +103,6 @@ export class OpenAPIDocument { } serializeDocument(): OpenAPI { - // @ts-ignore return cloneDeepWith(this.getDocument(), c => { if (c && typeof c === 'object') { if (c.__type === 'schema' && c.__registryKey && !c.__isComponent) { @@ -129,6 +125,8 @@ export class OpenAPIDocument { if (key.startsWith('__')) delete c[key]; } } + + return c; }); } diff --git a/packages/openapi/src/schema-registry.ts b/packages/openapi/src/schema-registry.ts index 152cb4147..1c5e4d91c 100644 --- a/packages/openapi/src/schema-registry.ts +++ b/packages/openapi/src/schema-registry.ts @@ -9,8 +9,7 @@ import { TypeUnion, isSameType, metaAnnotation, - stringifyType, - typeOf, + stringifyType } from '@deepkit/type'; import { OpenApiSchemaNameConflict } from './errors'; diff --git a/yarn.lock b/yarn.lock index 84df66e91..d2506c91c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4370,6 +4370,8 @@ __metadata: "@deepkit/http": "npm:^1.0.1" "@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" camelcase: "npm:8.0.0" lodash.clonedeepwith: "npm:4.5.0" send: "npm:1.2.0" @@ -4381,7 +4383,6 @@ __metadata: "@deepkit/http": ^1.0.1 "@deepkit/injector": ^1.0.1 "@deepkit/type": ^1.0.1 - "@types/lodash.clonedeepwith": 4.5.9 languageName: unknown linkType: soft @@ -10084,6 +10085,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" From f38b58d439ae752bbd9939ff6a90cc05e3e8c3be Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:01:33 +0200 Subject: [PATCH 05/10] chore: bump deps --- packages/openapi/package.json | 12 ++++++------ yarn.lock | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 92a2bbb48..6e044c392 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -29,16 +29,16 @@ "yaml": "2.8.0" }, "peerDependencies": { - "@deepkit/core": "^1.0.1", - "@deepkit/event": "^1.0.1", - "@deepkit/http": "^1.0.1", - "@deepkit/injector": "^1.0.1", - "@deepkit/type": "^1.0.1" + "@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.1", + "@deepkit/http": "^1.0.9", "@deepkit/injector": "^1.0.8", "@deepkit/type": "^1.0.8", "@types/lodash": "4.17.17", diff --git a/yarn.lock b/yarn.lock index d2506c91c..94d0886ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4367,7 +4367,7 @@ __metadata: dependencies: "@deepkit/core": "npm:^1.0.5" "@deepkit/event": "npm:^1.0.8" - "@deepkit/http": "npm:^1.0.1" + "@deepkit/http": "npm:^1.0.9" "@deepkit/injector": "npm:^1.0.8" "@deepkit/type": "npm:^1.0.8" "@types/lodash": "npm:4.17.17" @@ -4378,11 +4378,11 @@ __metadata: swagger-ui-dist: "npm:5.22.0" yaml: "npm:2.8.0" peerDependencies: - "@deepkit/core": ^1.0.1 - "@deepkit/event": ^1.0.1 - "@deepkit/http": ^1.0.1 - "@deepkit/injector": ^1.0.1 - "@deepkit/type": ^1.0.1 + "@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 From 1c0728ddc4839e4a6ea5296c1642472bd4135796 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:04:46 +0200 Subject: [PATCH 06/10] chore: add missing tsconfig.json reference --- packages/openapi/dist/.gitkeep | 0 tsconfig.json | 3 +++ 2 files changed, 3 insertions(+) delete mode 100644 packages/openapi/dist/.gitkeep diff --git a/packages/openapi/dist/.gitkeep b/packages/openapi/dist/.gitkeep deleted file mode 100644 index e69de29bb..000000000 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" }, From 951f4c22ea198289de5e361855a1d29270ed1249 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:09:51 +0200 Subject: [PATCH 07/10] chore: add missing tsconfig.esm.json reference --- tsconfig.esm.json | 3 +++ 1 file changed, 3 insertions(+) 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" }, From 27ffd7447000f5ae4e33df769d364b78176c2d6d Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:19:00 +0200 Subject: [PATCH 08/10] chore: add missing send types --- packages/openapi/package.json | 3 ++- yarn.lock | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 6e044c392..ad5d50d3a 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -42,7 +42,8 @@ "@deepkit/injector": "^1.0.8", "@deepkit/type": "^1.0.8", "@types/lodash": "4.17.17", - "@types/lodash.clonedeepwith": "4.5.9" + "@types/lodash.clonedeepwith": "4.5.9", + "@types/send": "0.17.4" }, "jest": { "testEnvironment": "node", diff --git a/yarn.lock b/yarn.lock index 94d0886ce..2ed6ee194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4372,6 +4372,7 @@ __metadata: "@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" @@ -10287,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: From 50c58d4404d969a23f46d0efbad41a1e56af48f9 Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Thu, 29 May 2025 10:23:08 +0200 Subject: [PATCH 09/10] fix: add missing http router filter provider --- packages/openapi/src/module.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/openapi/src/module.ts b/packages/openapi/src/module.ts index 6887909dc..f869358cb 100644 --- a/packages/openapi/src/module.ts +++ b/packages/openapi/src/module.ts @@ -29,4 +29,8 @@ export class OpenAPIModule extends createModuleClass({ configure(this.routeFilter); return this; } + + process() { + this.addProvider({ provide: HttpRouteFilter, useValue: this.routeFilter }); + } } From 10957080dbf743549316549db29d8ebdb20437df Mon Sep 17 00:00:00 2001 From: marcus-sa Date: Fri, 6 Jun 2025 18:47:24 +0200 Subject: [PATCH 10/10] fix(openapi): fixes and stuff --- packages/openapi/src/config.ts | 10 +- packages/openapi/src/decorator.ts | 40 +++++++ packages/openapi/src/document.ts | 66 ++++++----- packages/openapi/src/errors.ts | 30 +++-- packages/openapi/src/module.ts | 12 +- packages/openapi/src/parameters-resolver.ts | 6 +- packages/openapi/src/schema-registry.ts | 34 +++--- packages/openapi/src/service.ts | 7 +- .../openapi/src/static-rewriting-listener.ts | 34 ++++-- packages/openapi/src/type-schema-resolver.ts | 105 ++++++++++++------ packages/openapi/src/types.ts | 41 ++++--- packages/openapi/src/utils.ts | 2 + packages/openapi/src/validators.ts | 14 +-- .../tests/type-schema-resolver.spec.ts | 22 ++-- 14 files changed, 271 insertions(+), 152 deletions(-) create mode 100644 packages/openapi/src/decorator.ts diff --git a/packages/openapi/src/config.ts b/packages/openapi/src/config.ts index ae0801c18..02343813a 100644 --- a/packages/openapi/src/config.ts +++ b/packages/openapi/src/config.ts @@ -1,9 +1,9 @@ -import { OpenAPICoreConfig } from './document'; +import { OpenAPICoreConfig } from './document.js'; export class OpenAPIConfig extends OpenAPICoreConfig { - title: string = 'OpenAPI'; - description: string = ''; - version: string = '1.0.0'; + title = 'OpenAPI'; + description = ''; + version = '1.0.0'; // Prefix for all OpenAPI related controllers - prefix: string = '/openapi/'; + 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 index 40efac4f8..7ace9f4a7 100644 --- a/packages/openapi/src/document.ts +++ b/packages/openapi/src/document.ts @@ -4,9 +4,11 @@ import cloneDeepWith from 'lodash.clonedeepwith'; import { ClassType } from '@deepkit/core'; import { RouteConfig, parseRouteControllerAction } from '@deepkit/http'; import { ScopedLogger } from '@deepkit/logger'; -import { ReflectionKind } from '@deepkit/type'; +import { ReflectionKind, serialize } from '@deepkit/type'; -import { OpenApiControllerNameConflict, OpenApiOperationNameConflict, TypeError } from './errors'; +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'; @@ -17,6 +19,7 @@ import { RequestMediaTypeName, Responses, Schema, + SerializedOpenAPI, Tag, } from './types'; import { resolveOpenApiPath } from './utils'; @@ -27,23 +30,25 @@ export class OpenAPICoreConfig { } export class OpenAPIDocument { - schemaRegistry = new SchemaRegistry(this.config.customSchemaKeyFn); + schemaRegistry: SchemaRegistry; operations: Operation[] = []; tags: Tag[] = []; - errors: TypeError[] = []; + errors: OpenApiTypeError[] = []; constructor( private routes: RouteConfig[], private log: ScopedLogger, - private config: OpenAPICoreConfig = {}, - ) {} + private config: OpenAPIConfig, + ) { + this.schemaRegistry = new SchemaRegistry(this.config.customSchemaKeyFn); + } getControllerName(controller: ClassType) { - // TODO: Allow customized name - return camelCase(controller.name.replace(/Controller$/, '')); + const t = httpOpenApiController._fetch(controller); + return t?.name || camelCase(controller.name.replace(/Controller$/, '')); } registerTag(controller: ClassType) { @@ -55,7 +60,7 @@ export class OpenAPIDocument { const currentTag = this.tags.find(tag => tag.name === name); if (currentTag) { if (currentTag.__controller !== controller) { - throw new OpenApiControllerNameConflict(controller, currentTag.__controller, name); + throw new OpenApiControllerNameConflictError(controller, currentTag.__controller, name); } } else { this.tags.push(newTag); @@ -72,10 +77,9 @@ export class OpenAPIDocument { const openapi: OpenAPI = { openapi: '3.0.3', info: { - title: 'OpenAPI', - contact: {}, - license: { name: 'MIT' }, - version: '0.0.1', + title: this.config.title, + description: this.config.description, + version: this.config.version, }, servers: [], paths: {}, @@ -102,8 +106,8 @@ export class OpenAPIDocument { return openapi; } - serializeDocument(): OpenAPI { - return cloneDeepWith(this.getDocument(), c => { + serializeDocument(): SerializedOpenAPI { + const clonedDocument = cloneDeepWith(this.getDocument(), c => { if (c && typeof c === 'object') { if (c.__type === 'schema' && c.__registryKey && !c.__isComponent) { const ret = { @@ -119,21 +123,23 @@ export class OpenAPIDocument { return ret; } - - for (const key of Object.keys(c)) { - // Remove internal keys. - if (key.startsWith('__')) delete c[key]; - } } return c; }); + + return serialize(clonedDocument); } registerRouteSafe(route: RouteConfig) { try { this.registerRoute(route); - } catch (err: any) { + } 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); } } @@ -163,20 +169,28 @@ export class OpenAPIDocument { 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]), - parameters: parametersResolver.parameters.length > 0 ? parametersResolver.parameters : undefined, - requestBody: parametersResolver.requestBody, 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 OpenApiOperationNameConflict(operation.__path, operation.__method); + throw new OpenApiOperationNameConflictError(operation.__path, operation.__method); } this.operations.push(operation); @@ -196,7 +210,7 @@ export class OpenAPIDocument { this.errors.push(...schemaResult.errors); responses[200] = { - description: '', + description: route.description, content: { 'application/json': { schema: schemaResult.result, @@ -216,7 +230,7 @@ export class OpenAPIDocument { if (!responses[response.statusCode]) { responses[response.statusCode] = { - description: '', + description: response.description, content: { 'application/json': schema ? { schema } : undefined }, }; } diff --git a/packages/openapi/src/errors.ts b/packages/openapi/src/errors.ts index c7a7fc92f..e9857720d 100644 --- a/packages/openapi/src/errors.ts +++ b/packages/openapi/src/errors.ts @@ -3,61 +3,59 @@ import { Type, stringifyType } from '@deepkit/type'; export class OpenApiError extends Error {} -export class TypeError extends OpenApiError {} +export class OpenApiTypeError extends OpenApiError {} -export class TypeNotSupported extends TypeError { +export class OpenApiTypeNotSupportedError extends OpenApiTypeError { constructor( public type: Type, - public reason: string = '', + public reason = '', ) { super(`${stringifyType(type)} is not supported. ${reason}`); } } -export class LiteralSupported extends TypeError { +export class OpenApiLiteralNotSupportedError extends OpenApiTypeError { constructor(public typeName: string) { super(`${typeName} is not supported. `); } } -export class TypeErrors extends OpenApiError { +export class OpenApiTypeErrors extends OpenApiError { constructor( - public errors: TypeError[], + public errors: OpenApiTypeError[], message: string, ) { super(message); } } -export class OpenApiSchemaNameConflict extends OpenApiError { +export class OpenApiSchemaNameConflictError extends OpenApiError { constructor( public newType: Type, public oldType: Type, - public name: string, + name: string, ) { super( - `${stringifyType(newType)} and ${stringifyType( + `"${stringifyType(newType)}" and "${stringifyType( oldType, - )} are not the same, but their schema are both named as ${JSON.stringify(name)}. ` + - `Try to fix the naming of related types, or rename them using 'YourClass & Name'`, + )}" 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 OpenApiControllerNameConflict extends OpenApiError { +export class OpenApiControllerNameConflictError extends OpenApiError { constructor( public newController: ClassType, public oldController: ClassType, - public name: string, + name: string, ) { super( - `${getClassName(newController)} and ${getClassName(oldController)} are both tagged as ${name}. ` + - `Please consider renaming them. `, + `${getClassName(newController)} and ${getClassName(oldController)} are both tagged as ${name}. Please consider renaming them.`, ); } } -export class OpenApiOperationNameConflict extends OpenApiError { +export class OpenApiOperationNameConflictError extends OpenApiError { constructor( public fullPath: string, public method: string, diff --git a/packages/openapi/src/module.ts b/packages/openapi/src/module.ts index f869358cb..acdc76368 100644 --- a/packages/openapi/src/module.ts +++ b/packages/openapi/src/module.ts @@ -1,12 +1,10 @@ import { createModuleClass } from '@deepkit/app'; -import { - HttpRouteFilter, -} from '@deepkit/http'; +import { HttpRouteFilter } from '@deepkit/http'; import { OpenAPIConfig } from './config'; import { OpenAPIService } from './service'; import { OpenApiStaticRewritingListener } from './static-rewriting-listener'; -import { OpenAPI } from './types'; +import { SerializedOpenAPI } from './types'; export class OpenAPIModule extends createModuleClass({ config: OpenAPIConfig, @@ -18,9 +16,9 @@ export class OpenAPIModule extends createModuleClass({ group: 'app-static', }); - configureOpenApiFunction: (openApi: OpenAPI) => void = () => {}; + configureOpenApiFunction: (openApi: SerializedOpenAPI) => void = () => {}; - configureOpenApi(configure: (openApi: OpenAPI) => void) { + configureOpenApi(configure: (openApi: SerializedOpenAPI) => void) { this.configureOpenApiFunction = configure; return this; } @@ -30,7 +28,7 @@ export class OpenAPIModule extends createModuleClass({ return this; } - process() { + 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 index b4fb8aff1..e797c08cb 100644 --- a/packages/openapi/src/parameters-resolver.ts +++ b/packages/openapi/src/parameters-resolver.ts @@ -1,6 +1,6 @@ import { ReflectionKind } from '@deepkit/type'; -import { OpenApiError, TypeError } from './errors'; +import { OpenApiError, OpenApiTypeError } from './errors'; import { SchemaRegistry } from './schema-registry'; import { resolveTypeSchema } from './type-schema-resolver'; import { MediaType, Parameter, ParsedRoute, RequestBody, RequestMediaTypeName } from './types'; @@ -8,7 +8,7 @@ import { MediaType, Parameter, ParsedRoute, RequestBody, RequestMediaTypeName } export class ParametersResolver { parameters: Parameter[] = []; requestBody?: RequestBody; - errors: TypeError[] = []; + errors: OpenApiTypeError[] = []; constructor( private parsedRoute: ParsedRoute, @@ -50,7 +50,7 @@ export class ParametersResolver { }); } else { this.errors.push( - new TypeError( + new OpenApiTypeError( `Parameter name ${JSON.stringify(name)} is repeated. Please consider renaming them. `, ), ); diff --git a/packages/openapi/src/schema-registry.ts b/packages/openapi/src/schema-registry.ts index 1c5e4d91c..3f9c23e56 100644 --- a/packages/openapi/src/schema-registry.ts +++ b/packages/openapi/src/schema-registry.ts @@ -9,11 +9,12 @@ import { TypeUnion, isSameType, metaAnnotation, - stringifyType + stringifyType, } from '@deepkit/type'; -import { OpenApiSchemaNameConflict } from './errors'; -import { Schema } from './types'; +import { OpenApiSchemaNameConflictError } from './errors.js'; +import { TypeSchemaResolver } from './type-schema-resolver.js'; +import { Schema } from './types.js'; export interface SchemeEntry { name: string; @@ -27,6 +28,7 @@ export type SchemaKeyFn = (t: RegistrableSchema) => string | undefined; export class SchemaRegistry { store: Map = new Map(); + types: WeakMap = new WeakMap(); constructor(private customSchemaKeyFn?: SchemaKeyFn) {} @@ -39,11 +41,7 @@ export class SchemaRegistry { } // HttpQueries - if ( - t.typeName === 'HttpQueries' || - t.typeName === 'HttpBody' || - t.typeName === 'HttpBodyValidation' - ) { + 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, @@ -74,37 +72,37 @@ export class SchemaRegistry { t.kind === ReflectionKind.undefined ) { return stringifyType(t); - } else if ( + } + + if ( t.kind === ReflectionKind.class || t.kind === ReflectionKind.objectLiteral || t.kind === ReflectionKind.enum || t.kind === ReflectionKind.union ) { return this.getSchemaKey(t); - } else if (t.kind === ReflectionKind.array) { + } + + if (t.kind === ReflectionKind.array) { return camelcase([this.getTypeKey(t.type), 'Array'], { pascalCase: false, }); - } else { - // Complex types not named - return ''; } + + return ''; } registerSchema(name: string, type: Type, schema: Schema) { const currentEntry = this.store.get(name); if (currentEntry && !isSameType(type, currentEntry?.type)) { - throw new OpenApiSchemaNameConflict(type, currentEntry.type, name); + throw new OpenApiSchemaNameConflictError(type, currentEntry.type, name); } this.store.set(name, { type, name, - schema: { - ...schema, - nullable: undefined, - }, + schema, }); schema.__registryKey = name; } diff --git a/packages/openapi/src/service.ts b/packages/openapi/src/service.ts index 36e37d539..dfe8f9404 100644 --- a/packages/openapi/src/service.ts +++ b/packages/openapi/src/service.ts @@ -1,8 +1,9 @@ import { HttpRouteFilter, HttpRouterFilterResolver } from '@deepkit/http'; import { ScopedLogger } from '@deepkit/logger'; -import { OpenAPIConfig } from './config'; -import { OpenAPIDocument } from './document'; +import { OpenAPIConfig } from './config.js'; +import { OpenAPIDocument } from './document.js'; +import { SerializedOpenAPI } from './types.js'; export class OpenAPIService { constructor( @@ -12,7 +13,7 @@ export class OpenAPIService { private config: OpenAPIConfig, ) {} - serialize() { + 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 index 94b3eb6ff..e3a562146 100644 --- a/packages/openapi/src/static-rewriting-listener.ts +++ b/packages/openapi/src/static-rewriting-listener.ts @@ -1,5 +1,5 @@ -import { stat } from 'fs/promises'; -import { dirname, join } from 'path'; +import { stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import send from 'send'; import { stringify } from 'yaml'; @@ -10,8 +10,13 @@ import { HttpRequest, HttpResponse, RouteConfig, httpWorkflow, normalizeDirector 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, @@ -19,6 +24,7 @@ export class OpenApiStaticRewritingListener { ) {} serialize() { + if (this.serialized) return this.serialized; const openApi = this.openApi.serialize(); openApi.info.title = this.config.title; @@ -26,6 +32,7 @@ export class OpenApiStaticRewritingListener { openApi.info.version = this.config.version; this.module.configureOpenApiFunction(openApi); + this.serialized = openApi; return openApi; } @@ -41,7 +48,7 @@ export class OpenApiStaticRewritingListener { return ` window.onload = function() { window.ui = SwaggerUIBundle({ - url: ${JSON.stringify(this.prefix + 'openapi.yml')}, + url: ${JSON.stringify(`${this.prefix}openapi.yml`)}, dom_id: '#swagger-ui', deepLinking: true, presets: [ @@ -63,20 +70,24 @@ export class OpenApiStaticRewritingListener { response.setHeader('content-type', 'application/javascript; charset=utf-8'); response.end(this.swaggerInitializer); } else if (path.endsWith('/openapi.json')) { - const s = JSON.stringify(this.serialize(), undefined, 2); + 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 = stringify(this.serialize(), { - aliasDuplicateObjects: false, - }); + 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) => { + await asyncOperation(async resolve => { const relativePath = urlJoin('/', request.url!.substring(this.prefix.length)); if (relativePath === '') { - response.setHeader('location', this.prefix + 'index.html'); + response.setHeader('location', `${this.prefix}index.html`); response.status(301); return; } @@ -111,7 +122,10 @@ export class OpenApiStaticRewritingListener { module: this.module, methodName: 'serve', }), - () => ({ arguments: [relativePath, event.request, event.response], parameters: {} }), + () => ({ + 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 index dd7f4ce84..5d95b165c 100644 --- a/packages/openapi/src/type-schema-resolver.ts +++ b/packages/openapi/src/type-schema-resolver.ts @@ -6,19 +6,26 @@ import { TypeEnum, TypeLiteral, TypeObjectLiteral, + hasTypeInformation, isDateType, reflect, validationAnnotation, } from '@deepkit/type'; -import { LiteralSupported, TypeError, TypeErrors, TypeNotSupported } from './errors'; -import { SchemaRegistry } from './schema-registry'; +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: TypeError[] = []; + errors: OpenApiTypeError[] = []; constructor( public t: Type, @@ -50,38 +57,39 @@ export class TypeSchemaResolver { case ReflectionKind.bigint: this.result.type = 'number'; return; + case ReflectionKind.undefined: case ReflectionKind.null: this.result.nullable = true; return; - case ReflectionKind.undefined: - this.result.__isUndefined = true; - return; - case ReflectionKind.literal: + 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 LiteralSupported(typeof this.t.literal)); + this.errors.push(new OpenApiLiteralNotSupportedError(typeof this.t.literal)); } - return; + } case ReflectionKind.templateLiteral: this.result.type = 'string'; - this.errors.push(new TypeNotSupported(this.t, 'Literal is treated as string for simplicity')); + 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: + 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; @@ -89,7 +97,7 @@ export class TypeSchemaResolver { this.resolveUnion(); return; default: - this.errors.push(new TypeNotSupported(this.t)); + this.errors.push(new OpenApiTypeNotSupportedError(this.t)); return; } } @@ -115,11 +123,12 @@ export class TypeSchemaResolver { 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) { - typeClass = reflect(parentClass) as any; + if (parentClass && hasTypeInformation(parentClass)) { + typeClass = reflect(parentClass) as TypeClass | TypeObjectLiteral; typeClasses.unshift(typeClass); } else { break; @@ -131,7 +140,15 @@ export class TypeSchemaResolver { 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)); @@ -147,10 +164,12 @@ export class TypeSchemaResolver { this.result.required = required; } - const registryKey = this.schemaRegistry.getSchemaKey(this.t); + if (!this.schemaRegistry.types.has(this.t)) { + const registryKey = this.schemaRegistry.getSchemaKey(this.t); - if (registryKey) { - this.schemaRegistry.registerSchema(registryKey, this.t, this.result); + if (registryKey) { + this.schemaRegistry.registerSchema(registryKey, this.t, this.result); + } } } @@ -159,13 +178,13 @@ export class TypeSchemaResolver { return; } - let types = new Set(); + const types = new Set(); for (const value of this.t.values) { const currentType = mapSimpleLiteralToType(value); - if (currentType === undefined) { - this.errors.push(new TypeNotSupported(this.t, `Enum with unsupported members. `)); + if (!currentType) { + this.errors.push(new OpenApiTypeNotSupportedError(this.t, 'Enum with unsupported members')); continue; } @@ -186,10 +205,13 @@ export class TypeSchemaResolver { return; } - const hasNull = this.t.types.some(t => t.kind === ReflectionKind.null); - if (hasNull) { + 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) }; + 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 @@ -217,7 +239,7 @@ export class TypeSchemaResolver { const { result, errors } = resolveTypeSchema(enumType, this.schemaRegistry); this.result = result; this.errors.push(...errors); - if (hasNull) { + if (hasNil) { this.result.enum!.push(null); this.result.nullable = true; } @@ -241,12 +263,12 @@ export class TypeSchemaResolver { const validator = validators[name]; if (!validator) { - this.errors.push(new TypeNotSupported(this.t, `Validator ${name} is not supported. `)); + 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 TypeNotSupported) { + if (e instanceof OpenApiTypeNotSupportedError) { this.errors.push(e); } else { throw e; @@ -257,6 +279,13 @@ export class TypeSchemaResolver { } 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(); @@ -264,28 +293,30 @@ export class TypeSchemaResolver { } } -export const mapSimpleLiteralToType = (literal: any) => { +export const mapSimpleLiteralToType = (literal: unknown) => { if (typeof literal === 'string') { return 'string'; - } else if (typeof literal === 'bigint') { + } + if (typeof literal === 'bigint') { return 'integer'; - } else if (typeof literal === 'number') { + } + if (typeof literal === 'number') { return 'number'; - } else if (typeof literal === 'boolean') { + } + if (typeof literal === 'boolean') { return 'boolean'; - } else { - return; } + return undefined; }; -export const unwrapTypeSchema = (t: Type, r: SchemaRegistry = new SchemaRegistry()) => { +export const unwrapTypeSchema = (t: Type, _r: SchemaRegistry = new SchemaRegistry()) => { const resolver = new TypeSchemaResolver(t, new SchemaRegistry()).resolve(); - if (resolver.errors.length === 0) { - return resolver.result; - } else { - throw new TypeErrors(resolver.errors, 'Errors with input type. '); + 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()) => { diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 98b00581a..580fb1428 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -1,20 +1,21 @@ 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'; - __registryKey?: string; - __isComponent?: boolean; - __isUndefined?: boolean; + __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; @@ -33,16 +34,25 @@ export type Schema = { export const AnySchema: Schema = { __type: 'schema' }; -export const NumberSchema: Schema = { __type: 'schema', type: 'number' }; +export const NumberSchema: Schema = { + __type: 'schema', + type: 'number', +}; -export const StringSchema: Schema = { __type: 'schema', type: 'string' }; +export const StringSchema: Schema = { + __type: 'schema', + type: 'string', +}; -export const BooleanSchema: Schema = { __type: 'schema', type: 'boolean' }; +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; + __controller: ClassType & Excluded; name: string; }; @@ -56,8 +66,8 @@ export type OpenAPIResponse = { export type Responses = Record; export type Operation = { - __path: string; - __method: string; + __path: string & Excluded; + __method: string & Excluded; tags: string[]; summary?: string; description?: string; @@ -111,8 +121,10 @@ export type Info = { title: string; description?: string; termsOfService?: string; - contact: {}; - license: {}; + contact?: { + name: string; + }; + license?: unknown; version: string; }; @@ -120,10 +132,13 @@ export type Components = { schemas?: Record; }; +// TODO: rename to Internal export type OpenAPI = { openapi: string; info: Info; - servers: {}[]; + 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 index d440b5f07..4ab40207d 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,3 +1,5 @@ +import '@deepkit/type'; + export const resolveOpenApiPath = (deepkitPath: string) => { let s = deepkitPath.replace(/:(\w+)/g, (_, name) => `\{${name}\}`); s = !s.startsWith('/') ? '/' + s : s; diff --git a/packages/openapi/src/validators.ts b/packages/openapi/src/validators.ts index 5d064b15c..93610b569 100644 --- a/packages/openapi/src/validators.ts +++ b/packages/openapi/src/validators.ts @@ -1,7 +1,7 @@ import { TypeLiteral } from '@deepkit/type'; -import { TypeNotSupported } from './errors'; -import { Schema, SchemaMapper } from './types'; +import { OpenApiTypeNotSupportedError } from './errors'; +import { Schema, SchemaMapper } from './types.js'; export const validators: Record = { pattern(s, type: TypeLiteral & { literal: RegExp }): Schema { @@ -41,7 +41,7 @@ export const validators: Record = { }; }, multipleOf(s, num: TypeLiteral & { literal: number }): Schema { - if (num.literal === 0) throw new TypeNotSupported(num, `multiple cannot be 0`); + if (num.literal === 0) throw new OpenApiTypeNotSupportedError(num, `multiple cannot be 0`); return { ...s, @@ -49,7 +49,7 @@ export const validators: Record = { }; }, minLength(s, length: TypeLiteral & { literal: number }): Schema { - if (length.literal < 0) throw new TypeNotSupported(length, `length cannot be less than 0`); + if (length.literal < 0) throw new OpenApiTypeNotSupportedError(length, `length cannot be less than 0`); return { ...s, @@ -57,7 +57,7 @@ export const validators: Record = { }; }, maxLength(s, length: TypeLiteral & { literal: number }): Schema { - if (length.literal < 0) throw new TypeNotSupported(length, `length cannot be less than 0`); + if (length.literal < 0) throw new OpenApiTypeNotSupportedError(length, `length cannot be less than 0`); return { ...s, @@ -65,10 +65,10 @@ export const validators: Record = { }; }, includes(s, include: TypeLiteral): Schema { - throw new TypeNotSupported(include, `includes is not supported. `); + throw new OpenApiTypeNotSupportedError(include, `includes is not supported. `); }, excludes(s, exclude: TypeLiteral): Schema { - throw new TypeNotSupported(exclude, `excludes is not supported. `); + throw new OpenApiTypeNotSupportedError(exclude, `excludes is not supported. `); }, minimum(s, min: TypeLiteral & { literal: number | bigint }): Schema { return { diff --git a/packages/openapi/tests/type-schema-resolver.spec.ts b/packages/openapi/tests/type-schema-resolver.spec.ts index 0966fed86..dab757ad3 100644 --- a/packages/openapi/tests/type-schema-resolver.spec.ts +++ b/packages/openapi/tests/type-schema-resolver.spec.ts @@ -162,19 +162,27 @@ test('serialize nullables', () => { nullable: true, }); - const t5 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c'>()); - expect(t5).toMatchObject({ + // 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'], + enum: ['a', 'b', 'c', null], + nullable: true, }); - expect(t5.nullable).toBeUndefined(); - const t6 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c' | null>()); - expect(t6).toMatchObject({ + const t7 = unwrapTypeSchema(typeOf<'a' | 'b' | 'c' | undefined>()); + expect(t7).toMatchObject({ __type: 'schema', type: 'string', - enum: ['a', 'b', 'c'], + enum: ['a', 'b', 'c', null], nullable: true, }); });