From 046e04a7cf4076ff30873dac6202bde98f3db35b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Tue, 10 Jan 2023 13:07:12 -0500 Subject: [PATCH 01/61] Translated all endpoints to the new format, seeking feedback. Signed-off-by: Aramis Sennyey --- plugins/catalog-backend/package.json | 2 + .../src/service/createRouter.ts | 301 +++++++++--------- yarn.lock | 187 ++++++++++- 3 files changed, 331 insertions(+), 159 deletions(-) diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index db9e1845fe..65a3232e69 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -65,11 +65,13 @@ "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "express": "^4.17.1", + "express-openapi": "^12.1.0", "express-promise-router": "^4.1.0", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "10.1.0", "git-url-parse": "^13.0.0", "glob": "^7.1.6", + "js-yaml": "^4.1.0", "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 849096c601..6b733a0a9a 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -23,7 +23,7 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { NotFoundError, serializeError } from '@backstage/errors'; +import { InputError, NotFoundError, serializeError } from '@backstage/errors'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -50,6 +50,16 @@ import { locationInput, validateRequestBody, } from './util'; +import { initialize } from 'express-openapi'; +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; + +class ParsingError extends Error { + toString() { + return `ParsingError: ${this.message}`; + } +} /** * Options used by {@link createRouter}. @@ -94,26 +104,41 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } - if (refreshService) { - router.post('/refresh', async (req, res) => { - const refreshOptions: RefreshOptions = req.body; - refreshOptions.authorizationToken = getBearerToken( - req.header('authorization'), - ); + const validateDependency = ( + dependency: any, + next: (req: express.Request, res: express.Response) => any, + ) => { + return (req: express.Request, res: express.Response) => { + if (!dependency) { + console.log('no dependency'); + throw new NotFoundError( + 'Dependency Error', + 'Dependency not set up for this endpoint.', + ); + } + return next(req, res); + }; + }; - await refreshService.refresh(refreshOptions); - res.status(200).end(); - }); - } + initialize({ + app: router as any, + // NOTE: If using yaml you can provide a path relative to process.cwd() e.g. + // apiDoc: './api-v1/api-doc.yml', + apiDoc: yaml.load( + fs.readFileSync(path.resolve(__dirname, '../../openapi.yaml'), 'utf-8'), + ) as any, + operations: { + RefreshEntity: validateDependency(refreshService, async (req, res) => { + const refreshOptions: RefreshOptions = req.body; + refreshOptions.authorizationToken = getBearerToken( + req.header('authorization'), + ); - if (permissionIntegrationRouter) { - router.use(permissionIntegrationRouter); - } - - if (entitiesCatalog) { - router - .get('/entities', async (req, res) => { - const { entities, pageInfo } = await entitiesCatalog.entities({ + await refreshService!.refresh(refreshOptions); + res.status(200).end(); + }), + GetEntities: validateDependency(entitiesCatalog, async (req, res) => { + const { entities, pageInfo } = await entitiesCatalog!.entities({ filter: parseEntityFilterParams(req.query), fields: parseEntityTransformParams(req.query), order: parseEntityOrderParams(req.query), @@ -131,30 +156,10 @@ export async function createRouter( // TODO(freben): encode the pageInfo in the response res.json(entities); - }) - .get('/entities/by-query', async (req, res) => { - const { items, pageInfo, totalItems } = - await entitiesCatalog.queryEntities({ - ...parseQueryEntitiesParams(req.query), - authorizationToken: getBearerToken(req.header('authorization')), - }); - - res.json({ - items, - totalItems, - pageInfo: { - ...(pageInfo.nextCursor && { - nextCursor: encodeCursor(pageInfo.nextCursor), - }), - ...(pageInfo.prevCursor && { - prevCursor: encodeCursor(pageInfo.prevCursor), - }), - }, - }); - }) - .get('/entities/by-uid/:uid', async (req, res) => { + }), + GetEntityByUid: validateDependency(entitiesCatalog, async (req, res) => { const { uid } = req.params; - const { entities } = await entitiesCatalog.entities({ + const { entities } = await entitiesCatalog!.entities({ filter: basicEntityFilter({ 'metadata.uid': uid }), authorizationToken: getBearerToken(req.header('authorization')), }); @@ -162,17 +167,10 @@ export async function createRouter( throw new NotFoundError(`No entity with uid ${uid}`); } res.status(200).json(entities[0]); - }) - .delete('/entities/by-uid/:uid', async (req, res) => { - const { uid } = req.params; - await entitiesCatalog.removeEntityByUid(uid, { - authorizationToken: getBearerToken(req.header('authorization')), - }); - res.status(204).end(); - }) - .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { + }), + GetEntityByName: validateDependency(entitiesCatalog, async (req, res) => { const { kind, namespace, name } = req.params; - const { entities } = await entitiesCatalog.entities({ + const { entities } = await entitiesCatalog!.entities({ filter: basicEntityFilter({ kind: kind, 'metadata.namespace': namespace, @@ -186,41 +184,27 @@ export async function createRouter( ); } res.status(200).json(entities[0]); - }) - .get( - '/entities/by-name/:kind/:namespace/:name/ancestry', + }), + GetEntityAncestryByName: validateDependency( + entitiesCatalog, async (req, res) => { const { kind, namespace, name } = req.params; const entityRef = stringifyEntityRef({ kind, namespace, name }); - const response = await entitiesCatalog.entityAncestry(entityRef, { + const response = await entitiesCatalog!.entityAncestry(entityRef, { authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(response); }, - ) - .post('/entities/by-refs', async (req, res) => { - const request = entitiesBatchRequest(req); - const token = getBearerToken(req.header('authorization')); - const response = await entitiesCatalog.entitiesBatch({ - entityRefs: request.entityRefs, - fields: parseEntityTransformParams(req.query, request.fields), - authorizationToken: token, - }); - res.status(200).json(response); - }) - .get('/entity-facets', async (req, res) => { - const response = await entitiesCatalog.facets({ + ), + GetEntityFacets: validateDependency(entitiesCatalog, async (req, res) => { + const response = await entitiesCatalog!.facets({ filter: parseEntityFilterParams(req.query), facets: parseEntityFacetParams(req.query), authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(response); - }); - } - - if (locationService) { - router - .post('/locations', async (req, res) => { + }), + CreateLocation: validateDependency(locationService, async (req, res) => { const location = await validateRequestBody(req, locationInput); const dryRun = yn(req.query.dryRun, { default: false }); @@ -234,22 +218,21 @@ export async function createRouter( authorizationToken: getBearerToken(req.header('authorization')), }); res.status(201).json(output); - }) - .get('/locations', async (req, res) => { + }), + GetLocations: validateDependency(locationService, async (req, res) => { const locations = await locationService.listLocations({ authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(locations.map(l => ({ data: l }))); - }) - - .get('/locations/:id', async (req, res) => { + }), + GetLocation: validateDependency(locationService, async (req, res) => { const { id } = req.params; const output = await locationService.getLocation(id, { authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(output); - }) - .delete('/locations/:id', async (req, res) => { + }), + DeleteLocation: validateDependency(locationService, async (req, res) => { disallowReadonlyMode(readonlyEnabled); const { id } = req.params; @@ -257,74 +240,108 @@ export async function createRouter( authorizationToken: getBearerToken(req.header('authorization')), }); res.status(204).end(); - }); - } - - if (locationAnalyzer) { - router.post('/analyze-location', async (req, res) => { - const body = await validateRequestBody( - req, - z.object({ + }), + AnalyzeLocation: validateDependency(locationService, async (req, res) => { + const body = await validateRequestBody( + req, + z.object({ + location: locationInput, + catalogFilename: z.string().optional(), + }), + ); + const schema = z.object({ location: locationInput, catalogFilename: z.string().optional(), - }), - ); - const schema = z.object({ - location: locationInput, - catalogFilename: z.string().optional(), - }); - const output = await locationAnalyzer.analyzeLocation(schema.parse(body)); - res.status(200).json(output); - }); - } - - if (orchestrator) { - router.post('/validate-entity', async (req, res) => { - const bodySchema = z.object({ - entity: z.unknown(), - location: z.string(), - }); - - let body: z.infer; - let entity: Entity; - let location: { type: string; target: string }; - try { - body = await validateRequestBody(req, bodySchema); - entity = validateEntityEnvelope(body.entity); - location = parseLocationRef(body.location); - if (location.type !== 'url') - throw new TypeError( - `Invalid location ref ${body.location}, only 'url:' is supported, e.g. url:https://host/path`, - ); - } catch (err) { - return res.status(400).json({ - errors: [serializeError(err)], }); - } + const output = await locationAnalyzer!.analyzeLocation( + schema.parse(body), + ); + res.status(200).json(output); + }), + ValidateEntity: validateDependency(orchestrator, async (req, res) => { + const bodySchema = z.object({ + entity: z.unknown(), + location: z.string(), + }); - const processingResult = await orchestrator.process({ - entity: { - ...entity, - metadata: { - ...entity.metadata, - annotations: { - [ANNOTATION_LOCATION]: body.location, - [ANNOTATION_ORIGIN_LOCATION]: body.location, - ...entity.metadata.annotations, + let body: z.infer; + let entity: Entity; + let location: { type: string; target: string }; + try { + body = await validateRequestBody(req, bodySchema); + entity = validateEntityEnvelope(body.entity); + location = parseLocationRef(body.location); + if (location.type !== 'url') + throw new TypeError( + `Invalid location ref ${body.location}, only 'url:' is supported, e.g. url:https://host/path`, + ); + } catch (err) { + return res.status(400).json({ + errors: [serializeError(err)], + }); + } + + const processingResult = await orchestrator!.process({ + entity: { + ...entity, + metadata: { + ...entity.metadata, + annotations: { + [ANNOTATION_LOCATION]: body.location, + [ANNOTATION_ORIGIN_LOCATION]: body.location, + ...entity.metadata.annotations, + }, }, }, - }, - }); - - if (!processingResult.ok) - res.status(400).json({ - errors: processingResult.errors.map(e => serializeError(e)), }); - return res.status(200).end(); - }); - } - router.use(errorHandler()); + if (!processingResult.ok) + res.status(400).json({ + errors: processingResult.errors.map(e => serializeError(e)), + }); + return res.status(200).end(); + }), + DeleteEntityByUid: validateDependency( + entitiesCatalog, + async (req, res) => { + const { uid } = req.params; + await entitiesCatalog!.removeEntityByUid(uid, { + authorizationToken: getBearerToken(req.header('authorization')), + }); + res.status(204).end(); + }, + ), + GetEntitiesByRefs: validateDependency( + entitiesCatalog, + async (req, res) => { + const request = entitiesBatchRequest(req); + const token = getBearerToken(req.header('authorization')); + const response = await entitiesCatalog!.entitiesBatch({ + entityRefs: request.entityRefs, + fields: parseEntityTransformParams(req.query, request.fields), + authorizationToken: token, + }); + res.status(200).json(response); + }, + ), + }, + enableObjectCoercion: true, + errorMiddleware: errorHandler(), + errorTransformer: (openapiError, ajvError) => { + switch (openapiError.errorCode) { + case 'type.openapi.requestValidation': + throw new InputError( + `Invalid field ${openapiError.path}`, + new ParsingError(openapiError.message), + ); + } + return {}; + }, + }); + + if (permissionIntegrationRouter) { + router.use(permissionIntegrationRouter); + } return router; } diff --git a/yarn.lock b/yarn.lock index 3652a87b61..859a44fda4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5371,11 +5371,13 @@ __metadata: codeowners-utils: ^1.0.2 core-js: ^3.6.5 express: ^4.17.1 + express-openapi: ^12.1.0 express-promise-router: ^4.1.0 fast-json-stable-stringify: ^2.1.0 fs-extra: 10.1.0 git-url-parse: ^13.0.0 glob: ^7.1.6 + js-yaml: ^4.1.0 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 @@ -17590,7 +17592,7 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:^2.1.1": +"ajv-formats@npm:^2.0.2, ajv-formats@npm:^2.1.0, ajv-formats@npm:^2.1.1": version: 2.1.1 resolution: "ajv-formats@npm:2.1.1" dependencies: @@ -17636,7 +17638,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.8.0": +"ajv@npm:^8.0.0, ajv@npm:^8.1.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.3.0, ajv@npm:^8.4.0, ajv@npm:^8.8.0": version: 8.12.0 resolution: "ajv@npm:8.12.0" dependencies: @@ -20392,6 +20394,13 @@ __metadata: languageName: node linkType: hard +"content-type@npm:^1.0.4": + version: 1.0.4 + resolution: "content-type@npm:1.0.4" + checksum: 3d93585fda985d1554eca5ebd251994327608d2e200978fdbfba21c0c679914d5faf266d17027de44b34a72c7b0745b18584ecccaa7e1fdfb6a68ac7114f12e0 + languageName: node + linkType: hard + "content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -21787,6 +21796,15 @@ __metadata: languageName: node linkType: hard +"difunc@npm:0.0.4": + version: 0.0.4 + resolution: "difunc@npm:0.0.4" + dependencies: + esprima: ^4.0.0 + checksum: 19b850dae20cba3bdf238b85fa90f79526974e7634348a3157eef3da6eb161e7f43142aa531f6be61eed1235d1f4b6a16e07912f313b5afcceee56d998c2e300 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -23687,6 +23705,24 @@ __metadata: languageName: node linkType: hard +"express-normalize-query-params-middleware@npm:^0.5.0": + version: 0.5.1 + resolution: "express-normalize-query-params-middleware@npm:0.5.1" + checksum: 20fef3a200c452cbfef25f738e8ed44704ad30f04899cce21d3984b74e4fcbeefdc5dcec3aed1a0e4e54f231c55f3cba2c3d0cc3faebfea0cab437a852bca09b + languageName: node + linkType: hard + +"express-openapi@npm:^12.1.0": + version: 12.1.0 + resolution: "express-openapi@npm:12.1.0" + dependencies: + express-normalize-query-params-middleware: ^0.5.0 + openapi-framework: ^12.1.0 + openapi-types: ^12.1.0 + checksum: 6b87b7990891b1f4d4bd3e071eddd72d7433ae770d5ad0cfbbee234a0d4e712150640ec15d8fe9d1af99b49209700c58f4694045f00bd380a569a86ba5008755 + languageName: node + linkType: hard + "express-prom-bundle@npm:^6.3.6": version: 6.6.0 resolution: "express-prom-bundle@npm:6.6.0" @@ -24584,6 +24620,15 @@ __metadata: languageName: node linkType: hard +"fs-routes@npm:^12.0.0": + version: 12.0.0 + resolution: "fs-routes@npm:12.0.0" + peerDependencies: + glob: ">=7.1.6" + checksum: 4e97b08584c4ee04cf00215ac5175ba6f3aeaa4fb254cd108149d675edffcf90e802ead9965a5252271f26e1a47b31c341b41e209b9d210b2a584ae2a642c466 + languageName: node + linkType: hard + "fs.realpath@npm:^1.0.0": version: 1.0.0 resolution: "fs.realpath@npm:1.0.0" @@ -24905,6 +24950,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:*, glob@npm:8.0.3": + version: 8.0.3 + resolution: "glob@npm:8.0.3" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^5.0.1 + once: ^1.3.0 + checksum: 50bcdea19d8e79d8de5f460b1939ffc2b3299eac28deb502093fdca22a78efebc03e66bf54f0abc3d3d07d8134d19a32850288b7440d77e072aa55f9d33b18c5 + languageName: node + linkType: hard + "glob@npm:7.1.6": version: 7.1.6 resolution: "glob@npm:7.1.6" @@ -24919,19 +24977,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:8.0.3": - version: 8.0.3 - resolution: "glob@npm:8.0.3" - dependencies: - fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^5.0.1 - once: ^1.3.0 - checksum: 50bcdea19d8e79d8de5f460b1939ffc2b3299eac28deb502093fdca22a78efebc03e66bf54f0abc3d3d07d8134d19a32850288b7440d77e072aa55f9d33b18c5 - languageName: node - linkType: hard - "glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7, glob@npm:^7.2.0": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -26591,6 +26636,13 @@ __metadata: languageName: node linkType: hard +"is-dir@npm:^1.0.0": + version: 1.0.0 + resolution: "is-dir@npm:1.0.0" + checksum: b81430f22d03318b144391ec9633abdd6fdcd8fd02509bee1ab5a20b79311505ba4344282d2cab565114779ac36626fd034168b9bd47c3be12d956ae2ca76a5c + languageName: node + linkType: hard + "is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": version: 2.2.1 resolution: "is-docker@npm:2.2.1" @@ -29360,7 +29412,7 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": +"lodash.merge@npm:^4.6.1, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: ad580b4bdbb7ca1f7abf7e1bce63a9a0b98e370cf40194b03380a46b4ed799c9573029599caebc1b14e3f24b111aef72b96674a56cfa105e0f5ac70546cdc005 @@ -31924,6 +31976,79 @@ __metadata: languageName: node linkType: hard +"openapi-default-setter@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-default-setter@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 3b4543d7c091134690fcfc986258481b6211f17b4322552764b3a4a9725784202bc17419a524302dd1187ecea556dea4f2cb525f4a0258c2ab5ddec884bf00e2 + languageName: node + linkType: hard + +"openapi-framework@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-framework@npm:12.1.0" + dependencies: + difunc: 0.0.4 + fs-routes: ^12.0.0 + glob: "*" + is-dir: ^1.0.0 + js-yaml: ^3.10.0 + openapi-default-setter: ^12.1.0 + openapi-request-coercer: ^12.1.0 + openapi-request-validator: ^12.1.0 + openapi-response-validator: ^12.1.0 + openapi-schema-validator: ^12.1.0 + openapi-security-handler: ^12.1.0 + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 4cbb35e2870604c91c01f397aa2ea8fb072f648acb9a47b830d0d67c1e68bb75eadc3e97bcbe2ea994b7374ba38866b6d91734df6e2481ce42e6625d66ccc5bd + languageName: node + linkType: hard + +"openapi-jsonschema-parameters@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-jsonschema-parameters@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 9465549e132d02ad4be67326e22c9a2e3f0cf19ffa0e3c1fe69a542a675707f11c33080836b0eebd75c02501dbcb5f4130fa446d470ede3ba67b7c52e1bc341f + languageName: node + linkType: hard + +"openapi-request-coercer@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-request-coercer@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 4359d4fd58b032146838aabb9762fc2ac01771e7bea4679af5a241964f3df15ddced7968e6a8d419954d166e93ad32e08f530da0d9a8483d3c4d97be7c7c848c + languageName: node + linkType: hard + +"openapi-request-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-request-validator@npm:12.1.0" + dependencies: + ajv: ^8.3.0 + ajv-formats: ^2.1.0 + content-type: ^1.0.4 + openapi-jsonschema-parameters: ^12.1.0 + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 794b1c15dbf04107ede7b8304e5b7c2fa183ad0c4bd65b3e0ee4d415cf508324932959f661120f1bfb58a23d88b5458dc0db868bc36f9ec44ea03b99ea9d42df + languageName: node + linkType: hard + +"openapi-response-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-response-validator@npm:12.1.0" + dependencies: + ajv: ^8.4.0 + openapi-types: ^12.1.0 + checksum: f07036661ac846c5d508b423d01039cd5bd33d564e053fdc039046f964fad71b03a7c9254195b1d5a8a1912fcc45dce13616af97c445c6d7b040c825e1f91de1 + languageName: node + linkType: hard + "openapi-sampler@npm:^1.2.1": version: 1.2.1 resolution: "openapi-sampler@npm:1.2.1" @@ -31934,7 +32059,28 @@ __metadata: languageName: node linkType: hard -"openapi-types@npm:^12.0.0": +"openapi-schema-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-schema-validator@npm:12.1.0" + dependencies: + ajv: ^8.1.0 + ajv-formats: ^2.0.2 + lodash.merge: ^4.6.1 + openapi-types: ^12.1.0 + checksum: 1008ef82910cc30e85132c041b269a4cc2991dad17e2d7c4cddbfb5641a15e934016bc4d1bab32f2dc79f7f3673b82b655c3634838c9ce3a8f497af3be762aa5 + languageName: node + linkType: hard + +"openapi-security-handler@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-security-handler@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 07f645300fdc0ca5d01790fb77e01d8e537f6b0439f55ec9af5097d3dd044e3e0fc55ce0acb9551f04256819d7738f8c3d468645e581f7b0487648eed428d519 + languageName: node + linkType: hard + +"openapi-types@npm:^12.0.0, openapi-types@npm:^12.1.0": version: 12.1.0 resolution: "openapi-types@npm:12.1.0" checksum: d8f3e2bae519aa6bcf2012f4a5592b7283fe928c06f3bdc182776ce637c7ed8d5f9f342724f68c95c9572aefe21ed5e89b18a0295a703da61af5f97bd2aea9a7 @@ -38457,6 +38603,13 @@ __metadata: languageName: node linkType: hard +"ts-log@npm:^2.1.4": + version: 2.2.5 + resolution: "ts-log@npm:2.2.5" + checksum: 28f78ab15b8555d56c089dbc243327d8ce4331219956242a29fc4cb3bad6bb0cb8234dd17a292381a1b1dba99a7e4849a2181b2e1a303e8247e9f4ca4e284f2d + languageName: node + linkType: hard + "ts-log@npm:^2.2.3": version: 2.2.3 resolution: "ts-log@npm:2.2.3" From d7585d4003dc93f83f38fc0d266fe79399cd774f Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 19 Jan 2023 17:17:28 -0500 Subject: [PATCH 02/61] Starting router. Signed-off-by: Aramis Sennyey --- .../src/service/createRouter.ts | 18 + plugins/openapi-router-common/.eslintrc.js | 1 + plugins/openapi-router-common/README.md | 5 + plugins/openapi-router-common/package.json | 37 + plugins/openapi-router-common/src/index.ts | 23 + plugins/openapi-router-common/src/router.ts | 357 ++++++++++ .../openapi-router-common/src/setupTests.ts | 16 + .../openapi-router-common/src/types/fields.ts | 66 ++ .../openapi-router-common/src/types/path.ts | 666 ++++++++++++++++++ .../openapi-router-common/src/types/utils.ts | 144 ++++ yarn.lock | 43 +- 11 files changed, 1374 insertions(+), 2 deletions(-) create mode 100644 plugins/openapi-router-common/.eslintrc.js create mode 100644 plugins/openapi-router-common/README.md create mode 100644 plugins/openapi-router-common/package.json create mode 100644 plugins/openapi-router-common/src/index.ts create mode 100644 plugins/openapi-router-common/src/router.ts create mode 100644 plugins/openapi-router-common/src/setupTests.ts create mode 100644 plugins/openapi-router-common/src/types/fields.ts create mode 100644 plugins/openapi-router-common/src/types/path.ts create mode 100644 plugins/openapi-router-common/src/types/utils.ts diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6b733a0a9a..6685332002 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -54,6 +54,7 @@ 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() { @@ -104,6 +105,23 @@ 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, diff --git a/plugins/openapi-router-common/.eslintrc.js b/plugins/openapi-router-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/openapi-router-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/openapi-router-common/README.md b/plugins/openapi-router-common/README.md new file mode 100644 index 0000000000..cda5d7fc7f --- /dev/null +++ b/plugins/openapi-router-common/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-openapi-router-common + +Welcome to the common package for the openapi-router plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/openapi-router-common/package.json b/plugins/openapi-router-common/package.json new file mode 100644 index 0000000000..a852edfcec --- /dev/null +++ b/plugins/openapi-router-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@backstage/plugin-openapi-router", + "description": "OpenAPI router with type completions.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ], + "dependencies": { + "express": "^4.18.2", + "json-schema-to-ts": "^2.6.2", + "openapi-types": "^12.1.0" + } +} diff --git a/plugins/openapi-router-common/src/index.ts b/plugins/openapi-router-common/src/index.ts new file mode 100644 index 0000000000..7edca76f93 --- /dev/null +++ b/plugins/openapi-router-common/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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. + */ + +/** + * Common functionalities for the openapi-router plugin. + * + * @packageDocumentation + */ + +export * from './router'; diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts new file mode 100644 index 0000000000..c7940cdbd0 --- /dev/null +++ b/plugins/openapi-router-common/src/router.ts @@ -0,0 +1,357 @@ +/* + * Copyright 2023 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 { OpenAPIV3_1 } from 'openapi-types'; +import { FieldValues } from './types/fields'; +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'; + +type RouterFn = < + TFieldName extends FieldPath = FieldPath, +>( + name: TFieldName, +) => null; + +const doc = { + openapi: '3.1.0', + info: { + version: '1.0.0', + title: 'Swagger Petstore', + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + servers: [ + { + url: 'http://petstore.swagger.io/v1', + }, + ], + paths: { + '/pets': { + get: { + summary: 'List all pets', + operationId: 'listPets', + tags: ['pets'], + parameters: [ + { + name: 'limit', + in: 'query', + description: 'How many items to return at one time (max 100)', + required: false, + schema: { + type: 'integer', + format: 'int32', + }, + }, + ], + responses: { + '200': { + description: 'A paged array of pets', + headers: { + 'x-next': { + description: 'A link to the next page of responses', + schema: { + type: 'string', + }, + }, + }, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pets', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + post: { + summary: 'Create a pet', + operationId: 'createPets', + tags: ['pets'], + responses: { + '201': { + description: 'Null response', + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + '/pets/{petId}': { + get: { + summary: 'Info for a specific pet', + operationId: 'showPetById', + tags: ['pets'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'The id of the pet to retrieve', + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Expected response to a valid request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + format: 'int64', + }, + name: { + type: 'string', + }, + tag: { + type: 'string', + }, + }, + }, + Pets: { + type: 'array', + items: { + $ref: '#/components/schemas/Pet', + }, + }, + Error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'integer', + format: 'int32', + }, + message: { + type: 'string', + }, + }, + }, + }, + }, +} 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, +> = S extends `${infer P}${Tail}` ? P : S; +type GetRouteParameter = RemoveTail< + RemoveTail, `-${string}`>, + `.${string}` +>; +export type RouteParameters = string extends Route + ? ParamsDictionary + : Route extends `${string}(${string}` + ? ParamsDictionary + : Route extends `${string}:${infer Rest}` + ? (GetRouteParameter extends never + ? ParamsDictionary + : GetRouteParameter extends `${infer ParamName}?` + ? { [P in ParamName]?: string } + : { [P in GetRouteParameter]: string }) & + (Rest extends `${GetRouteParameter}${infer Next}` + ? RouteParameters + : unknown) + : {}; +interface ParsedQs { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +} +interface ApiRouterMatcher< + TFieldValues extends FieldValues, + 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; +} + +type path = ApiRouterMatcher; +const test: path = a => { + console.log(a); +}; +test('/pets/{petId}'); + +export interface IApiRouter extends IRouter { + all: ApiRouterMatcher; + get: ApiRouterMatcher; +} + +export default class ApiRouter< + ApiSpec, + PathSpec extends OpenAPIV3_1.Document, + T, +> implements IApiRouter +{ + private _router = Router(); + + constructor(private spec: OpenAPIV3_1.Document) {} + + static fromSpec(spec: OpenAPIV3_1.Document) { + return new ApiRouter(spec); + } + + get< + TFieldValues extends FieldValues = PathSpec, + TFieldName extends FieldPath = FieldPath, + >( + path: TFieldName, + ...handlers: core.RequestHandler< + core.ParamsDictionary, + any, + FieldPathValue, 'get'>, + ParsedQs, + Record + >[] + ) { + console.log(path); + return this._router.get(path, ...handlers); + } +} + +const router = ApiRouter.fromSpec(doc); + +router.get('/pets', (req, res) => { + req.body.tags; +}); diff --git a/plugins/openapi-router-common/src/setupTests.ts b/plugins/openapi-router-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/openapi-router-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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. + */ +export {}; diff --git a/plugins/openapi-router-common/src/types/fields.ts b/plugins/openapi-router-common/src/types/fields.ts new file mode 100644 index 0000000000..e97eeace9d --- /dev/null +++ b/plugins/openapi-router-common/src/types/fields.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2023 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 { IsFlatObject, Noop } from './utils'; + +export type InternalFieldName = string; + +export type FieldName = + IsFlatObject extends true + ? Extract + : string; + +export type CustomElement = { + name: FieldName; + type?: string; + value?: any; + disabled?: boolean; + checked?: boolean; + options?: HTMLOptionsCollection; + files?: FileList | null; + focus?: Noop; +}; + +export type FieldValue = + TFieldValues[InternalFieldName]; + +export type FieldValues = Record; + +export type NativeFieldValue = + | string + | number + | boolean + | null + | undefined + | unknown[]; + +export type FieldElement = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | CustomElement; + +export type Ref = FieldElement; + +export type Field = { + _f: { + ref: Ref; + name: InternalFieldName; + refs?: HTMLInputElement[]; + mount?: boolean; + }; +}; + +export type FieldRefs = Partial>; diff --git a/plugins/openapi-router-common/src/types/path.ts b/plugins/openapi-router-common/src/types/path.ts new file mode 100644 index 0000000000..c1ffbd6378 --- /dev/null +++ b/plugins/openapi-router-common/src/types/path.ts @@ -0,0 +1,666 @@ +/* + * Copyright 2023 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 { FieldValues } from './fields'; +import { + BrowserNativeObject, + IsAny, + IsNever, + IsEqual, + Primitive, +} from './utils'; + +/** + * Type alias to `string` which describes a lodash-like path through an object. + * E.g. `'foo.bar.0.baz'` + */ +export type PathString = string; + +/** + * Type which can be traversed through with a {@link PathString}. + * I.e. objects, arrays, and tuples + */ +export type Traversable = object; + +/** + * Type to query whether an array type T is a tuple type. + * @typeParam T - type which may be an array or tuple + * @example + * ``` + * IsTuple<[number]> = true + * IsTuple = false + * ``` + */ +export type IsTuple> = number extends T['length'] + ? false + : true; + +/** + * Type which can be used to index an array or tuple type. + */ +export type ArrayKey = number; + +/** + * Type which can be used to index an object. + */ +export type Key = string; + +/** + * Type to assert that a type is a {@link Key}. + * @typeParam T - type which may be a {@link Key} + */ +export type AsKey = Extract; + +/** + * Type to convert a type to a {@link Key}. + * @typeParam T - type which may be converted to a {@link Key} + */ +export type ToKey = T extends ArrayKey ? `${T}` : AsKey; + +/** + * Type which describes a path through an object + * as a list of individual {@link Key}s. + */ +export type PathTuple = Key[]; + +/** + * Type to assert that a type is a {@link PathTuple}. + * @typeParam T - type which may be a {@link PathTuple} + */ +export type AsPathTuple = Extract; + +/** + * Type to intersect a union type. + * See https://fettblog.eu/typescript-union-to-intersection/ + * @typeParam U - union + * @example + * ``` + * UnionToIntersection<{ foo: string } | { bar: number }> + * = { foo: string; bar: number } + * ``` + */ +export type UnionToIntersection = ( + U extends any ? (_: U) => any : never +) extends (_: infer I) => any + ? I + : never; + +/** + * Type which appends a {@link Key} to the {@link PathTuple} only if it is not + * blank, i.e. not the empty string. + * @typeParam PT - path + * @typeParam K - key + * @example + * ``` + * AppendNonBlankKey<['foo'], 'bar'> = ['foo', 'bar'] + * AppendNonBlankKey<['foo'], ''> = ['foo'] + * ``` + */ +export type AppendNonBlankKey< + PT extends PathTuple, + K extends Key, +> = K extends '' ? PT : [...PT, K]; + +/** + * Type to implement {@link SplitPathString} tail recursively. + * @typeParam PS - remaining {@link PathString} which should be split into its + * individual {@link Key}s + * @typeParam PT - accumulator of the {@link Key}s which have been split from + * the original {@link PathString} already + */ +type SplitPathStringImpl< + PS extends PathString, + PT extends PathTuple, +> = PS extends `${infer K}.${infer R}` + ? SplitPathStringImpl> + : AppendNonBlankKey; + +/** + * Type to split a {@link PathString} into a {@link PathTuple}. + * The individual {@link Key}s may be empty strings. + * @typeParam PS - {@link PathString} which should be split into its + * individual {@link Key}s + * @example + * ``` + * SplitPathString<'foo'> = ['foo'] + * SplitPathString<'foo.bar.0.baz'> = ['foo', 'bar', '0', 'baz'] + * SplitPathString<'.'> = [] + * ``` + */ +export type SplitPathString = SplitPathStringImpl< + PS, + [] +>; + +/** + * Type to implement {@link JoinPathTuple} tail-recursively. + * @typeParam PT - remaining {@link Key}s which needs to be joined + * @typeParam PS - accumulator of the already joined {@link Key}s + */ +type JoinPathTupleImpl< + PT extends PathTuple, + PS extends PathString, +> = PT extends [infer K, ...infer R] + ? JoinPathTupleImpl, `${PS}.${AsKey}`> + : PS; + +/** + * Type to join a {@link PathTuple} to a {@link PathString}. + * @typeParam PT - {@link PathTuple} which should be joined. + * @example + * ``` + * JoinPathTuple<['foo']> = 'foo' + * JoinPathTuple<['foo', 'bar', '0', 'baz']> = 'foo.bar.0.baz' + * JoinPathTuple<[]> = never + * ``` + */ +export type JoinPathTuple = PT extends [ + infer K, + ...infer R, +] + ? JoinPathTupleImpl, AsKey> + : never; + +/** + * Type which converts all keys of an object to {@link Key}s. + * @typeParam T - object type + * @example + * ``` + * MapKeys<{0: string}> = {'0': string} + * ``` + */ +type MapKeys = { [K in keyof T as ToKey]: T[K] }; + +/** + * Type to access a type by a key. + * - Returns undefined if it can't be indexed by that key. + * - Returns null if the type is null. + * - Returns undefined if the type is not traversable. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccess<{foo: string}, 'foo'> = string + * TryAccess<{foo: string}, 'bar'> = undefined + * TryAccess = null + * TryAccess = undefined + * ``` + */ +type TryAccess = K extends keyof T + ? T[K] + : T extends null + ? null + : undefined; + +/** + * Type to access an array type by a key. + * Returns undefined if the key is non-numeric. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccessArray = string + * TryAccessArray = undefined + * ``` + */ +type TryAccessArray< + T extends ReadonlyArray, + K extends Key, +> = K extends `${ArrayKey}` ? T[number] : TryAccess; + +/** + * Type to evaluate the type which the given key points to. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * @example + * ``` + * EvaluateKey<{foo: string}, 'foo'> = string + * EvaluateKey<[number, string], '1'> = string + * EvaluateKey = string + * ``` + */ +export type EvaluateKey = T extends ReadonlyArray + ? IsTuple extends true + ? TryAccess + : TryAccessArray + : TryAccess, K>; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam PT - path into the deeply nested type + * @example + * ``` + * EvaluatePath<{foo: {bar: string}}, ['foo', 'bar']> = string + * EvaluatePath<[number, string], ['1']> = string + * EvaluatePath = number + * EvaluatePath = undefined + * ``` + */ +export type EvaluatePath = PT extends [ + infer K, + ...infer R, +] + ? EvaluatePath>, AsPathTuple> + : T; + +/** + * Type which given a tuple type returns its own keys, i.e. only its indices. + * @typeParam T - tuple type + * @example + * ``` + * TupleKeys<[number, string]> = '0' | '1' + * ``` + */ +export type TupleKeys> = Exclude< + keyof T, + keyof any[] +>; + +/** + * Type which extracts all numeric keys from an object. + * @typeParam T - type + * @example + * ``` + * NumericObjectKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * ``` + */ +type NumericObjectKeys = ToKey< + Extract +>; + +/** + * Type which extracts all numeric keys from an object, tuple, or array. + * If a union is passed, it evaluates to the overlapping numeric keys. + * @typeParam T - type + * @example + * ``` + * NumericKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * NumericKeys = `${number}` + * NumericKeys<[string, number]> = '0' | '1' + * NumericKeys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type NumericKeys = UnionToIntersection< + T extends ReadonlyArray + ? IsTuple extends true + ? [TupleKeys] + : [ToKey] + : [NumericObjectKeys] +>[never]; + +/** + * Type which extracts all keys from an object. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - object type + * @example + * ``` + * ObjectKeys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * ObjectKeys<{foo: string, bar: number}, string> = 'foo' + * ``` + */ +export type ObjectKeys = Exclude< + ToKey, + `${string}.${string}` | '' +>; + +/** + * Type to check whether a type's property matches the constraint type + * and return its key. Converts the key to a {@link Key}. + * @typeParam T - type whose property should be checked + * @typeParam K - key of the property + * @typeParam U - constraint type + * @example + * ``` + * CheckKeyConstraint<{foo: string}, 'foo', string> = 'foo' + * CheckKeyConstraint<{foo: string}, 'foo', number> = never + * CheckKeyConstraint = `${number}` + * ``` + */ +export type CheckKeyConstraint = K extends any + ? EvaluateKey extends U + ? K + : never + : never; + +/** + * Type which evaluates to true when the type is an array or tuple or is a union + * which contains an array or tuple. + * @typeParam T - type + * @example + * ``` + * ContainsIndexable<{foo: string}> = false + * ContainsIndexable<{foo: string} | number[]> = true + * ``` + */ +export type ContainsIndexable = IsNever< + Extract> +> extends true + ? false + : true; + +/** + * Type to implement {@link Keys} for non-nullable values. + * @typeParam T - non-nullable type whose property should be checked + */ +type KeysImpl = [T] extends [Traversable] + ? ContainsIndexable extends true + ? NumericKeys + : ObjectKeys + : never; + +/** + * Type to find all properties of a type that match the constraint type + * and return their keys. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - type whose property should be checked + * @typeParam U - constraint type + * @example + * ``` + * Keys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * Keys<{foo?: string, bar?: string}> = 'foo' | 'bar' + * Keys<{foo: string, bar: number}, string> = 'foo' + * Keys<[string, number], string> = '0' + * Keys = `${number}` + * Keys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type Keys = IsAny extends true + ? Key + : IsNever extends true + ? Key + : IsNever> extends true + ? never + : CheckKeyConstraint>, U>; + +/** + * Type to check whether a {@link Key} is present in a type. + * If a union of {@link Key}s is passed, all {@link Key}s have to be present + * in the type. + * @typeParam T - type which is introspected + * @typeParam K - key + * @example + * ``` + * HasKey<{foo: string}, 'foo'> = true + * HasKey<{foo: string}, 'bar'> = false + * HasKey<{foo: string}, 'foo' | 'bar'> = false + * ``` + */ +export type HasKey = IsNever>>; + +/** + * Type to implement {@link ValidPathPrefix} tail recursively. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @typeParam VPT - accumulates the prefix of {@link Key}s which have been + * confirmed to exist already + */ +type ValidPathPrefixImpl< + T, + PT extends PathTuple, + VPT extends PathTuple, +> = PT extends [infer K, ...infer R] + ? HasKey> extends true + ? ValidPathPrefixImpl< + EvaluateKey>, + AsPathTuple, + AsPathTuple<[...VPT, K]> + > + : VPT + : VPT; + +/** + * Type to find the longest path prefix which is still valid, + * i.e. exists within the given type. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'bar']> = ['foo', 'bar'] + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'ba']> = ['foo'] + * ``` + */ +export type ValidPathPrefix = ValidPathPrefixImpl< + T, + PT, + [] +>; + +/** + * Type to check whether a path through a type exists. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * HasPath<{foo: {bar: string}}, ['foo', 'bar']> = true + * HasPath<{foo: {bar: string}}, ['foo', 'ba']> = false + * ``` + */ +export type HasPath = ValidPathPrefix extends PT + ? true + : false; + +/** + * Helper function to break apart T1 and check if any are equal to T2 + * + * See {@link IsEqual} + */ +type AnyIsEqual = T1 extends T2 + ? IsEqual extends true + ? true + : never + : never; + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'all'; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link Path} + */ +type PathImpl = V extends + | Primitive + | BrowserNativeObject + ? `${K}` + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? `${K}` + : true extends AnyIsEqual + ? `` + : `${K}` | `${K}.${PathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link Path} + */ +type PathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: PathImpl; + }[TupleKeys] + : PathImpl + : { + [K in keyof T]-?: PathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type + * @typeParam T - type which should be introspected + * @example + * ``` + * Path<{foo: {bar: string}}> = 'foo' | 'foo.bar' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type Path = T extends any ? PathInternal : never; + +/** + * See {@link Path} + */ +export type FieldPath = Path; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link ArrayPath} + */ +type ArrayPathImpl = V extends + | Primitive + | BrowserNativeObject + ? IsAny extends true + ? string + : never + : V extends ReadonlyArray + ? U extends Primitive | BrowserNativeObject + ? IsAny extends true + ? string + : never + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? never + : `${K}` | `${K}.${ArrayPathInternal}` + : true extends AnyIsEqual + ? never + : `${K}.${ArrayPathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link ArrayPath} + */ +type ArrayPathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: ArrayPathImpl; + }[TupleKeys] + : ArrayPathImpl + : { + [K in keyof T]-?: ArrayPathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type which point to an array + * type. + * @typeParam T - type which should be introspected. + * @example + * ``` + * Path<{foo: {bar: string[], baz: number[]}}> = 'foo.bar' | 'foo.baz' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type ArrayPath = T extends any ? ArrayPathInternal : never; + +/** + * See {@link ArrayPath} + */ +export type FieldArrayPath = + ArrayPath; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam P - path into the deeply nested type + * @example + * ``` + * PathValue<{foo: {bar: string}}, 'foo.bar'> = string + * PathValue<[number, string], '1'> = string + * ``` + */ +export type PathValue | ArrayPath> = T extends any + ? P extends `${infer K}.${infer R}` + ? K extends keyof T + ? R extends Path + ? PathValue + : never + : K extends `${ArrayKey}` + ? T extends ReadonlyArray + ? PathValue> + : never + : never + : P extends keyof T + ? T[P] + : P extends `${ArrayKey}` + ? T extends ReadonlyArray + ? V + : never + : never + : never; + +/** + * See {@link PathValue} + */ +export type FieldPathValue< + TFieldValues extends FieldValues, + TFieldPath extends FieldPath, +> = PathValue; + +/** + * See {@link PathValue} + */ +export type FieldArrayPathValue< + TFieldValues extends FieldValues, + TFieldArrayPath extends FieldArrayPath, +> = PathValue; + +/** + * Type to evaluate the type which the given paths point to. + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TPath - paths into the deeply nested field values + * @example + * ``` + * FieldPathValues<{foo: {bar: string}}, ['foo', 'foo.bar']> + * = [{bar: string}, string] + * ``` + */ +export type FieldPathValues< + TFieldValues extends FieldValues, + TPath extends FieldPath[] | readonly FieldPath[], +> = {} & { + [K in keyof TPath]: FieldPathValue< + TFieldValues, + TPath[K] & FieldPath + >; +}; + +/** + * Type which eagerly collects all paths through a fieldType that matches a give type + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TValue - the value you want to match into each type + * @example + * ```typescript + * FieldPathByValue<{foo: {bar: number}, baz: number, bar: string}, number> + * = 'foo.bar' | 'baz' + * ``` + */ +export type FieldPathByValue = { + [FieldKey in FieldPath]: FieldPathValue< + TFieldValues, + FieldKey + > extends TValue + ? FieldKey + : never; +}[FieldPath]; diff --git a/plugins/openapi-router-common/src/types/utils.ts b/plugins/openapi-router-common/src/types/utils.ts new file mode 100644 index 0000000000..25282e1da2 --- /dev/null +++ b/plugins/openapi-router-common/src/types/utils.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2023 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. + */ +declare const $NestedValue: unique symbol; + +/** + * @deprecated to be removed in the next major version + */ +export type NestedValue = { + [$NestedValue]: never; +} & TValue; + +/* +Projects that React Hook Form installed don't include the DOM library need these interfaces to compile. +React Native applications is no DOM available. The JavaScript runtime is ES6/ES2015 only. +These definitions allow such projects to compile with only --lib ES6. + +Warning: all of these interfaces are empty. +If you want type definitions for various properties, you need to add `--lib DOM` (via command line or tsconfig.json). +*/ + +export type Noop = () => void; + +interface File extends Blob { + readonly lastModified: number; + readonly name: string; +} + +interface FileList { + readonly length: number; + item(index: number): File | null; + [index: number]: File; +} + +export type Primitive = + | null + | undefined + | string + | number + | boolean + | symbol + | bigint; + +export type BrowserNativeObject = Date | FileList | File; + +export type EmptyObject = { [K in string | number]: never }; + +export type NonUndefined = T extends undefined ? never : T; + +export type LiteralUnion = + | T + | (U & { _?: never }); + +export type DeepPartial = T extends BrowserNativeObject | NestedValue + ? T + : { [K in keyof T]?: DeepPartial }; + +export type DeepPartialSkipArrayKey = T extends + | BrowserNativeObject + | NestedValue + ? T + : T extends ReadonlyArray + ? { [K in keyof T]: DeepPartialSkipArrayKey } + : { [K in keyof T]?: DeepPartialSkipArrayKey }; + +/** + * Checks whether the type is any + * See {@link https://stackoverflow.com/a/49928360/3406963} + * @typeParam T - type which may be any + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsAny = 0 extends 1 & T ? true : false; + +/** + * Checks whether the type is never + * @typeParam T - type which may be never + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsNever = [T] extends [never] ? true : false; + +/** + * Checks whether T1 can be exactly (mutually) assigned to T2 + * @typeParam T1 - type to check + * @typeParam T2 - type to check against + * ``` + * IsEqual = true + * IsEqual<'foo', 'foo'> = true + * IsEqual = false + * IsEqual = false + * IsEqual = false + * IsEqual<'foo', string> = false + * IsEqual<'foo' | 'bar', 'foo'> = boolean // 'foo' is assignable, but 'bar' is not (true | false) -> boolean + * ``` + */ +export type IsEqual = T1 extends T2 + ? (() => G extends T1 ? 1 : 2) extends () => G extends T2 ? 1 : 2 + ? true + : false + : false; + +export type DeepMap = IsAny extends true + ? any + : T extends BrowserNativeObject | NestedValue + ? TValue + : T extends object + ? { [K in keyof T]: DeepMap, TValue> } + : TValue; + +export type IsFlatObject = Extract< + Exclude, + any[] | object +> extends never + ? true + : false; + +export type Merge = { + [K in keyof A | keyof B]?: K extends keyof A & keyof B + ? [A[K], B[K]] extends [object, object] + ? Merge + : A[K] | B[K] + : K extends keyof A + ? A[K] + : K extends keyof B + ? B[K] + : never; +}; diff --git a/yarn.lock b/yarn.lock index 859a44fda4..eb39765348 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3342,7 +3342,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.20.13 resolution: "@babel/runtime@npm:7.20.13" dependencies: @@ -7446,6 +7446,17 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common" + dependencies: + "@backstage/cli": "workspace:^" + express: ^4.18.2 + json-schema-to-ts: ^2.6.2 + openapi-types: ^12.1.0 + languageName: unknown + linkType: soft + "@backstage/plugin-org-react@workspace:plugins/org-react": version: 0.0.0-use.local resolution: "@backstage/plugin-org-react@workspace:plugins/org-react" @@ -23777,7 +23788,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1": +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -28324,6 +28335,18 @@ __metadata: languageName: node linkType: hard +"json-schema-to-ts@npm:^2.6.2": + version: 2.6.2 + resolution: "json-schema-to-ts@npm:2.6.2" + dependencies: + "@babel/runtime": ^7.18.3 + "@types/json-schema": ^7.0.9 + ts-algebra: ^1.1.1 + ts-toolbelt: ^9.6.0 + checksum: e408f8d3d32dda1a001f194cf5514fea7ef0a4dc4ed4f5448b9ea02df3a071119a0517aaffb639de725f4a12102406dfdf7743455e0e30317e231bb551362039 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -38580,6 +38603,15 @@ __metadata: languageName: node linkType: hard +"ts-algebra@npm:^1.1.1": + version: 1.1.1 + resolution: "ts-algebra@npm:1.1.1" + dependencies: + ts-toolbelt: ^9.6.0 + checksum: 29fe27215863520377a94a14ccf1e78e921be6a5c514a0a8250dab09e325b006a9571ef1f366875d58e495b59ba880620718a1dd38b6af598cc6ea6de82d4f66 + languageName: node + linkType: hard + "ts-easing@npm:^0.2.0": version: 0.2.0 resolution: "ts-easing@npm:0.2.0" @@ -38672,6 +38704,13 @@ __metadata: languageName: node linkType: hard +"ts-toolbelt@npm:^9.6.0": + version: 9.6.0 + resolution: "ts-toolbelt@npm:9.6.0" + checksum: 9f35fd95d895a5d32ea9fd2e532a695b0bae6cbff6832b77292efa188a0ed1ed6e54f63f74a8920390f3d909a7a3adb20a144686372a8e78b420246a9bd3d58a + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.1 resolution: "tsconfig-paths@npm:3.14.1" From ef4d42ec301f586560b2a195d23a091acb8d06e9 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 19 Jan 2023 18:51:12 -0500 Subject: [PATCH 03/61] Testing Signed-off-by: Aramis Sennyey --- plugins/openapi-router-common/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts index c7940cdbd0..740dc8dc03 100644 --- a/plugins/openapi-router-common/src/router.ts +++ b/plugins/openapi-router-common/src/router.ts @@ -340,7 +340,7 @@ export default class ApiRouter< ...handlers: core.RequestHandler< core.ParamsDictionary, any, - FieldPathValue, 'get'>, + FieldPathValue, ParsedQs, Record >[] From e1962a4f0a0db599e9a5a66307f603d7ebf7753b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 16 Feb 2023 16:13:26 -0500 Subject: [PATCH 04/61] Ading server testing and better handlers. Signed-off-by: Aramis Sennyey --- .../src/service/createRouter.ts | 33 +- plugins/openapi-router-common/package.json | 4 +- plugins/openapi-router-common/src/router.ts | 295 ++++---- plugins/openapi-router-common/src/run.ts | 33 + .../src/standaloneServer.ts | 48 ++ .../openapi-router-common/src/types/common.ts | 116 +++ .../openapi-router-common/src/types/fields.ts | 66 -- .../openapi-router-common/src/types/index.ts | 2 + .../openapi-router-common/src/types/path.ts | 666 ------------------ .../src/types/requests.ts | 36 + .../src/types/response.ts | 68 ++ .../openapi-router-common/src/types/utils.ts | 144 ---- yarn.lock | 11 + 13 files changed, 491 insertions(+), 1031 deletions(-) create mode 100644 plugins/openapi-router-common/src/run.ts create mode 100644 plugins/openapi-router-common/src/standaloneServer.ts create mode 100644 plugins/openapi-router-common/src/types/common.ts delete mode 100644 plugins/openapi-router-common/src/types/fields.ts create mode 100644 plugins/openapi-router-common/src/types/index.ts delete mode 100644 plugins/openapi-router-common/src/types/path.ts create mode 100644 plugins/openapi-router-common/src/types/requests.ts create mode 100644 plugins/openapi-router-common/src/types/response.ts delete mode 100644 plugins/openapi-router-common/src/types/utils.ts 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