diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6685332002..f6c2e441e5 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -54,7 +54,6 @@ import { initialize } from 'express-openapi'; import yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; -import OpenAPIBackend from 'openapi-backend'; class ParsingError extends Error { toString() { @@ -105,23 +104,6 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } - // create api with your definition file or object - const api = new OpenAPIBackend({ definition: './petstore.yml' }); - - // register your framework specific request handlers here - api.register({ - getPets: (c, req, res) => { - return res.status(200).json({ result: 'ok' }); - }, - getPetById: (c, req, res) => res.status(200).json({ result: 'ok' }), - validationFail: (c, req, res) => - res.status(400).json({ err: c.validation.errors }), - notFound: (c, req, res) => res.status(404).json({ err: 'not found' }), - }); - - // initalize the backend - api.init(); - const validateDependency = ( dependency: any, next: (req: express.Request, res: express.Response) => any, @@ -143,6 +125,7 @@ export async function createRouter( // NOTE: If using yaml you can provide a path relative to process.cwd() e.g. // apiDoc: './api-v1/api-doc.yml', apiDoc: yaml.load( + // eslint-disable-next-line no-restricted-syntax fs.readFileSync(path.resolve(__dirname, '../../openapi.yaml'), 'utf-8'), ) as any, operations: { @@ -345,12 +328,18 @@ export async function createRouter( }, enableObjectCoercion: true, errorMiddleware: errorHandler(), - errorTransformer: (openapiError, ajvError) => { - switch (openapiError.errorCode) { + errorTransformer: openapiError => { + const error = openapiError as { + errorCode: string; + path: string; + message: string; + }; + // eslint-disable-next-line default-case + switch (error.errorCode) { case 'type.openapi.requestValidation': throw new InputError( - `Invalid field ${openapiError.path}`, - new ParsingError(openapiError.message), + `Invalid field ${error.path}`, + new ParsingError(error.message), ); } return {}; diff --git a/plugins/openapi-router-common/package.json b/plugins/openapi-router-common/package.json index a852edfcec..3a40ddecd0 100644 --- a/plugins/openapi-router-common/package.json +++ b/plugins/openapi-router-common/package.json @@ -32,6 +32,8 @@ "dependencies": { "express": "^4.18.2", "json-schema-to-ts": "^2.6.2", - "openapi-types": "^12.1.0" + "openapi-types": "^12.1.0", + "openapi3-ts": "^3.1.2", + "ts-node": "^10.9.1" } } diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts index 740dc8dc03..99a45b8e0c 100644 --- a/plugins/openapi-router-common/src/router.ts +++ b/plugins/openapi-router-common/src/router.ts @@ -13,26 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { OpenAPIV3_1 } from 'openapi-types'; -import { FieldValues } from './types/fields'; +import { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; +import { IRouter, Router } from 'express'; +import core, { ParamsDictionary } from 'express-serve-static-core'; +import { FromSchema, JSONSchema7 } from 'json-schema-to-ts'; import { - AppendNonBlankKey, - FieldPath, - FieldPathValue, - Path, -} from './types/path'; -import { IRouter, Router, IRouterMatcher } from 'express'; -import core, { - ParamsDictionary, - RequestHandler, -} from 'express-serve-static-core'; -import { FromSchema } from 'json-schema-to-ts'; + DocPath, + DocPathMethod, + DocPathTemplate, + MethodAwareDocPath, + PathTemplate, + RequestBodySchema, + RequiredDoc, + ValueOf, +} from './types'; +import { ResponseSchemas } from './types/response'; -type RouterFn = < - TFieldName extends FieldPath = FieldPath, ->( - name: TFieldName, -) => null; +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; const doc = { openapi: '3.1.0', @@ -201,59 +198,6 @@ const doc = { }, } as const; -const isUndefined = (a: any): a is undefined => a === undefined; -const isNull = (a: any): a is null => a === null; - -const isNullOrUndefined = (a: any): a is undefined | null => - isUndefined(a) || isNull(a); - -const isDateObject = (value: unknown): value is Date => value instanceof Date; - -const isObjectType = (value: unknown) => typeof value === 'object'; - -const isObject = (value: unknown): value is T => - !isNullOrUndefined(value) && - !Array.isArray(value) && - isObjectType(value) && - !isDateObject(value); - -const compact = (value: TValue[]) => - Array.isArray(value) ? value.filter(Boolean) : []; - -export const resolve = ( - obj: T, - path: string, - defaultValue?: unknown, -): any => { - if (!path || !isObject(obj)) { - return defaultValue; - } - - const result = compact(path.split(/[,[\].]+?/)).reduce( - (result, key) => - isNullOrUndefined(result) ? result : result[key as keyof {}], - obj, - ); - if (result === undefined || result === obj) { - return obj[path as keyof T] === undefined - ? defaultValue - : obj[path as keyof T]; - } - return result; -}; - -/** - * We want this to input path and have - * @param name - * @returns - */ -const x: RouterFn = name => { - console.log(resolve(doc, name)); - return null; -}; - -x('/pets/{petId}'); - type RemoveTail< S extends string, Tail extends string, @@ -279,79 +223,166 @@ export type RouteParameters = string extends Route interface ParsedQs { [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; } -interface ApiRouterMatcher< - TFieldValues extends FieldValues, + +// oh boy don't do this +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; +type LastOf = UnionToIntersection< + T extends any ? () => T : never +> extends () => infer R + ? R + : never; + +// TS4.0+ +type Push = [...T, V]; + +// TS4.1+ +type TuplifyUnion< T, - Method extends - | 'all' - | 'get' - | 'post' - | 'put' - | 'delete' - | 'patch' - | 'options' - | 'head', -> { - < - TFieldName extends Path = Path, - P = RouteParameters, - ResBody = any, - ReqBody = any, - ReqQuery = ParsedQs, - Locals extends Record = Record, - >( - // (it's used as the default type parameter for P) - path: TFieldName, - // (This generic is meant to be passed explicitly.) - ...handlers: Array> - ): T; -} + L = LastOf, + N = [T] extends [never] ? true : false, +> = true extends N ? [] : Push>, L>; -type path = ApiRouterMatcher; -const test: path = a => { - console.log(a); -}; -test('/pets/{petId}'); +type ConvertAll = []> = T extends [ + infer First extends JSONSchema7, + ...infer Rest, +] + ? ConvertAll]> + : R; -export interface IApiRouter extends IRouter { - all: ApiRouterMatcher; - get: ApiRouterMatcher; -} +type ResponseToJsonSchema< + Doc extends RequiredDoc, + Path extends PathTemplate>, + Method extends DocPathMethod, +> = ConvertAll< + TuplifyUnion>> +>[number]; -export default class ApiRouter< - ApiSpec, - PathSpec extends OpenAPIV3_1.Document, - T, -> implements IApiRouter -{ +type DocRequestHandler< + Doc extends RequiredDoc, + Path extends DocPathTemplate, + Method extends keyof Doc['paths'][Path], +> = core.RequestHandler< + core.ParamsDictionary, + // From https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema. + ResponseToJsonSchema, + RequestBodySchema, + ParsedQs, + Record +>; + +export default class ApiRouter { private _router = Router(); - constructor(private spec: OpenAPIV3_1.Document) {} + constructor(private spec: OpenAPIV3_1.Document | OpenAPIV3.Document) {} - static fromSpec(spec: OpenAPIV3_1.Document) { - return new ApiRouter(spec); + static fromSpec( + spec: OpenAPIV3_1.Document | OpenAPIV3.Document, + ) { + return new ApiRouter(spec); } - get< - TFieldValues extends FieldValues = PathSpec, - TFieldName extends FieldPath = FieldPath, - >( - path: TFieldName, - ...handlers: core.RequestHandler< - core.ParamsDictionary, - any, - FieldPathValue, - ParsedQs, - Record - >[] + get, 'get'>>( + path: Path, + ...handlers: DocRequestHandler[] ) { console.log(path); - return this._router.get(path, ...handlers); + this._router.get(path, ...handlers); + return this; + } + + post, 'post'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.post(path, ...handlers); + return this; + } + + all, 'all'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.all(path, ...handlers); + return this; + } + + put, 'put'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.put(path, ...handlers); + return this; + } + delete, 'delete'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.delete(path, ...handlers); + return this; + } + patch, 'patch'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.patch(path, ...handlers); + return this; + } + options< + Path extends MethodAwareDocPath, 'options'>, + >(path: Path, ...handlers: DocRequestHandler[]) { + console.log(path); + this._router.options(path, ...handlers); + return this; + } + head, 'head'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.head(path, ...handlers); + return this; + } + + use(...handlers: core.RequestHandler[]) { + return this._router.use(handlers); + } + + build() { + return this._router; } } -const router = ApiRouter.fromSpec(doc); +interface RouterOptions {} -router.get('/pets', (req, res) => { - req.body.tags; -}); +export async function createRouter(options: RouterOptions) { + const router = ApiRouter.fromSpec>( + // As const forces the doc to readonly which conflicts with imported types. + doc as DeepWriteable, + ); + + router.get('/pets/:uid', (req, res) => { + res.json({ + id: 1, + name: req.params['uid'], + }); + }); + + // router.get('/pet') will complain with a TS error + + router.post('/pets', (req, res) => { + res.json({ + message: req.path, + code: 1, + }); + }); + return router.build(); +} diff --git a/plugins/openapi-router-common/src/run.ts b/plugins/openapi-router-common/src/run.ts new file mode 100644 index 0000000000..9a14d0e57d --- /dev/null +++ b/plugins/openapi-router-common/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import yn from 'yn'; +import { getRootLogger } from '@backstage/backend-common'; +import { startStandaloneServer } from './standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3004; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/openapi-router-common/src/standaloneServer.ts b/plugins/openapi-router-common/src/standaloneServer.ts new file mode 100644 index 0000000000..aef8f006ff --- /dev/null +++ b/plugins/openapi-router-common/src/standaloneServer.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createServiceBuilder } from '@backstage/backend-common'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'app-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + appPackageName: 'example-app', + }); + + const service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/openapi-router-common/src/types/common.ts b/plugins/openapi-router-common/src/types/common.ts new file mode 100644 index 0000000000..2f0d6735a4 --- /dev/null +++ b/plugins/openapi-router-common/src/types/common.ts @@ -0,0 +1,116 @@ +/** + * Pulled from https://github.com/varanauskas/oatx. + */ + +import type { + ContentObject, + OpenAPIObject, + ReferenceObject, +} from 'openapi3-ts'; + +export type RequiredDoc = Pick; +export type PathDoc = { paths: Record }; + +/** + * Get value types of `T` + */ +export type ValueOf = T[keyof T]; + +/** + * Validate a string against OpenAPI path template + * ``` + * const path = PathTemplate<"/posts/{postId}/comments/{commentId}"> = "/posts/1/comments/2"const pathWithParams: PathTemplate<"/posts/{postId}/comments/{commentId}"> = "/posts/1/comments/2"; + * const pathWithoutParams: PathTemplate<"/posts/comments"> = "/posts/comments";``` + * https://spec.openapis.org/oas/v3.1.0#path-templating-matching + */ +export type PathTemplate = + Path extends `${infer Prefix}{${string}}${infer Suffix}` + ? `${Prefix}${string}${PathTemplate}` + : Path; + +/** + * Extract path as specified in OpenAPI `Doc` based on request path + * ``` + * const spec = { + * paths: { + * "/posts/{postId}/comments/{commentId}": {}, + * "/posts/comments": {}, + * } + * }; + * const specPathWithParams: DocPath = "/posts/{postId}/comments/{commentId}"; + * const specPathWithoutParams: DocPath = "/posts/comments"; + * ``` + */ +export type DocPath< + Doc extends PathDoc, + Path extends PathTemplate>, +> = ValueOf<{ + [Template in Extract< + keyof Doc['paths'], + string + >]: Path extends PathTemplate