From ef4ccb7e687192e39d87711dfcc5d63fdabf0287 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:39:58 -0800 Subject: [PATCH 01/16] in progress work --- eslint.config.js | 15 +- package.json | 2 - packages/rest/__tests__/src/routing.spec.mts | 1 - packages/rest/package.json | 6 +- packages/rest/src/runtime/decorators.mts | 34 +- packages/rest/src/runtime/router.mts | 1 - .../rest/src/transform/controller-meta.ts | 63 +++ packages/rest/src/transform/lib.ts | 30 +- .../rest/src/transform/openapi-generator.ts | 2 - .../openapi-merge/component-equivalence.ts | 108 +++++ .../rest/src/transform/openapi-merge/data.ts | 148 ++++++ .../src/transform/openapi-merge/dispute.ts | 35 ++ .../src/transform/openapi-merge/extensions.ts | 51 ++ .../rest/src/transform/openapi-merge/index.ts | 56 +++ .../rest/src/transform/openapi-merge/info.ts | 39 ++ .../openapi-merge/operation-selection.ts | 84 ++++ .../openapi-merge/paths-and-components.ts | 434 ++++++++++++++++++ .../openapi-merge/reference-walker.ts | 315 +++++++++++++ .../rest/src/transform/openapi-merge/tags.ts | 39 ++ packages/rest/src/transform/project.ts | 1 + packages/rest/src/transform/transform.ts | 26 +- .../controller-method-transformer.ts | 4 +- .../transformers/file-transformer.ts | 21 +- .../processors/chain-route-processor.ts | 50 +- .../processors/controller-processor.ts | 11 +- packages/test/__tests__/src/test.spec.ts | 1 - packages/test/src/controller.ts | 25 +- packages/test/src/controller2.ts | 8 + pnpm-lock.yaml | 53 ++- 29 files changed, 1584 insertions(+), 79 deletions(-) create mode 100644 packages/rest/src/transform/openapi-merge/component-equivalence.ts create mode 100644 packages/rest/src/transform/openapi-merge/data.ts create mode 100644 packages/rest/src/transform/openapi-merge/dispute.ts create mode 100644 packages/rest/src/transform/openapi-merge/extensions.ts create mode 100644 packages/rest/src/transform/openapi-merge/index.ts create mode 100644 packages/rest/src/transform/openapi-merge/info.ts create mode 100644 packages/rest/src/transform/openapi-merge/operation-selection.ts create mode 100644 packages/rest/src/transform/openapi-merge/paths-and-components.ts create mode 100644 packages/rest/src/transform/openapi-merge/reference-walker.ts create mode 100644 packages/rest/src/transform/openapi-merge/tags.ts diff --git a/eslint.config.js b/eslint.config.js index 4db1c0f..f0d7b41 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,21 +18,16 @@ const test = { extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:unicorn/recommended", "plugin:jest/recommended", - "plugin:jest/style", "plugin:workspaces/recommended", "plugin:eslint-comments/recommended", - "plugin:sonarjs/recommended", ], parser: "@typescript-eslint/parser", plugins: [ "@typescript-eslint", "eslint-plugin-workspaces", - "eslint-plugin-unicorn", "eslint-plugin-jest", "eslint-plugin-eslint-comments", - "eslint-plugin-sonarjs", "eslint-plugin-no-secrets", ], root: true, @@ -44,15 +39,8 @@ const test = { ], rules: { // Reduce is confusing, but it shouldn't be banned - "unicorn/no-array-reduce": ["off"], - "unicorn/filename-case": ["error", { - case: "kebabCase", - }], - "unicorn/no-empty-files": ["off"], "workspaces/require-dependency": ["off"], - "unicorn/prevent-abbreviations": ["off"], "no-secrets/no-secrets": ["warn", {"tolerance": 5.0}], - "unicorn/numeric-separators-style": ["off"], "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", @@ -72,8 +60,7 @@ const test = { "match": false } }, - ], - "sonarjs/no-duplicate-string": ["off"], + ] }, }; diff --git a/package.json b/package.json index 8776bca..716d7d3 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-jest": "^27.2.3", "eslint-plugin-no-secrets": "^0.8.9", - "eslint-plugin-sonarjs": "^0.19.0", - "eslint-plugin-unicorn": "^48.0.0", "eslint-plugin-workspaces": "^0.9.0", "husky": "^8.0.3", "jest": "^29.5.0", diff --git a/packages/rest/__tests__/src/routing.spec.mts b/packages/rest/__tests__/src/routing.spec.mts index bb18976..d5addc6 100644 --- a/packages/rest/__tests__/src/routing.spec.mts +++ b/packages/rest/__tests__/src/routing.spec.mts @@ -87,7 +87,6 @@ class TestController { statusCode: HttpStatusCode.Ok, body: `cool`, headers: { - // eslint-disable-next-line sonarjs/no-duplicate-string "content-type": MimeType.TextPlain, }, })); diff --git a/packages/rest/package.json b/packages/rest/package.json index ffa4e7a..7927889 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -7,15 +7,19 @@ "@nrfcloud/ts-json-schema-transformer": "^1.2.4", "@types/aws-lambda": "^8.10.115", "ajv": "^8.12.0", + "atlassian-openapi": "^1.0.18", + "lodash": "^4.17.21", "openapi-types": "^12.1.0", "trouter": "^3.2.1", + "ts-is-present": "^1.2.2", "ts-json-schema-generator": "^1.4.0", - "ts-morph": "^19.0.0", + "ts-morph": "^20.0.0", "tsutils": "^3.21.0" }, "devDependencies": { "@jest/globals": "^29.5.0", "@types/jest": "^29.4.0", + "@types/lodash": "^4.14.202", "@types/node": "^18.15.11", "eslint": "^8.45.0", "jest": "^29.5.0", diff --git a/packages/rest/src/runtime/decorators.mts b/packages/rest/src/runtime/decorators.mts index 82fdc8a..01b9525 100644 --- a/packages/rest/src/runtime/decorators.mts +++ b/packages/rest/src/runtime/decorators.mts @@ -24,9 +24,11 @@ const routeChainDecorator = (_path: Path) + { + return routeChainDecorator as IfEquals; } /** @@ -34,8 +36,8 @@ export function GetChain(_path: string) { * * @originator nornir/rest */ -export function PostChain(_path: string) { - return routeChainDecorator; +export function PostChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -43,8 +45,8 @@ export function PostChain(_path: string) { * * @originator nornir/rest */ -export function PutChain(_path: string) { - return routeChainDecorator; +export function PutChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -52,8 +54,8 @@ export function PutChain(_path: string) { * * @originator nornir/rest */ -export function PatchChain(_path: string) { - return routeChainDecorator; +export function PatchChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -61,8 +63,8 @@ export function PatchChain(_path: string) { * * @originator nornir/rest */ -export function DeleteChain(_path: string) { - return routeChainDecorator; +export function DeleteChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -70,8 +72,8 @@ export function DeleteChain(_path: string) { * * @originator nornir/rest */ -export function HeadChain(_path: string) { - return routeChainDecorator; +export function HeadChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -79,8 +81,8 @@ export function HeadChain(_path: string) { * * @originator nornir/rest */ -export function OptionsChain(_path: string) { - return routeChainDecorator; +export function OptionsChain(_path: Path) { + return routeChainDecorator as IfEquals; } /** @@ -93,3 +95,7 @@ export function Provider() { throw UNTRANSFORMED_ERROR } } + +type Exact = IfEquals; +type IfEquals = (() => G extends T ? 1 : 2) extends (() => G extends U ? 1 : 2) ? Y + : N; diff --git a/packages/rest/src/runtime/router.mts b/packages/rest/src/runtime/router.mts index 0164499..20b2331 100644 --- a/packages/rest/src/runtime/router.mts +++ b/packages/rest/src/runtime/router.mts @@ -63,7 +63,6 @@ export class Router { return async (event, registry): Promise => { - // eslint-disable-next-line unicorn/no-array-method-this-argument, unicorn/no-array-callback-reference const {params, handlers: [handler]} = this.router.find(event.method, event.path); const request: HttpRequest = { ...event, diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index 0cba723..4629b4a 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -1,7 +1,36 @@ +import { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; import ts from "typescript"; +import { isErrorResult, merge, MergeInput } from "./openapi-merge"; import { Project } from "./project"; import { FileTransformer } from "./transformers/file-transformer"; +export abstract class OpenApiSpecHolder { + private static specFileMap = new Map(); + + public static addSpecForFile(file: ts.SourceFile, spec: OpenAPIV3_1.Document) { + const fileSpecs = this.specFileMap.get(file.fileName) || []; + fileSpecs.push(spec); + this.specFileMap.set(file.fileName, fileSpecs); + } + + public static getSpecForFile(file: ts.SourceFile): OpenAPIV3_1.Document { + const mergeInputs: MergeInput = (this.specFileMap.get(file.fileName) || []) + .map((spec) => ({ + oas: spec, + dispute: { + alwaysApply: true, + }, + })) as MergeInput; + + const merged = merge(mergeInputs); + if (isErrorResult(merged)) { + throw new Error(merged.message); + } + + return merged.output as OpenAPIV3_1.Document; + } +} + export class ControllerMeta { private static cache = new Map(); private static routes = new Map>(); @@ -174,6 +203,9 @@ export class ControllerMeta { input: ts.Type; output: ts.Type; filePath: string; + tags?: string[]; + deprecated?: boolean; + operationId?: string; }) { if (this.project.transformOnly) { return; @@ -186,6 +218,8 @@ export class ControllerMeta { throw new Error(`Route already registered: ${index.method} ${index.path}`); } + OpenApiSpecHolder.addSpecForFile(this.source, this.generateRouteSpec(routeInfo)); + methods.set(index.method, { method: routeInfo.method, path: this.basePath + routeInfo.path.toLowerCase(), @@ -197,6 +231,32 @@ export class ControllerMeta { }); } + private generateRouteSpec(route: RouteInfo): OpenAPIV3_1.Document { + return { + openapi: "3.0.3", + info: { + title: "Nornir API", + version: "1.0.0", + }, + paths: { + [this.basePath + route.path.toLowerCase()]: { + [route.method.toLowerCase()]: { + deprecated: route.deprecated, + tags: route.tags, + operationId: route.operationId, + summary: route.summary, + description: route.description, + responses: { + 200: { + description: "OK", + }, + }, + }, + }, + }, + } as OpenAPIV3_1.Document; + } + // private buildRequestInfo(routeIndex: RouteIndex, inputType: ts.Type): RequestInfo { // const paramterData: { [key in ParameterType]: { [name: string]: ParameterMeta } } = { // path: {}, @@ -385,6 +445,9 @@ export interface RouteInfo { path: string; description?: string; summary?: string; + deprecated?: boolean; + operationId?: string; + tags?: string[]; // requestInfo: RequestInfo; // responseInfo: ResponseInfo; filePath: string; diff --git a/packages/rest/src/transform/lib.ts b/packages/rest/src/transform/lib.ts index 9f0814f..7dac363 100644 --- a/packages/rest/src/transform/lib.ts +++ b/packages/rest/src/transform/lib.ts @@ -29,7 +29,8 @@ export function getStringLiteralOrConst(project: Project, node: ts.Expression): export interface NornirDecoratorInfo { decorator: ts.Decorator; - signature: ts.Signature; + symbol: ts.Symbol; + // signature: ts.Signature; declaration: ts.Declaration; } @@ -42,19 +43,34 @@ export function separateNornirDecorators( } { const nornirDecorators: { decorator: ts.Decorator; - signature: ts.Signature; + symbol: ts.Symbol; + // signature: ts.Signature; declaration: ts.Declaration; }[] = []; const decorators: ts.Decorator[] = []; for (const decorator of originalDecorators) { - const signature = project.checker.getResolvedSignature(decorator); - const parentDeclaration = signature?.getDeclaration()?.parent; - if (parentDeclaration && signature && signature.declaration && isNornirRestNode(parentDeclaration)) { + const identifier = ts.isIdentifier(decorator.expression) + ? decorator.expression + : ts.isCallExpression(decorator.expression) + ? decorator.expression.expression as ts.Identifier + : undefined; + if (!identifier) continue; + const identifierSymbol = project.checker.getSymbolAtLocation(identifier); + if (!identifierSymbol) continue; + const symbol = project.checker.getAliasedSymbol(identifierSymbol); + const declaration = symbol?.declarations?.[0]; + if (!declaration) continue; + + // const signature = project.checker.getResolvedSignature(decorator); + // + // const parentDeclaration = signature?.getDeclaration()?.parent; + if (isNornirRestNode(declaration)) { nornirDecorators.push({ decorator, - signature, - declaration: signature.declaration, + symbol, + // signature, + declaration, }); } else { decorators.push(decorator); diff --git a/packages/rest/src/transform/openapi-generator.ts b/packages/rest/src/transform/openapi-generator.ts index 3a1aeef..5a29ac4 100644 --- a/packages/rest/src/transform/openapi-generator.ts +++ b/packages/rest/src/transform/openapi-generator.ts @@ -1,5 +1,3 @@ -/* eslint-disable eslint-comments/disable-enable-pair,unicorn/no-empty-file */ - // import type { OpenAPIV3 } from "openapi-types"; // import { Metadata } from "typia/lib/metadata/Metadata"; // import { ApplicationProgrammer } from "typia/lib/programmers/ApplicationProgrammer"; diff --git a/packages/rest/src/transform/openapi-merge/component-equivalence.ts b/packages/rest/src/transform/openapi-merge/component-equivalence.ts new file mode 100644 index 0000000..1a613f8 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/component-equivalence.ts @@ -0,0 +1,108 @@ +import { Swagger, SwaggerLookup as Lookup, SwaggerTypeChecks as TC } from "atlassian-openapi"; +import _ from "lodash"; +import { Modify } from "./reference-walker"; + +export type ReferenceWalker = (component: A, modify: Modify) => void; + +function referenceCount(walker: ReferenceWalker, component: A): number { + let count = 0; + walker(component, ref => { + count++; + return ref; + }); + return count; +} + +export function shallowEquality( + referenceWalker: ReferenceWalker, +): (x: A | Swagger.Reference, y: A | Swagger.Reference) => boolean { + return (x: A | Swagger.Reference, y: A | Swagger.Reference): boolean => { + if (!_.isEqual(x, y)) { + return false; + } + + if (TC.isReference(x)) { + return false; + } + + return referenceCount(referenceWalker, x) === 0; + }; +} + +function isSchemaOrThrowError(ref: Swagger.Reference, s: Swagger.Schema | undefined): Swagger.Schema { + if (s === undefined) { + throw new Error(`Could not resolve reference: ${ref.$ref}`); + } + return s; +} + +function arraysEquivalent(leftOriginal: Array, rightOriginal: Array): boolean { + if (leftOriginal.length !== rightOriginal.length) { + return false; + } + + const left = [...leftOriginal].sort(); + const right = [...rightOriginal].sort(); + + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +} + +// The idea is that, if you have made this comparison before, then don't do it again, just return true becauese you have a cycle +type SeenResult = "seen-before" | "new"; + +class ReferenceRecord { + private leftRightSeen: { [leftRef: string]: { [rightRef: string]: boolean } } = {}; + + public checkAndStore(left: Swagger.Reference, right: Swagger.Reference): SeenResult { + if (this.leftRightSeen[left.$ref] === undefined) { + this.leftRightSeen[left.$ref] = {}; + } + + const leftLookup = this.leftRightSeen[left.$ref]; + + const result: SeenResult = leftLookup[right.$ref] === true ? "seen-before" : "new"; + leftLookup[right.$ref] = true; + return result; + } +} + +export function deepEquality( + xLookup: Lookup.Lookup, + yLookup: Lookup.Lookup, +): (x: A | Swagger.Reference, y: A | Swagger.Reference) => boolean { + const refRecord = new ReferenceRecord(); + + function compare(x: T | Swagger.Reference, y: T | Swagger.Reference): boolean { + // If both are references then look up the references and compare them for equality + if (TC.isReference(x) && TC.isReference(y)) { + if (refRecord.checkAndStore(x, y) === "seen-before") { + return true; + } + + const xResult = isSchemaOrThrowError(x, xLookup.getSchema(x)); + const yResult = isSchemaOrThrowError(y, yLookup.getSchema(y)); + return compare(xResult, yResult); + } else if (TC.isReference(x) || TC.isReference(y)) { + return false; + } else if (typeof x === "object" && typeof y === "object") { + // If both are objects then they should have all of the same keys and the values of those keys should match + if (!arraysEquivalent(Object.keys(x as object), Object.keys(y as object))) { + return false; + } + + const xKeys = Object.keys(x as object) as Array; + return xKeys.every(key => compare(x?.[key], y?.[key])); + } + + // If they are not objects or references then you can just run a direct equality + return _.isEqual(x, y); + } + + return compare; +} diff --git a/packages/rest/src/transform/openapi-merge/data.ts b/packages/rest/src/transform/openapi-merge/data.ts new file mode 100644 index 0000000..91cb2ae --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/data.ts @@ -0,0 +1,148 @@ +import { Swagger } from "atlassian-openapi"; + +export type OperationSelection = { + /** + * Only Operations that have these tags will be taken from this OpenAPI file. If a single Operation contains + * an includeTag and an excludeTag then it will be excluded; exclusion takes precedence. + */ + includeTags?: string[]; + + /** + * Any Operation that has any one of these tags will be excluded from the final result. If a single Operation contains + * an includeTag and an excludeTag then it will be excluded; exclusion takes precedence. + */ + excludeTags?: string[]; +}; + +export interface DisputeBase { + /** + * If this is set to true, then this prefix will always be applied to every Schema, even if there is no dispute + * for that particular schema. This may prevent the deduplication of common schemas from different OpenApi files. + */ + alwaysApply?: boolean; + /** + * If this is set to true, then this well deep merge components, bringing all keys and values from identically + * named components in to the one object + */ + mergeDispute?: boolean; +} + +export interface DisputePrefix extends DisputeBase { + /** + * The prefix to use when a schema is in dispute. + */ + prefix: string; +} + +export interface DisputeSuffix extends DisputeBase { + /** + * The suffix to use when a schema is in dispute. + */ + suffix: string; +} + +export type Dispute = DisputePrefix | DisputeSuffix; + +export interface SingleMergeInputBase { + oas: Swagger.SwaggerV3; + + pathModification?: PathModification; + + /** + * Any Operation tagged with one of the paths in this definition will be excluded from the merge result. Any tag + * mentioned in this list will also be excluded from the top level list of tags. + */ + operationSelection?: OperationSelection; + + /** + * This configuration setting lets you configure how the info.description from this OpenAPI file will be merged + * into the final resulting OpenAPI file + */ + description?: DescriptionMergeBehaviour; +} + +/** + * The original SingelMergeInput, now deprecated. This is included for backwards compatibility, to prevent a breaking + * change and should be removed in the next major version. + * + * @deprecated + */ +export interface SingleMergeInputV1 extends SingleMergeInputBase { + /** + * The prefix to use in the event of a dispute. + * + * @deprecated + */ + disputePrefix?: string; +} + +/** + * The current expected format of the SingleMergeInput. + */ +export interface SingleMergeInputV2 extends SingleMergeInputBase { + /** + * This dictates how any disputes will be resolved between similar elements across multiple OpenAPI files. + */ + dispute?: Dispute; + /** + * When set to false, allows operation IDs to be non-uniqiue. Default behaviour is to force a unique suffix unless + * specifically set + */ + uniqueOperations?: boolean; +} + +export type SingleMergeInput = SingleMergeInputV1 | SingleMergeInputV2; + +export type PathModification = { + stripStart?: string; + prepend?: string; +}; + +export type DescriptionMergeBehaviour = { + /** + * Wether or not the description for this OpenAPI file will be merged into the description of the final file. + */ + append: boolean; + + /** + * You may optionally include a Markdown Title to demarcate this particular section of the merged description files. + */ + title?: DescriptionTitle; +}; + +export type DescriptionTitle = { + /** + * The value of the included title. + * + * @minLength 1 + */ + value: string; + + /** + * What heading level this heading will be at: from h1 through to h6. The default value is 1 and will create h1 elements + * in Markdown format. + * + * @minimum 1 + * @maximum 6 + */ + headingLevel?: number; +}; + +export type MergeInput = Array; + +export type SuccessfulMergeResult = { + output: Swagger.SwaggerV3; +}; + +export type ErrorType = "no-inputs" | "duplicate-paths" | "component-definition-conflict" | "operation-id-conflict"; + +export type ErrorMergeResult = { + type: ErrorType; + message: string; +}; + +export function isErrorResult(t: object): t is ErrorMergeResult { + return "type" in t && "message" in t; +} + +export type MergeResult = SuccessfulMergeResult | ErrorMergeResult; diff --git a/packages/rest/src/transform/openapi-merge/dispute.ts b/packages/rest/src/transform/openapi-merge/dispute.ts new file mode 100644 index 0000000..0df94e6 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/dispute.ts @@ -0,0 +1,35 @@ +import { Dispute, DisputePrefix, SingleMergeInput } from "./data"; + +export function getDispute(input: SingleMergeInput): Dispute | undefined { + if ("disputePrefix" in input) { + if (input.disputePrefix !== undefined) { + return { + prefix: input.disputePrefix, + }; + } + + return undefined; + } else if ("dispute" in input) { + return input.dispute; + } + + return undefined; +} + +export type DisputeStatus = "disputed" | "undisputed"; + +function isDisputePrefix(dispute: Dispute): dispute is DisputePrefix { + return "prefix" in dispute; +} + +export function applyDispute(dispute: Dispute | undefined, input: string, status: DisputeStatus): string { + if (dispute === undefined) { + return input; + } + + if ((status === "disputed" && !dispute.mergeDispute) || dispute.alwaysApply) { + return isDisputePrefix(dispute) ? `${dispute.prefix}${input}` : `${input}${dispute.suffix}`; + } + + return input; +} diff --git a/packages/rest/src/transform/openapi-merge/extensions.ts b/packages/rest/src/transform/openapi-merge/extensions.ts new file mode 100644 index 0000000..015deaf --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/extensions.ts @@ -0,0 +1,51 @@ +import { Swagger } from "atlassian-openapi"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type Extensions = { [extensionKey: string]: any }; + +function extractExtensions(input: Swagger.SwaggerV3): Extensions { + const result: Extensions = {}; + + const plainObject: Extensions = input; + + for (const key in plainObject) { + /* eslint-disable-next-line no-prototype-builtins */ + if (key.startsWith("x-") && plainObject.hasOwnProperty(key)) { + result[key] = plainObject[key]; + } + } + + return result; +} + +function mergeExtensionsHelper(extensions: Extensions[]): Extensions { + if (extensions.length === 0) { + return {}; + } + + if (extensions.length === 1) { + return extensions[0]; + } + + const result = { ...extensions[0] }; + + for (let extensionIndex = 1; extensionIndex < extensions.length; extensionIndex++) { + const ext = extensions[extensionIndex]; + + for (const extensionKey in ext) { + /* eslint-disable-next-line no-prototype-builtins */ + if (result[extensionKey] === undefined && ext.hasOwnProperty(extensionKey)) { + result[extensionKey] = ext[extensionKey]; + } + } + } + + return result; +} + +export function mergeExtensions(mergeTarget: Swagger.SwaggerV3, oass: Swagger.SwaggerV3[]): Swagger.SwaggerV3 { + return { + ...mergeTarget, + ...mergeExtensionsHelper([extractExtensions(mergeTarget), ...oass.map(extractExtensions)]), + }; +} diff --git a/packages/rest/src/transform/openapi-merge/index.ts b/packages/rest/src/transform/openapi-merge/index.ts new file mode 100644 index 0000000..afb11c2 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/index.ts @@ -0,0 +1,56 @@ +import { Swagger } from "atlassian-openapi"; +import { isPresent } from "ts-is-present"; +import { isErrorResult, MergeInput, MergeResult, OperationSelection, PathModification } from "./data"; +import { mergeExtensions } from "./extensions"; +import { mergeInfos } from "./info"; +import { mergePathsAndComponents } from "./paths-and-components"; +import { mergeTags } from "./tags"; + +export { isErrorResult, MergeInput, MergeResult, OperationSelection, PathModification }; + +function getFirst(inputs: Array): A | undefined { + if (inputs.length > 0) { + return inputs[0]; + } + + return undefined; +} + +function getFirstMatching(inputs: Array, extract: (input: A) => B | undefined): B | undefined { + return getFirst(inputs.map(extract).filter(isPresent)); +} + +/** + * Swagger Merge Tool + */ +export function merge(inputs: MergeInput): MergeResult { + if (inputs.length === 0) { + return { type: "no-inputs", message: "You must provide at least one OAS file as an input." }; + } + + const pathAndComponentResult = mergePathsAndComponents(inputs); + + if (isErrorResult(pathAndComponentResult)) { + return pathAndComponentResult; + } + + const { paths, components: retComponents } = pathAndComponentResult; + + const components = Object.keys(retComponents).length === 0 ? undefined : retComponents; + + const output: Swagger.SwaggerV3 = mergeExtensions( + { + openapi: "3.0.3", + info: mergeInfos(inputs), + servers: getFirstMatching(inputs, input => input.oas.servers), + externalDocs: getFirstMatching(inputs, input => input.oas.externalDocs), + security: getFirstMatching(inputs, input => input.oas.security), + tags: mergeTags(inputs), + paths, + components, + }, + inputs.map(input => input.oas), + ); + + return { output }; +} diff --git a/packages/rest/src/transform/openapi-merge/info.ts b/packages/rest/src/transform/openapi-merge/info.ts new file mode 100644 index 0000000..fa932a5 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/info.ts @@ -0,0 +1,39 @@ +import { Swagger } from "atlassian-openapi"; +import _ from "lodash"; +import { isPresent } from "ts-is-present"; +import { MergeInput, SingleMergeInput } from "./data"; + +function getInfoDescriptionWithHeading(mergeInput: SingleMergeInput): string | undefined { + const { description } = mergeInput.oas.info; + + if (description === undefined) { + return undefined; + } + + const trimmedDescription = description.trimRight(); + + if (mergeInput.description === undefined || mergeInput.description.title === undefined) { + return trimmedDescription; + } + + const { title } = mergeInput.description; + + const headingLevel = title.headingLevel || 1; + + return `${"#".repeat(headingLevel)} ${title.value}\n\n${trimmedDescription}`; +} + +export function mergeInfos(mergeInput: MergeInput): Swagger.Info { + const finalInfo = _.cloneDeep(mergeInput[0].oas.info); + + const appendedDescriptions = mergeInput + .filter(i => i.description && i.description.append) + .map(getInfoDescriptionWithHeading) + .filter(isPresent); + + if (appendedDescriptions.length > 0) { + finalInfo.description = appendedDescriptions.join("\n\n"); + } + + return finalInfo; +} diff --git a/packages/rest/src/transform/openapi-merge/operation-selection.ts b/packages/rest/src/transform/openapi-merge/operation-selection.ts new file mode 100644 index 0000000..f1bd508 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/operation-selection.ts @@ -0,0 +1,84 @@ +import { Swagger } from "atlassian-openapi"; +import _ from "lodash"; +import { OperationSelection } from "./data"; + +const allMethods: Swagger.Method[] = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +]; + +function operationContainsAnyTag(operation: Swagger.Operation, tags: string[]): boolean { + return operation.tags !== undefined && operation.tags.some(tag => tags.includes(tag)); +} + +function dropOperationsThatHaveTags(originalOas: Swagger.SwaggerV3, excludedTags: string[]): Swagger.SwaggerV3 { + if (excludedTags.length === 0) { + return originalOas; + } + + const oas = _.cloneDeep(originalOas); + + for (const path in oas.paths) { + /* eslint-disable-next-line no-prototype-builtins */ + if (oas.paths.hasOwnProperty(path)) { + const pathItem = oas.paths[path]; + + for (let i = 0; i < allMethods.length; i++) { + const method = allMethods[i]; + const operation = pathItem[method]; + + if (operation !== undefined && operationContainsAnyTag(operation, excludedTags)) { + delete pathItem[method]; + } + } + } + } + + return oas; +} + +function includeOperationsThatHaveTags(originalOas: Swagger.SwaggerV3, includeTags: string[]): Swagger.SwaggerV3 { + if (includeTags.length === 0) { + return originalOas; + } + + const oas = _.cloneDeep(originalOas); + + for (const path in oas.paths) { + /* eslint-disable-next-line no-prototype-builtins */ + if (oas.paths.hasOwnProperty(path)) { + const pathItem = oas.paths[path]; + + for (let i = 0; i < allMethods.length; i++) { + const method = allMethods[i]; + const operation = pathItem[method]; + + if (operation !== undefined && !operationContainsAnyTag(operation, includeTags)) { + delete pathItem[method]; + } + } + } + } + + return oas; +} + +export function runOperationSelection( + originalOas: Swagger.SwaggerV3, + operationSelection: OperationSelection | undefined, +): Swagger.SwaggerV3 { + if (operationSelection === undefined) { + return originalOas; + } + + return dropOperationsThatHaveTags( + includeOperationsThatHaveTags(originalOas, operationSelection.includeTags || []), + operationSelection.excludeTags || [], + ); +} diff --git a/packages/rest/src/transform/openapi-merge/paths-and-components.ts b/packages/rest/src/transform/openapi-merge/paths-and-components.ts new file mode 100644 index 0000000..b69ba7c --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/paths-and-components.ts @@ -0,0 +1,434 @@ +import { Swagger, SwaggerLookup } from "atlassian-openapi"; +import _ from "lodash"; +import { deepEquality } from "./component-equivalence"; +import { Dispute, ErrorMergeResult, MergeInput } from "./data"; +import { applyDispute, getDispute } from "./dispute"; +import { runOperationSelection } from "./operation-selection"; +import { walkAllReferences } from "./reference-walker"; + +export type PathAndComponents = { + paths: Swagger.Paths; + components: Swagger.Components; +}; + +function removeFromStart(input: string, trim: string): string { + if (input.startsWith(trim)) { + return input.substring(trim.length); + } + + return input; +} + +type Components = { [key: string]: A }; +type Equal = (x: A, y: A) => boolean; +type AddModRef = (from: string, to: string) => void; + +function processComponents( + results: Components, + components: Components, + areEqual: Equal, + dispute: Dispute | undefined, + addModifiedReference: AddModRef, +): ErrorMergeResult | undefined { + for (const key in components) { + /* eslint-disable-next-line no-prototype-builtins */ + if (components.hasOwnProperty(key)) { + const component = components[key]; + + const modifiedKey = applyDispute(dispute, key, "undisputed"); + if (modifiedKey !== key) { + addModifiedReference(key, modifiedKey); + } + + if (results[modifiedKey] === undefined || areEqual(results[modifiedKey], component)) { + // Add the schema + results[modifiedKey] = component; + } else { + // Distnguish the name and then add the element + let schemaPlaced = false; + + // Try and use the dispute prefix first + if (dispute !== undefined) { + const preferredSchemaKey = applyDispute(dispute, key, "disputed"); + if (results[preferredSchemaKey] === undefined || areEqual(results[preferredSchemaKey], component)) { + results[preferredSchemaKey] = component; + addModifiedReference(key, preferredSchemaKey); + schemaPlaced = true; + } // Merge deeply if the flag is set in the dispute object + else if (dispute.mergeDispute && Object.keys(results).includes(preferredSchemaKey)) { + results[preferredSchemaKey] = _.merge(results[preferredSchemaKey], component); + addModifiedReference(key, preferredSchemaKey); + schemaPlaced = true; + } + } + + // Incrementally find the right prefix + for (let antiConflict = 1; schemaPlaced === false && antiConflict < 1000; antiConflict++) { + const trySchemaKey = `${key}${antiConflict}`; + + if (results[trySchemaKey] === undefined) { + results[trySchemaKey] = component; + addModifiedReference(key, trySchemaKey); + schemaPlaced = true; + } + } + + // In the unlikely event that we can't find a duplicate, return an error + if (schemaPlaced === false) { + return { + type: "component-definition-conflict", + message: `The "${key}" definition had a duplicate in a previous input and could not be deduplicated.`, + }; + } + } + } + } +} + +function countOperationsInPathItem(pathItem: Swagger.PathItem): number { + let count = 0; + count += pathItem.get !== undefined ? 1 : 0; + count += pathItem.put !== undefined ? 1 : 0; + count += pathItem.post !== undefined ? 1 : 0; + count += pathItem.delete !== undefined ? 1 : 0; + count += pathItem.options !== undefined ? 1 : 0; + count += pathItem.head !== undefined ? 1 : 0; + count += pathItem.patch !== undefined ? 1 : 0; + count += pathItem.trace !== undefined ? 1 : 0; + return count; +} + +function dropPathItemsWithNoOperations(originalOas: Swagger.SwaggerV3): Swagger.SwaggerV3 { + const oas = _.cloneDeep(originalOas); + + for (const path in oas.paths) { + /* eslint-disable-next-line no-prototype-builtins */ + if (oas.paths.hasOwnProperty(path)) { + const pathItem = oas.paths[path]; + + if (countOperationsInPathItem(pathItem) === 0) { + delete oas.paths[path]; + } + } + } + + return oas; +} + +function findUniqueOperationId( + operationId: string, + seenOperationIds: Set, + dispute: Dispute | undefined, +): string | ErrorMergeResult { + if (!seenOperationIds.has(operationId)) { + return operationId; + } + + // Try the dispute prefix + if (dispute !== undefined) { + const disputeOpId = applyDispute(dispute, operationId, "disputed"); + if (!seenOperationIds.has(disputeOpId)) { + return disputeOpId; + } + } + + // Incrementally find the right prefix + for (let antiConflict = 1; antiConflict < 1000; antiConflict++) { + const tryOpId = `${operationId}${antiConflict}`; + if (!seenOperationIds.has(tryOpId)) { + return tryOpId; + } + } + + // Fail with an error + return { + type: "operation-id-conflict", + message: `Could not resolve a conflict for the operationId '${operationId}'`, + }; +} + +function ensureUniqueOperationId( + operation: Swagger.Operation, + seenOperationIds: Set, + dispute: Dispute | undefined, +): ErrorMergeResult | undefined { + if (operation.operationId !== undefined) { + const opId = findUniqueOperationId(operation.operationId, seenOperationIds, dispute); + if (typeof opId === "string") { + operation.operationId = opId; + seenOperationIds.add(opId); + } else { + return opId; + } + } +} + +function ensureUniqueOperationIds( + pathItem: Swagger.PathItem, + seenOperationIds: Set, + dispute: Dispute | undefined, +): ErrorMergeResult | undefined { + const operations = [ + pathItem.get, + pathItem.put, + pathItem.post, + pathItem.delete, + pathItem.patch, + pathItem.head, + pathItem.trace, + pathItem.options, + ]; + + for (let opIndex = 0; opIndex < operations.length; opIndex++) { + const operation = operations[opIndex]; + + if (operation !== undefined) { + const result = ensureUniqueOperationId(operation, seenOperationIds, dispute); + if (result !== undefined) { + return result; + } + } + } +} + +/** + * Merge algorithm: + * + * Generate reference mappings for the components. Eliminating duplicates. + * Generate reference mappings for the paths. + * Copy the elements into the new location. + * Update all of the paths and components to the new references. + * + * @param inputs + */ +export function mergePathsAndComponents(inputs: MergeInput): PathAndComponents | ErrorMergeResult { + const seenOperationIds = new Set(); + + const result: PathAndComponents = { + paths: {}, + components: {}, + }; + + for (let inputIndex = 0; inputIndex < inputs.length; inputIndex++) { + const input = inputs[inputIndex]; + + const { oas: originalOas, pathModification, operationSelection } = input; + const dispute = getDispute(input); + + const oas = dropPathItemsWithNoOperations(runOperationSelection(_.cloneDeep(originalOas), operationSelection)); + + // Original references will be transformed to new non-conflicting references + const referenceModification: { [originalReference: string]: string } = {}; + + // For each component in the original input, place it in the output with deduplicate taking place + if (oas.components !== undefined) { + const resultLookup = new SwaggerLookup.InternalLookup({ + openapi: "3.0.1", + info: { title: "dummy", version: "0" }, + paths: {}, + components: result.components, + }); + const currentLookup = new SwaggerLookup.InternalLookup(oas); + if (oas.components.schemas !== undefined) { + result.components.schemas = result.components.schemas || {}; + + processComponents( + result.components.schemas, + oas.components.schemas, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/schemas/${from}`] = `#/components/schemas/${to}`; + }, + ); + } + + if (oas.components.responses !== undefined) { + result.components.responses = result.components.responses || {}; + + processComponents( + result.components.responses, + oas.components.responses, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/responses/${from}`] = `#/components/responses/${to}`; + }, + ); + } + + if (oas.components.parameters !== undefined) { + result.components.parameters = result.components.parameters || {}; + + processComponents( + result.components.parameters, + oas.components.parameters, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/parameters/${from}`] = `#/components/parameters/${to}`; + }, + ); + } + + // examples + if (oas.components.examples !== undefined) { + result.components.examples = result.components.examples || {}; + + processComponents( + result.components.examples, + oas.components.examples, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/examples/${from}`] = `#/components/examples/${to}`; + }, + ); + } + + // requestBodies + if (oas.components.requestBodies !== undefined) { + result.components.requestBodies = result.components.requestBodies || {}; + + processComponents( + result.components.requestBodies, + oas.components.requestBodies, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/requestBodies/${from}`] = `#/components/requestBodies/${to}`; + }, + ); + } + + // headers + if (oas.components.headers !== undefined) { + result.components.headers = result.components.headers || {}; + + processComponents( + result.components.headers, + oas.components.headers, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/headers/${from}`] = `#/components/headers/${to}`; + }, + ); + } + + // security schemes + if (oas.components.securitySchemes !== undefined) { + result.components.securitySchemes = result.components.securitySchemes || {}; + + processComponents( + result.components.securitySchemes, + oas.components.securitySchemes, + deepEquality(resultLookup, currentLookup), + { prefix: "", mergeDispute: true, ...dispute }, // security should always be merged + (from: string, to: string) => { + referenceModification[`#/components/securitySchemes/${from}`] = `#/components/securitySchemes/${to}`; + }, + ); + } + + // links + if (oas.components.links !== undefined) { + result.components.links = result.components.links || {}; + + processComponents( + result.components.links, + oas.components.links, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/links/${from}`] = `#/components/links/${to}`; + }, + ); + } + + // callbacks + if (oas.components.callbacks !== undefined) { + result.components.callbacks = result.components.callbacks || {}; + + processComponents( + result.components.callbacks, + oas.components.callbacks, + deepEquality(resultLookup, currentLookup), + dispute, + (from: string, to: string) => { + referenceModification[`#/components/callbacks/${from}`] = `#/components/callbacks/${to}`; + }, + ); + } + } + + // For each path, convert it into the right format (looking out for duplicates) + const paths = Object.keys(oas.paths || {}); + + for (let pathIndex = 0; pathIndex < paths.length; pathIndex++) { + const originalPath = paths[pathIndex]; + + const newPath = pathModification === undefined + ? originalPath + : `${pathModification.prepend || ""}${removeFromStart(originalPath, pathModification.stripStart || "")}`; + + if (originalPath !== newPath) { + referenceModification[`#/paths/${originalPath}`] = `#/paths/${newPath}`; + } + + if (result.paths[newPath] !== undefined) { + const operations = ["get", "put", "post", "delete", "options", "head", "patch", "trace"]; + const newMethods = Object.keys(input.oas.paths[originalPath]).filter((method) => + Object.values(operations).includes(method.toLowerCase()) + ); + try { + newMethods.forEach((method) => { + const pathMethod = method.toLowerCase() as Swagger.Method; + if (result.paths[newPath][pathMethod]) { + throw { + type: "duplicate-paths", + message: + `Input ${inputIndex}: The method '${originalPath}:${pathMethod}' is already mapped to '${newPath}:${pathMethod}' and has already been added by another input file`, + }; + } else { + const copyPathItem: Swagger.PathItem = { [method]: oas.paths[originalPath][pathMethod] }; + if (!("uniqueOperations" in input && input.uniqueOperations === false)) { + ensureUniqueOperationIds(copyPathItem, seenOperationIds, dispute); + } + result.paths[newPath][pathMethod] = copyPathItem[pathMethod]; + } + }); + } catch (err) { + return err as ErrorMergeResult; + } + } else { + const copyPathItem = oas.paths[originalPath]; + if (!("uniqueOperations" in input && input.uniqueOperations === false)) { + ensureUniqueOperationIds(copyPathItem, seenOperationIds, dispute); + } + + result.paths[newPath] = copyPathItem; + } + } + + // Update the references to point to the right location + const modifiedKeys = Object.keys(referenceModification); + walkAllReferences(oas, (ref) => { + if (referenceModification[ref] !== undefined) { + return referenceModification[ref]; + } + + const matchingKeys = modifiedKeys.filter((key) => key.startsWith(`${ref}/`)); + + if (matchingKeys.length > 1) { + throw new Error(`Found more than one matching key for reference '${ref}': ${JSON.stringify(matchingKeys)}`); + } else if (matchingKeys.length === 1) { + return referenceModification[matchingKeys[0]]; + } + + return ref; + }); + } + + return result; +} diff --git a/packages/rest/src/transform/openapi-merge/reference-walker.ts b/packages/rest/src/transform/openapi-merge/reference-walker.ts new file mode 100644 index 0000000..1d3ddc1 --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/reference-walker.ts @@ -0,0 +1,315 @@ +import { Swagger, SwaggerTypeChecks as TC } from "atlassian-openapi"; + +export type Modify = (input: string) => string; + +export function walkSchemaReferences(schema: Swagger.Schema | Swagger.Reference, modify: Modify): void { + if (TC.isReference(schema)) { + schema.$ref = modify(schema.$ref); + } else { + if (schema.not !== undefined) walkSchemaReferences(schema.not, modify); + + if (schema.allOf !== undefined) { + for (const childSchema of schema.allOf) { + walkSchemaReferences(childSchema, modify); + } + } + + if (schema.oneOf !== undefined) { + for (const childSchema of schema.oneOf) { + walkSchemaReferences(childSchema, modify); + } + } + + if (schema.anyOf !== undefined) { + for (const childSchema of schema.anyOf) { + walkSchemaReferences(childSchema, modify); + } + } + + if (schema.items !== undefined) { + walkSchemaReferences(schema.items, modify); + } + + for (const propertyKey in schema.properties) { + if (schema.properties.hasOwnProperty(propertyKey)) { + const property = schema.properties[propertyKey]; + walkSchemaReferences(property, modify); + } + } + + if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") { + walkSchemaReferences(schema.additionalProperties, modify); + } + } +} + +export function walkExampleReferences(example: Swagger.Example | Swagger.Reference, modify: Modify): void { + if (TC.isReference(example)) { + example.$ref = modify(example.$ref); + } +} + +function walkMediaTypeReferences(mediaType: Swagger.MediaType, modify: Modify): void { + if (mediaType.schema !== undefined) walkSchemaReferences(mediaType.schema, modify); + + if (TC.isMediaTypeWithExamples(mediaType)) { + if (mediaType.schema !== undefined) walkSchemaReferences(mediaType.schema, modify); + + for (const exampleKey of Object.keys(mediaType.examples)) { + const example = mediaType.examples[exampleKey]; + walkExampleReferences(example, modify); + } + } +} + +export function walkParameterReferences(parameterOrRef: Swagger.ParameterOrRef, modify: Modify): void { + if (TC.isReference(parameterOrRef)) { + parameterOrRef.$ref = modify(parameterOrRef.$ref); + } else if (TC.isParameterWithSchema(parameterOrRef)) { + walkSchemaReferences(parameterOrRef.schema, modify); + + if ("examples" in parameterOrRef) { + for (const exampleKey in parameterOrRef.examples) { + if (parameterOrRef.examples.hasOwnProperty(exampleKey)) { + const example = parameterOrRef.examples[exampleKey]; + walkExampleReferences(example, modify); + } + } + } + } else { + for (const contentKey in parameterOrRef.content) { + if (parameterOrRef.content.hasOwnProperty(contentKey)) { + const mediaType = parameterOrRef.content[contentKey]; + walkMediaTypeReferences(mediaType, modify); + } + } + } +} + +export function walkRequestBodyReferences(requestBody: Swagger.RequestBody | Swagger.Reference, modify: Modify): void { + if (TC.isReference(requestBody)) { + requestBody.$ref = modify(requestBody.$ref); + } else { + for (const contentKey in requestBody.content) { + if (requestBody.content.hasOwnProperty(contentKey)) { + const mediaType = requestBody.content[contentKey]; + walkMediaTypeReferences(mediaType, modify); + } + } + } +} + +export function walkHeaderReferences(header: Swagger.Header | Swagger.Reference, modify: Modify): void { + if (TC.isReference(header)) { + header.$ref = modify(header.$ref); + } else if (TC.isHeaderWithSchema(header)) { + if (header.schema !== undefined) walkSchemaReferences(header.schema, modify); + + if ("examples" in header) { + for (const exampleKey in header.examples) { + if (header.examples.hasOwnProperty(exampleKey)) { + const example = header.examples[exampleKey]; + walkExampleReferences(example, modify); + } + } + } + } else { + for (const contentKey in header.content) { + if (header.content.hasOwnProperty(contentKey)) { + const mediaType = header.content[contentKey]; + walkMediaTypeReferences(mediaType, modify); + } + } + } +} + +export function walkLinkReferences(link: Swagger.Link | Swagger.Reference, modify: Modify): void { + if (TC.isReference(link)) { + link.$ref = modify(link.$ref); + } else { + // TODO work out if there are any references in here that should be updated + } +} + +export function walkResponseReferences(response: Swagger.Response | Swagger.Reference, modify: Modify): void { + if (TC.isReference(response)) { + response.$ref = modify(response.$ref); + } else { + if (response.headers !== undefined) { + for (const headerKey of Object.keys(response.headers)) { + const headerOrRef = response.headers[headerKey]; + walkHeaderReferences(headerOrRef, modify); + } + } + + if (response.content !== undefined) { + const contentKeys = Object.keys(response.content); + for (let contentKeyIndex = 0; contentKeyIndex < contentKeys.length; contentKeyIndex++) { + const contentKey = contentKeys[contentKeyIndex]; + const mediaType = response.content[contentKey]; + walkMediaTypeReferences(mediaType, modify); + } + } + + if (response.links !== undefined) { + const linkKeys = Object.keys(response.links); + for (let linkKeyIndex = 0; linkKeyIndex < linkKeys.length; linkKeyIndex++) { + const linkKey = linkKeys[linkKeyIndex]; + const linkOrRef = response.links[linkKey]; + walkLinkReferences(linkOrRef, modify); + } + } + } +} + +export function walkCallbackReferences(callback: Swagger.Callback | Swagger.Reference, modify: Modify): void { + if (TC.isReference(callback)) { + callback.$ref = modify(callback.$ref); + } else { + for (const pathItemKey in callback) { + if (callback.hasOwnProperty(pathItemKey)) { + const pathItem = callback[pathItemKey]; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + walkPathItemReferences(pathItem, modify); + } + } + } +} + +function walkOperationReferences(operation: Swagger.Operation, modify: Modify): void { + if (operation.parameters !== undefined) { + for (const parameterOrRef of operation.parameters) { + walkParameterReferences(parameterOrRef, modify); + } + } + + if (operation.requestBody !== undefined) { + walkRequestBodyReferences(operation.requestBody, modify); + } + + for (const responseKey in operation.responses) { + if (operation.responses.hasOwnProperty(responseKey)) { + const response = operation.responses[responseKey]; + walkResponseReferences(response, modify); + } + } + + if (operation.callbacks !== undefined) { + const callbackKeys = Object.keys(operation.callbacks); + for (let callbackKeyIndex = 0; callbackKeyIndex < callbackKeys.length; callbackKeyIndex++) { + const callbackKey = callbackKeys[callbackKeyIndex]; + const callback = operation.callbacks[callbackKey]; + walkCallbackReferences(callback, modify); + } + } +} + +function walkPathItemReferences(pathItem: Swagger.PathItem, modify: Modify): void { + if (pathItem["$ref"] !== undefined) { + pathItem["$ref"] = modify(pathItem["$ref"]); + } else { + if (pathItem.get !== undefined) walkOperationReferences(pathItem.get, modify); + if (pathItem.put !== undefined) walkOperationReferences(pathItem.put, modify); + if (pathItem.post !== undefined) walkOperationReferences(pathItem.post, modify); + if (pathItem.delete !== undefined) walkOperationReferences(pathItem.delete, modify); + if (pathItem.options !== undefined) walkOperationReferences(pathItem.options, modify); + if (pathItem.head !== undefined) walkOperationReferences(pathItem.head, modify); + if (pathItem.patch !== undefined) walkOperationReferences(pathItem.patch, modify); + if (pathItem.trace !== undefined) walkOperationReferences(pathItem.trace, modify); + + if (pathItem.parameters !== undefined) { + for (let parameterIndex = 0; parameterIndex < pathItem.parameters.length; parameterIndex++) { + walkParameterReferences(pathItem.parameters[parameterIndex], modify); + } + } + } +} + +export function walkComponentReferences(components: Swagger.Components, modify: Modify): void { + if (components.schemas !== undefined) { + for (const schemaKey in components.schemas) { + if (components.schemas.hasOwnProperty(schemaKey)) { + const schema = components.schemas[schemaKey]; + walkSchemaReferences(schema, modify); + } + } + } + + if (components.responses !== undefined) { + for (const responsesKey in components.responses) { + if (components.responses.hasOwnProperty(responsesKey)) { + const response = components.responses[responsesKey]; + + walkResponseReferences(response, modify); + } + } + } + + if (components.parameters !== undefined) { + for (const parameterKey in components.parameters) { + if (components.parameters.hasOwnProperty(parameterKey)) { + const parameter = components.parameters[parameterKey]; + walkParameterReferences(parameter, modify); + } + } + } + + if (components.examples !== undefined) { + for (const exampleKey in components.examples) { + if (components.examples.hasOwnProperty(exampleKey)) { + const example = components.examples[exampleKey]; + walkExampleReferences(example, modify); + } + } + } + + if (components.requestBodies !== undefined) { + for (const requestBodyKey in components.requestBodies) { + if (components.requestBodies.hasOwnProperty(requestBodyKey)) { + const requestBody = components.requestBodies[requestBodyKey]; + walkRequestBodyReferences(requestBody, modify); + } + } + } + + if (components.headers !== undefined) { + for (const headerKey in components.headers) { + if (components.headers.hasOwnProperty(headerKey)) { + const header = components.headers[headerKey]; + walkHeaderReferences(header, modify); + } + } + } + + if (components.links !== undefined) { + for (const linkKey in components.links) { + if (components.links.hasOwnProperty(linkKey)) { + const link = components.links[linkKey]; + walkLinkReferences(link, modify); + } + } + } + + if (components.callbacks !== undefined) { + for (const componentKey in components.callbacks) { + if (components.callbacks.hasOwnProperty(componentKey)) { + const callback = components.callbacks[componentKey]; + walkCallbackReferences(callback, modify); + } + } + } +} + +export function walkPathReferences(paths: Swagger.Paths, modify: Modify): void { + for (const pathKey in paths) { + if (paths.hasOwnProperty(pathKey)) { + const path = paths[pathKey]; + walkPathItemReferences(path, modify); + } + } +} + +export function walkAllReferences(oas: Swagger.SwaggerV3, modify: Modify): void { + walkPathReferences(oas.paths, modify); + if (oas.components !== undefined) walkComponentReferences(oas.components, modify); +} diff --git a/packages/rest/src/transform/openapi-merge/tags.ts b/packages/rest/src/transform/openapi-merge/tags.ts new file mode 100644 index 0000000..de1df6f --- /dev/null +++ b/packages/rest/src/transform/openapi-merge/tags.ts @@ -0,0 +1,39 @@ +import { Swagger } from "atlassian-openapi"; +import { MergeInput } from "./data"; + +function getNonExcludedTags(originalTags: Swagger.Tag[], excludedTagNames: string[]): Swagger.Tag[] { + if (excludedTagNames.length === 0) { + return originalTags; + } + + return originalTags.filter(tag => !excludedTagNames.includes(tag.name)); +} + +export function mergeTags(inputs: MergeInput): Swagger.Tag[] | undefined { + const result = new Array(); + + const seenTags = new Set(); + inputs.forEach(input => { + const { operationSelection } = input; + const { tags } = input.oas; + if (tags !== undefined) { + const excludeTags = operationSelection !== undefined && operationSelection.excludeTags !== undefined + ? operationSelection.excludeTags + : []; + const nonExcludedTags = getNonExcludedTags(tags, excludeTags); + + nonExcludedTags.forEach(tag => { + if (!seenTags.has(tag.name)) { + seenTags.add(tag.name); + result.push(tag); + } + }); + } + }); + + if (result.length === 0) { + return undefined; + } + + return result; +} diff --git a/packages/rest/src/transform/project.ts b/packages/rest/src/transform/project.ts index e8e3f7b..0a96af7 100644 --- a/packages/rest/src/transform/project.ts +++ b/packages/rest/src/transform/project.ts @@ -16,6 +16,7 @@ export interface Project { schemaGenerator: SchemaGenerator; typeFormatter: TypeFormatter; nodeParser: NodeParser; + parsedCommandLine: ts.ParsedCommandLine; } export type AJVOptions = Pick< diff --git a/packages/rest/src/transform/transform.ts b/packages/rest/src/transform/transform.ts index 3626edf..7e7e2ab 100644 --- a/packages/rest/src/transform/transform.ts +++ b/packages/rest/src/transform/transform.ts @@ -54,6 +54,23 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme schemaConfig, ); + const compilerHost = program.getCompilerOptions().incremental + ? ts.createIncrementalCompilerHost(program.getCompilerOptions()) + : ts.createCompilerHost(program.getCompilerOptions()); + + const parseConfigHost: ts.ParseConfigFileHost = { + useCaseSensitiveFileNames: true, + fileExists: fileName => compilerHost!.fileExists(fileName), + readFile: fileName => compilerHost!.readFile(fileName), + directoryExists: f => compilerHost!.directoryExists!(f), + getDirectories: f => compilerHost!.getDirectories!(f), + realpath: compilerHost.realpath, + readDirectory: (...args) => compilerHost!.readDirectory!(...args), + trace: compilerHost.trace, + getCurrentDirectory: compilerHost.getCurrentDirectory, + onUnRecoverableConfigFileDiagnostic: () => {}, + }; + project = { transformOnly: false, program, @@ -67,9 +84,12 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme schemaGenerator, nodeParser, typeFormatter, - compilerHost: program.getCompilerOptions().incremental - ? ts.createIncrementalCompilerHost(program.getCompilerOptions()) - : ts.createCompilerHost(program.getCompilerOptions()), + compilerHost, + parsedCommandLine: ts.getParsedCommandLineOfConfigFile( + ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json") as string, + program.getCompilerOptions(), + parseConfigHost, + )!, }; return (context) => { diff --git a/packages/rest/src/transform/transformers/controller-method-transformer.ts b/packages/rest/src/transform/transformers/controller-method-transformer.ts index 30382b9..47c7bec 100644 --- a/packages/rest/src/transform/transformers/controller-method-transformer.ts +++ b/packages/rest/src/transform/transformers/controller-method-transformer.ts @@ -18,13 +18,13 @@ export abstract class ControllerMethodTransformer { if (nornirDecorators.length === 0) return node; const methodDecorator = nornirDecorators.find(decorator => { - const name = project.checker.getTypeAtLocation(decorator.declaration.parent).symbol.name; + const name = decorator.symbol.name; return METHOD_DECORATOR_PROCESSORS[name] != undefined; }); if (!methodDecorator) return node; - const method = project.checker.getTypeAtLocation(methodDecorator.declaration.parent).symbol.name; + const method = methodDecorator.symbol.name; if (!method) return node; diff --git a/packages/rest/src/transform/transformers/file-transformer.ts b/packages/rest/src/transform/transformers/file-transformer.ts index 487ad93..3ce73f2 100644 --- a/packages/rest/src/transform/transformers/file-transformer.ts +++ b/packages/rest/src/transform/transformers/file-transformer.ts @@ -1,5 +1,7 @@ +import { rmSync } from "fs"; +import { parseJsDocOfNode } from "tsutils"; import ts from "typescript"; -import { ControllerMeta } from "../controller-meta"; +import { ControllerMeta, OpenApiSpecHolder } from "../controller-meta"; import { TransformationError } from "../error"; import { Project } from "../project.js"; import { NodeTransformer } from "./node-transformer"; @@ -10,8 +12,18 @@ export abstract class FileTransformer { // return file; const transformed = FileTransformer.iterateNode(project, context, file, file); + const outputFileNames = ts.getOutputFileNames(project.parsedCommandLine, file.fileName, false); + const schemaFileName = `${outputFileNames[0]}.nornir.oas.json`; + + const { compilerHost } = project; const nodesToAdd = FileTransformer.getStatementsForFile(file); - if (nodesToAdd == undefined) return transformed; + if (nodesToAdd == undefined) { + // delete schema file if it exists + if (compilerHost.fileExists(schemaFileName)) { + rmSync(schemaFileName); + } + return transformed; + } const updated = ts.factory.updateSourceFile( transformed, @@ -27,6 +39,11 @@ export abstract class FileTransformer { file.libReferenceDirectives, ); + const mergedSpec = OpenApiSpecHolder.getSpecForFile(file); + console.log("Merged Spec:", JSON.stringify(mergedSpec, null, 2)); + + compilerHost.writeFile(schemaFileName, JSON.stringify(mergedSpec, null, 2), false, undefined, []); + FileTransformer.FILE_NODE_MAP.delete(file.fileName); FileTransformer.IMPORT_MAP.delete(file.fileName); ControllerMeta.clearCache(); diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index e741607..a15c3c0 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -15,6 +15,20 @@ export const ChainMethodDecoratorTypeMap = { HeadChain: "HEAD", } as const; +const TagMapper = { + summary: (value?: string) => value, + description: (value?: string) => value, + deprecated: (value?: string) => value != "false", + operationId: (value?: string) => value, + tags: (value?: string) => value?.split(",").map(tag => tag.trim()) || [], +} as const; + +const SupportedTags = Object.keys(TagMapper) as (keyof typeof TagMapper)[]; + +type RouteTags = { + -readonly [K in keyof typeof TagMapper]?: Exclude, undefined>; +}; + export const ChainMethodDecoratorTypes = Object.keys( ChainMethodDecoratorTypeMap, ) as (keyof typeof ChainMethodDecoratorTypeMap)[]; @@ -41,6 +55,8 @@ export abstract class ChainRouteProcessor { const inputValidator = schemaToValidator(inputSchema, project.options.validation); + const parsedDocComments = ChainRouteProcessor.parseJSDoc(project, node); + controller.registerRoute(node, { method, path, @@ -48,9 +64,12 @@ export abstract class ChainRouteProcessor { // FIXME: this should be the output type not the input type output: inputType, - // description, - // summary, + description: parsedDocComments.description, + summary: parsedDocComments.summary, filePath: source.fileName, + deprecated: parsedDocComments.deprecated, + operationId: parsedDocComments.operationId, + tags: parsedDocComments.tags, }); controller.addInitializationStatement( @@ -64,7 +83,7 @@ export abstract class ChainRouteProcessor { ), ); const { otherDecorators } = separateNornirDecorators(project, ts.getDecorators(node) || []); - return ts.factory.createMethodDeclaration( + const recreatedNode = ts.factory.createMethodDeclaration( [...(ts.getModifiers(node) || []), ...otherDecorators], node.asteriskToken, node.name, @@ -74,6 +93,28 @@ export abstract class ChainRouteProcessor { node.type, node.body, ); + + ts.setTextRange(recreatedNode, node); + ts.setOriginalNode(recreatedNode, node); + + return recreatedNode; + } + + private static parseJSDoc(project: Project, method: ts.MethodDeclaration): RouteTags { + const docs = ts.getJSDocCommentsAndTags(ts.getOriginalNode(method)); + const topLevel = docs[0]; + const description = ts.getTextOfJSDocComment(topLevel.comment); + if (!ts.isJSDoc(topLevel)) { + return {}; + } + + const tagSet = (topLevel.tags || []) + .map(tag => [tag.tagName.escapedText as string, ts.getTextOfJSDocComment(tag.comment)] as const) + .filter(tag => SupportedTags.includes(tag[0] as keyof typeof TagMapper)) + .map(tag => [tag[0], TagMapper[tag[0] as keyof typeof TagMapper](tag[1])]); + + tagSet.push(["description", description]); + return Object.fromEntries(tagSet) as RouteTags; } private static generateRouteStatement( @@ -125,7 +166,7 @@ export abstract class ChainRouteProcessor { } private static getMethod(project: Project, methodDecorator: NornirDecoratorInfo): string { - const name = project.checker.getTypeAtLocation(methodDecorator.declaration.parent).symbol.name; + const name = methodDecorator.symbol.name; return ChainMethodDecoratorTypeMap[name as keyof typeof ChainMethodDecoratorTypeMap]; } @@ -141,7 +182,6 @@ export abstract class ChainRouteProcessor { throw new Error("Handler chain input must be a Nornir class"); } - // eslint-disable-next-line unicorn/no-useless-undefined const paramTypeNode = project.checker.typeToTypeNode(paramType, undefined, undefined) as ts.TypeReferenceNode; const [paramTypeArg] = paramTypeNode.typeArguments || []; if (!paramTypeArg) { diff --git a/packages/rest/src/transform/transformers/processors/controller-processor.ts b/packages/rest/src/transform/transformers/processors/controller-processor.ts index 6fe757b..7fc13ad 100644 --- a/packages/rest/src/transform/transformers/processors/controller-processor.ts +++ b/packages/rest/src/transform/transformers/processors/controller-processor.ts @@ -27,7 +27,7 @@ export abstract class ControllerProcessor { const { otherDecorators } = separateNornirDecorators(project, ts.getDecorators(transformedNode) || []); - return ts.factory.createClassDeclaration( + const recreatedNode = ts.factory.createClassDeclaration( [...transformedModifiers, ...otherDecorators], transformedNode.name, transformedNode.typeParameters, @@ -37,15 +37,18 @@ export abstract class ControllerProcessor { ...routeMeta.getGeneratedMembers(), ], ); + + ts.setTextRange(recreatedNode, node); + ts.setOriginalNode(recreatedNode, node); + + return recreatedNode; } private static getArguments(project: Project, nornirDecorators: NornirDecoratorInfo[]): { basePath: string; apiId: string; } { - const basePathDecorator = nornirDecorators.find((decorator) => - project.checker.getTypeAtLocation(decorator.declaration.parent).symbol.name === "Controller" - ); + const basePathDecorator = nornirDecorators.find((decorator) => decorator.symbol.name === "Controller"); if (basePathDecorator == undefined) throw new Error("Controller must have a controller decorator"); if (!ts.isCallExpression(basePathDecorator.decorator.expression)) { throw new Error("Controller decorator is not a call expression"); diff --git a/packages/test/__tests__/src/test.spec.ts b/packages/test/__tests__/src/test.spec.ts index 05de923..3f79cc4 100644 --- a/packages/test/__tests__/src/test.spec.ts +++ b/packages/test/__tests__/src/test.spec.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line unicorn/no-empty-file describe("test something", () => { it.skip("should do something", () => { expect(true).toBe(true); diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index df16f2e..6b214b6 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -13,14 +13,12 @@ import { assertValid } from "@nrfcloud/ts-json-schema-transformer"; interface RouteGetInput extends HttpRequestEmpty { headers: { - // eslint-disable-next-line sonarjs/no-duplicate-string "content-type": AnyMimeType; }; } interface RoutePostInputJSON extends HttpRequest { headers: { - // eslint-disable-next-line sonarjs/no-duplicate-string "content-type": MimeType.ApplicationJson; }; body: RoutePostBodyInput; @@ -77,17 +75,18 @@ export declare class Tagged { */ export type Nominal = T & Tagged> = (T & Tagged) | E; -const basePath = "/basepath"; +const overallBase = "/root"; +const basePath = `${overallBase}/basepath`; + +/** + * This is a controller + * @summary This is a summary + */ @Controller(basePath) export class TestController { - static { - console.log("hello"); - } - /** - * A simple get route - * @summary Cool Route + * Cool get route */ @GetChain("/route") public getRoute(chain: Nornir) { @@ -105,6 +104,14 @@ export class TestController { }, })); } + + /** + * A simple post route + * @summary Cool Route + * @tags cool, route + * @deprecated + * @operationId coolRoute + */ @PostChain("/route") public postRoute(chain: Nornir) { return chain diff --git a/packages/test/src/controller2.ts b/packages/test/src/controller2.ts index a91d72b..b08a81c 100644 --- a/packages/test/src/controller2.ts +++ b/packages/test/src/controller2.ts @@ -43,6 +43,10 @@ interface RoutePostBodyInput { const basePath = "/basepath/2"; +/** + * This is a second controller + * @summary This is a summary + */ @Controller(basePath, "test") export class TestController { @Provider() @@ -67,6 +71,10 @@ export class TestController { })); } + /** + * The second simple PUT route. + * @summary Put route + */ @PutChain("/route") public postRoute(chain: Nornir): Nornir { return chain diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00d31ca..beb93aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,18 +140,27 @@ importers: ajv: specifier: ^8.12.0 version: 8.12.0 + atlassian-openapi: + specifier: ^1.0.18 + version: 1.0.18 + lodash: + specifier: ^4.17.21 + version: 4.17.21 openapi-types: specifier: ^12.1.0 version: 12.1.0 trouter: specifier: ^3.2.1 version: 3.2.1 + ts-is-present: + specifier: ^1.2.2 + version: 1.2.2 ts-json-schema-generator: specifier: ^1.4.0 version: 1.4.0 ts-morph: - specifier: ^19.0.0 - version: 19.0.0 + specifier: ^20.0.0 + version: 20.0.0 tsutils: specifier: ^3.21.0 version: 3.21.0(typescript@5.2.2) @@ -162,6 +171,9 @@ importers: '@types/jest': specifier: ^29.4.0 version: 29.4.0 + '@types/lodash': + specifier: ^4.14.202 + version: 4.14.202 '@types/node': specifier: ^18.15.11 version: 18.15.11 @@ -2277,8 +2289,8 @@ packages: '@sinonjs/commons': 2.0.0 dev: true - /@ts-morph/common@0.20.0: - resolution: {integrity: sha512-7uKjByfbPpwuzkstL3L5MQyuXPSKdoNG93Fmi2JoDcTf3pEP731JdRFAduRVkOs8oqxPsXKA+ScrWkdQ8t/I+Q==} + /@ts-morph/common@0.21.0: + resolution: {integrity: sha512-ES110Mmne5Vi4ypUKrtVQfXFDtCsDXiUiGxF6ILVlE90dDD4fdpC1LSjydl/ml7xJWKSDZwUYD2zkOePMSrPBA==} dependencies: fast-glob: 3.2.12 minimatch: 7.4.6 @@ -2377,10 +2389,10 @@ packages: /@types/lodash.clonedeep@4.5.7: resolution: {integrity: sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==} dependencies: - '@types/lodash': 4.14.194 + '@types/lodash': 4.14.202 - /@types/lodash@4.14.194: - resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} /@types/minimist@1.2.2: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} @@ -2789,6 +2801,13 @@ packages: engines: {node: '>=0.10.0'} dev: true + /atlassian-openapi@1.0.18: + resolution: {integrity: sha512-IXgF/cYD8DW1mYB/ejDm/lKQMNXi2iCsxus2Y0ffZOxfa/SLoz0RuEZ4xu4suSRjtlda7qZDonQ6TAkQPVuQig==} + dependencies: + jsonpointer: 5.0.1 + urijs: 1.19.11 + dev: false + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -5202,6 +5221,11 @@ packages: resolution: {integrity: sha512-890w2Pjtj0iswAxalRlt2kHthi6HKrXEfZcn+ZNZptv7F3rUGIeDuZo+C+h4vXBHLEsVjJrHeCm35nYeZLzSBQ==} engines: {node: '>=10.0.0'} + /jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + dev: false + /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -5293,7 +5317,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -6569,6 +6592,10 @@ packages: typescript: 5.2.2 dev: true + /ts-is-present@1.2.2: + resolution: {integrity: sha512-cA5MPLWGWYXvnlJb4TamUUx858HVHBsxxdy8l7jxODOLDyGYnQOllob2A2jyDghGa5iJHs2gzFNHvwGJ0ZfR8g==} + dev: false + /ts-json-schema-generator@1.4.0: resolution: {integrity: sha512-wm8vyihmGgYpxrqRshmYkWGNwEk+sf3xV2rUgxv8Ryeh7bSpMO7pZQOht+2rS002eDkFTxR7EwRPXVzrS0WJTg==} engines: {node: '>=10.0.0'} @@ -6582,10 +6609,10 @@ packages: safe-stable-stringify: 2.4.3 typescript: 5.2.2 - /ts-morph@19.0.0: - resolution: {integrity: sha512-D6qcpiJdn46tUqV45vr5UGM2dnIEuTGNxVhg0sk5NX11orcouwj6i1bMqZIz2mZTZB1Hcgy7C3oEVhAT+f6mbQ==} + /ts-morph@20.0.0: + resolution: {integrity: sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg==} dependencies: - '@ts-morph/common': 0.20.0 + '@ts-morph/common': 0.21.0 code-block-writer: 12.0.0 dev: false @@ -6859,6 +6886,10 @@ packages: dependencies: punycode: 2.3.0 + /urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true From ad03fb9e3dea455de05759ecef50b33ccc18ee26 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:44:46 -0800 Subject: [PATCH 02/16] in progress work --- packages/rest/package.json | 1 + packages/rest/src/runtime/http-event.mts | 7 +- .../rest/src/transform/controller-meta.ts | 96 ++++++-- packages/rest/src/transform/transform.ts | 25 ++- .../processors/chain-route-processor.ts | 30 ++- packages/test/src/controller.ts | 51 +++-- packages/test/src/controller2.ts | 208 +++++++++--------- pnpm-lock.yaml | 89 +------- 8 files changed, 270 insertions(+), 237 deletions(-) diff --git a/packages/rest/package.json b/packages/rest/package.json index 7927889..0862773 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -8,6 +8,7 @@ "@types/aws-lambda": "^8.10.115", "ajv": "^8.12.0", "atlassian-openapi": "^1.0.18", + "json-schema-traverse": "^1.0.0", "lodash": "^4.17.21", "openapi-types": "^12.1.0", "trouter": "^3.2.1", diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index 198bb17..f7f9d0e 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -31,9 +31,14 @@ export interface HttpRequest { readonly body?: unknown; - readonly pathParams: Record; + readonly pathParams: PathParams; } +/** + * @nornirIgnore + */ +type PathParams = Record; + export interface HttpRequestEmpty extends HttpRequest { headers: { "content-type": AnyMimeType; diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index 4629b4a..617d868 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -1,9 +1,14 @@ -import { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; +import traverse from "json-schema-traverse"; +import { IJsonSchema, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; +import * as tsm from "ts-morph"; import ts from "typescript"; import { isErrorResult, merge, MergeInput } from "./openapi-merge"; import { Project } from "./project"; import { FileTransformer } from "./transformers/file-transformer"; +import { JSONSchemaType } from "ajv"; +import { ReferenceType, SubNodeParser, UnionType } from "ts-json-schema-generator"; + export abstract class OpenApiSpecHolder { private static specFileMap = new Map(); @@ -195,18 +200,7 @@ export class ControllerMeta { }; } - public registerRoute(node: ts.Node, routeInfo: { - method: string; - path: string; - description?: string; - summary?: string; - input: ts.Type; - output: ts.Type; - filePath: string; - tags?: string[]; - deprecated?: boolean; - operationId?: string; - }) { + public registerRoute(node: ts.Node, routeInfo: RouteInfo) { if (this.project.transformOnly) { return; } @@ -228,10 +222,16 @@ export class ControllerMeta { // responseInfo: this.buildResponseInfo(index, routeInfo.output), filePath: routeInfo.filePath, summary: routeInfo.summary, + deprecated: routeInfo.deprecated, + operationId: routeInfo.operationId, + tags: routeInfo.tags, + input: routeInfo.input, + output: routeInfo.output, }); } private generateRouteSpec(route: RouteInfo): OpenAPIV3_1.Document { + this.generatePathParamsSpecs(route); return { openapi: "3.0.3", info: { @@ -254,9 +254,50 @@ export class ControllerMeta { }, }, }, + components: { + parameters: {}, + }, } as OpenAPIV3_1.Document; } + private generatePathParamsSpecs(routeInfo: RouteInfo): OpenAPIV3_1.ParameterObject[] { + const wrapped = tsm.createWrappedNode(routeInfo.input, { typeChecker: this.project.checker }); + const property = wrapped.getType().getProperty("pathParams"); + if (property == undefined) throw new Error("No pathParams property found"); + const declarations = property.getDeclarations() as tsm.PropertySignature[]; + const typeNodes = declarations.map((declaration) => declaration.getTypeNodeOrThrow()); + const paramDeclaractions = typeNodes.map(typeNode => typeNode.getType()) + .map(type => type.getProperties()) + .flat() + .reduce((acc, property) => { + const name = property.getName(); + const arr = acc[name] || []; + const declarations = property.getDeclarations() as tsm.PropertySignature[]; + arr.push(...declarations); + acc[name] = arr; + return acc; + }, {} as Record); + + const paramObjectSchema = this.project.schemaGenerator.createSchemaFromNodes( + [ts.factory.createUnionTypeNode(typeNodes.map((typeNode) => typeNode.compilerNode))], + ); + const schemas = Object.fromEntries( + Object.entries(paramDeclaractions).map(([name, declarations]) => { + const typeSymbols = declarations.map((declaration) => declaration.getSymbolOrThrow()); + const typeRefs = typeSymbols.map(symbol => { + const typeRef = ts.factory.createTypeReferenceNode(name, []); + + (typeRef as any).symbol = symbol.compilerSymbol; + return typeRef; + }); + const schema = this.project.schemaGenerator.createSchemaFromNodes(typeRefs); + return [name, schema] as const; + }), + ); + + return []; + } + // private buildRequestInfo(routeIndex: RouteIndex, inputType: ts.Type): RequestInfo { // const paramterData: { [key in ParameterType]: { [name: string]: ParameterMeta } } = { // path: {}, @@ -436,6 +477,27 @@ export class ControllerMeta { // } } +// Traverses the schema and extracts a unified definition for the property at the given path +// export function getUnifiedPropertySchema(schema: IJsonSchema, path: string) { +// // Take a path from the json schema and convert it to a path in validated object +// const convertJsonSchemaPathToPropertyPath = (path: string) => { +// return path +// // replace properties +// .replace(/\/properties\//g, ".") +// // replace items and index +// .replace(/\/items\/(\d+)\//g, "[].") +// // replace anyOf and index +// .replace(/\/anyOf\/(\d+)\//g, ".") +// // replace oneOf and index +// .replace(/\/oneOf\/(\d+)\//g, ".") +// // replace allOf and index +// .replace(/\/allOf\/(\d+)\//g, ".") +// } +// const schema +// +// traverse() +// } + function deparameterizePath(path: string) { return path.replaceAll(/:[^/]+/g, ":param"); } @@ -445,12 +507,12 @@ export interface RouteInfo { path: string; description?: string; summary?: string; + input: ts.TypeNode; + output: ts.TypeNode; + filePath: string; + tags?: string[]; deprecated?: boolean; operationId?: string; - tags?: string[]; - // requestInfo: RequestInfo; - // responseInfo: ResponseInfo; - filePath: string; } export type RouteIndex = Pick; diff --git a/packages/rest/src/transform/transform.ts b/packages/rest/src/transform/transform.ts index 7e7e2ab..06ec4fa 100644 --- a/packages/rest/src/transform/transform.ts +++ b/packages/rest/src/transform/transform.ts @@ -1,4 +1,13 @@ -import { createFormatter, createParser, SchemaGenerator } from "ts-json-schema-generator"; +import { + BaseType, + Context, + createFormatter, + createParser, + NeverType, + ReferenceType, + SchemaGenerator, + SubNodeParser, +} from "ts-json-schema-generator"; import ts from "typescript"; import { AJV_DEFAULTS, AJVOptions, Options, Project, SCHEMA_DEFAULTS, SchemaConfig } from "./project.js"; import { FileTransformer } from "./transformers/file-transformer.js"; @@ -6,6 +15,18 @@ import { FileTransformer } from "./transformers/file-transformer.js"; let project: Project; // let files: string[] = []; // let openapi: OpenAPIV3.Document; + +export class NornirIgnoreParser implements SubNodeParser { + supportsNode(node: ts.Node): boolean { + const tags = ts.getJSDocTags(node); + return tags.some((tag) => tag.tagName.getText() === "nornirIgnore"); + } + + createType(node: ts.Node, context: Context, reference?: ReferenceType): BaseType { + return new NeverType(); + } +} + export function transform(program: ts.Program, options?: Options): ts.TransformerFactory { const { loopEnum, @@ -42,6 +63,8 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme const nodeParser = createParser(program as unknown as Parameters[0], { ...schemaConfig, + }, (prs) => { + // prs.addNodeParser(new NornirIgnoreParser()); }); const typeFormatter = createFormatter({ ...schemaConfig, diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index a15c3c0..1e1ab22 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -1,4 +1,5 @@ import { schemaToValidator } from "@nrfcloud/ts-json-schema-transformer/utils"; +import { createWrappedNode } from "ts-morph"; import { isTypeReference } from "tsutils"; import ts from "typescript"; import { ControllerMeta } from "../../controller-meta"; @@ -46,8 +47,7 @@ export abstract class ChainRouteProcessor { // const wrappedNode = createWrappedNode(node, { typeChecker: project.checker }) as MethodDeclaration; - const inputTypeNode = ChainRouteProcessor.resolveInputType(project, node); - const inputType = project.checker.getTypeFromTypeNode(inputTypeNode); + const { typeNode: inputTypeNode, type: inputType } = ChainRouteProcessor.resolveInputType(project, node); // const outputType = ChainRouteProcessor.resolveOutputType(project, methodSignature); @@ -60,10 +60,10 @@ export abstract class ChainRouteProcessor { controller.registerRoute(node, { method, path, - input: inputType, + input: inputTypeNode, // FIXME: this should be the output type not the input type - output: inputType, + output: inputTypeNode, description: parsedDocComments.description, summary: parsedDocComments.summary, filePath: source.fileName, @@ -170,25 +170,39 @@ export abstract class ChainRouteProcessor { return ChainMethodDecoratorTypeMap[name as keyof typeof ChainMethodDecoratorTypeMap]; } - private static resolveInputType(project: Project, method: ts.MethodDeclaration): ts.TypeNode { + private static resolveInputType( + project: Project, + method: ts.MethodDeclaration, + ): { type: ts.Type; typeNode: ts.TypeNode } { const params = method.parameters; if (params.length !== 1) { throw new Error("Handler chain must have 1 parameter"); } const [param] = params; - const paramType = project.checker.getTypeAtLocation(param); + const paramTypeNode = param.type; + if (!paramTypeNode || !ts.isTypeReferenceNode(paramTypeNode)) { + throw new Error("Handler chain parameter must have a type"); + } + + const paramType = project.checker.getTypeFromTypeNode(paramTypeNode); const paramDeclaration = paramType.symbol?.declarations?.[0]; if (!paramDeclaration || !isNornirNode(paramDeclaration)) { throw new Error("Handler chain input must be a Nornir class"); } - const paramTypeNode = project.checker.typeToTypeNode(paramType, undefined, undefined) as ts.TypeReferenceNode; + // const paramTypeNode = project.checker.typeToTypeNode(paramType, undefined, undefined) as ts.TypeReferenceNode; const [paramTypeArg] = paramTypeNode.typeArguments || []; + + const paramTypeArgType = project.checker.getTypeFromTypeNode(paramTypeArg); + if (!paramTypeArg) { throw new Error("Handler chain input must have a type argument"); } - return paramTypeArg; + return { + type: paramTypeArgType, + typeNode: paramTypeArg, + }; } private static resolveOutputType(project: Project, methodSignature: ts.Signature): ts.Type { diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index 6b214b6..bba9e06 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -25,6 +25,13 @@ interface RoutePostInputJSON extends HttpRequest { query: { test: "boolean"; }; + pathParams: { + /** + * Very cool property that does a thing + * @pattern ^[a-z]+$ + */ + reallyCool: "true" | "false"; + } } interface RoutePostInputCSV extends HttpRequest { @@ -37,8 +44,12 @@ interface RoutePostInputCSV extends HttpRequest { * @minLength 5 */ "csv-header": string; + }; body: TestStringType; + pathParams: { + reallyCool: "stuff"; + } } type RoutePostInput = RoutePostInputJSON | RoutePostInputCSV; @@ -85,25 +96,25 @@ const basePath = `${overallBase}/basepath`; */ @Controller(basePath) export class TestController { - /** - * Cool get route - */ - @GetChain("/route") - public getRoute(chain: Nornir) { - return chain - .use(input => { - assertValid(input); - return input; - }) - .use(input => input.headers["content-type"]) - .use(contentType => ({ - statusCode: HttpStatusCode.Ok, - body: `Content-Type: ${contentType}`, - headers: { - "content-type": MimeType.TextPlain, - }, - })); - } + // /** + // * Cool get route + // */ + // @GetChain("/route") + // public getRoute(chain: Nornir) { + // return chain + // .use(input => { + // assertValid(input); + // return input; + // }) + // .use(input => input.headers["content-type"]) + // .use(contentType => ({ + // statusCode: HttpStatusCode.Ok, + // body: `Content-Type: ${contentType}`, + // headers: { + // "content-type": MimeType.TextPlain, + // }, + // })); + // } /** * A simple post route @@ -112,7 +123,7 @@ export class TestController { * @deprecated * @operationId coolRoute */ - @PostChain("/route") + @PostChain("/route/{cool}") public postRoute(chain: Nornir) { return chain .use(contentType => ({ diff --git a/packages/test/src/controller2.ts b/packages/test/src/controller2.ts index b08a81c..b80703e 100644 --- a/packages/test/src/controller2.ts +++ b/packages/test/src/controller2.ts @@ -1,104 +1,104 @@ -import { Nornir } from "@nornir/core"; -import { - AnyMimeType, - Controller, - GetChain, - HttpRequest, - HttpRequestEmpty, - HttpResponse, - HttpResponseEmpty, - HttpStatusCode, - MimeType, - Provider, - PutChain, -} from "@nornir/rest"; - -interface RouteGetInput extends HttpRequestEmpty { - headers: GetHeaders; -} -interface GetHeaders { - "content-type": AnyMimeType; - [key: string]: string; -} - -interface RoutePostInputJSON extends HttpRequest { - headers: { - "content-type": MimeType.ApplicationJson; - }; - body: RoutePostBodyInput; -} - -interface RoutePostInputCSV extends HttpRequest { - headers: { - "content-type": MimeType.TextCsv; - }; - body: string; -} - -type RoutePutInput = RoutePostInputJSON | RoutePostInputCSV; - -interface RoutePostBodyInput { - cool: string; -} - -const basePath = "/basepath/2"; - -/** - * This is a second controller - * @summary This is a summary - */ -@Controller(basePath, "test") -export class TestController { - @Provider() - public static test() { - return new TestController(); - } - - /** - * The second simple GET route. - * @summary Get route - */ - @GetChain("/route") - public getRoute(chain: Nornir) { - return chain - .use(input => input.headers["content-type"]) - .use(contentType => ({ - statusCode: HttpStatusCode.Ok, - body: `Content-Type: ${contentType}`, - headers: { - "content-type": MimeType.TextPlain, - }, - })); - } - - /** - * The second simple PUT route. - * @summary Put route - */ - @PutChain("/route") - public postRoute(chain: Nornir): Nornir { - return chain - .use(() => ({ - statusCode: HttpStatusCode.Created, - headers: { - "content-type": AnyMimeType, - }, - })); - } -} - -type PutResponse = PutSuccessResponse | PutBadRequestResponse; - -interface PutSuccessResponse extends HttpResponseEmpty { - statusCode: HttpStatusCode.Created; -} - -interface PutBadRequestResponse extends HttpResponse { - statusCode: HttpStatusCode.BadRequest; - headers: { - "content-type": MimeType.ApplicationJson; - }; - body: { - potato: boolean; - }; -} +// import { Nornir } from "@nornir/core"; +// import { +// AnyMimeType, +// Controller, +// GetChain, +// HttpRequest, +// HttpRequestEmpty, +// HttpResponse, +// HttpResponseEmpty, +// HttpStatusCode, +// MimeType, +// Provider, +// PutChain, +// } from "@nornir/rest"; +// +// interface RouteGetInput extends HttpRequestEmpty { +// headers: GetHeaders; +// } +// interface GetHeaders { +// "content-type": AnyMimeType; +// [key: string]: string; +// } +// +// interface RoutePostInputJSON extends HttpRequest { +// headers: { +// "content-type": MimeType.ApplicationJson; +// }; +// body: RoutePostBodyInput; +// } +// +// interface RoutePostInputCSV extends HttpRequest { +// headers: { +// "content-type": MimeType.TextCsv; +// }; +// body: string; +// } +// +// type RoutePutInput = RoutePostInputJSON | RoutePostInputCSV; +// +// interface RoutePostBodyInput { +// cool: string; +// } +// +// const basePath = "/basepath/2"; +// +// /** +// * This is a second controller +// * @summary This is a summary +// */ +// @Controller(basePath, "test") +// export class TestController { +// @Provider() +// public static test() { +// return new TestController(); +// } +// +// /** +// * The second simple GET route. +// * @summary Get route +// */ +// @GetChain("/route") +// public getRoute(chain: Nornir) { +// return chain +// .use(input => input.headers["content-type"]) +// .use(contentType => ({ +// statusCode: HttpStatusCode.Ok, +// body: `Content-Type: ${contentType}`, +// headers: { +// "content-type": MimeType.TextPlain, +// }, +// })); +// } +// +// /** +// * The second simple PUT route. +// * @summary Put route +// */ +// @PutChain("/route") +// public postRoute(chain: Nornir): Nornir { +// return chain +// .use(() => ({ +// statusCode: HttpStatusCode.Created, +// headers: { +// "content-type": AnyMimeType, +// }, +// })); +// } +// } +// +// type PutResponse = PutSuccessResponse | PutBadRequestResponse; +// +// interface PutSuccessResponse extends HttpResponseEmpty { +// statusCode: HttpStatusCode.Created; +// } +// +// interface PutBadRequestResponse extends HttpResponse { +// statusCode: HttpStatusCode.BadRequest; +// headers: { +// "content-type": MimeType.ApplicationJson; +// }; +// body: { +// potato: boolean; +// }; +// } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb93aa..63a6beb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,12 +56,6 @@ importers: eslint-plugin-no-secrets: specifier: ^0.8.9 version: 0.8.9(eslint@8.45.0) - eslint-plugin-sonarjs: - specifier: ^0.19.0 - version: 0.19.0(eslint@8.45.0) - eslint-plugin-unicorn: - specifier: ^48.0.0 - version: 48.0.0(eslint@8.45.0) eslint-plugin-workspaces: specifier: ^0.9.0 version: 0.9.0 @@ -143,6 +137,9 @@ importers: atlassian-openapi: specifier: ^1.0.18 version: 1.0.18 + json-schema-traverse: + specifier: ^1.0.0 + version: 1.0.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -500,11 +497,6 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} - engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-option@7.21.0: resolution: {integrity: sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==} engines: {node: '>=6.9.0'} @@ -3014,11 +3006,6 @@ packages: ieee754: 1.2.1 dev: true - /builtin-modules@3.3.0: - resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} - engines: {node: '>=6'} - dev: true - /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -3126,13 +3113,6 @@ packages: resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==} dev: true - /clean-regexp@1.0.0: - resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} - engines: {node: '>=4'} - dependencies: - escape-string-regexp: 1.0.5 - dev: true - /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3626,15 +3606,6 @@ packages: eslint: 8.45.0 dev: true - /eslint-plugin-sonarjs@0.19.0(eslint@8.45.0): - resolution: {integrity: sha512-6+s5oNk5TFtVlbRxqZN7FIGmjdPCYQKaTzFPmqieCmsU1kBYDzndTeQav0xtQNwZJWu5awWfTGe8Srq9xFOGnw==} - engines: {node: '>=14'} - peerDependencies: - eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - eslint: 8.45.0 - dev: true - /eslint-plugin-turbo@1.9.3(eslint@8.45.0): resolution: {integrity: sha512-ZsRtksdzk3v+z5/I/K4E50E4lfZ7oYmLX395gkrUMBz4/spJlYbr+GC8hP9oVNLj9s5Pvnm9rLv/zoj5PVYaVw==} peerDependencies: @@ -3643,30 +3614,6 @@ packages: eslint: 8.45.0 dev: true - /eslint-plugin-unicorn@48.0.0(eslint@8.45.0): - resolution: {integrity: sha512-8fk/v3p1ro34JSVDBEmtOq6EEQRpMR0iTir79q69KnXFZ6DJyPkT3RAi+ZoTqhQMdDSpGh8BGR68ne1sP5cnAA==} - engines: {node: '>=16'} - peerDependencies: - eslint: '>=8.44.0' - dependencies: - '@babel/helper-validator-identifier': 7.22.5 - '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - ci-info: 3.8.0 - clean-regexp: 1.0.0 - eslint: 8.45.0 - esquery: 1.5.0 - indent-string: 4.0.0 - is-builtin-module: 3.2.1 - jsesc: 3.0.2 - lodash: 4.17.21 - pluralize: 8.0.0 - read-pkg-up: 7.0.1 - regexp-tree: 0.1.27 - regjsparser: 0.10.0 - semver: 7.5.4 - strip-indent: 3.0.0 - dev: true - /eslint-plugin-workspaces@0.9.0: resolution: {integrity: sha512-krMuZ+yZgzwv1oTBfz50oamNVPDIm7CDyot3i1GRKBqMD2oXAwnXHLQWH7ctpV8k6YVrkhcaZhuV9IJxD8OPAQ==} dependencies: @@ -4461,13 +4408,6 @@ packages: has-tostringtag: 1.0.0 dev: true - /is-builtin-module@3.2.1: - resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} - engines: {node: '>=6'} - dependencies: - builtin-modules: 3.3.0 - dev: true - /is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -5173,12 +5113,6 @@ packages: hasBin: true dev: true - /jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} - engines: {node: '>=6'} - hasBin: true - dev: true - /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true @@ -5896,11 +5830,6 @@ packages: v8flags: 4.0.0 dev: true - /pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - dev: true - /preferred-pm@3.0.3: resolution: {integrity: sha512-+wZgbxNES/KlJs9q40F/1sfOd/j7f1O9JaHcW5Dsn3aUUOZg3L2bjpVUcKV2jvtElYfoTuQiNeMfQJ4kwUAhCQ==} engines: {node: '>=10'} @@ -6045,11 +5974,6 @@ packages: '@babel/runtime': 7.21.5 dev: true - /regexp-tree@0.1.27: - resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} - hasBin: true - dev: true - /regexp.prototype.flags@1.5.0: resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} engines: {node: '>= 0.4'} @@ -6076,13 +6000,6 @@ packages: unicode-match-property-value-ecmascript: 2.1.0 dev: true - /regjsparser@0.10.0: - resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} - hasBin: true - dependencies: - jsesc: 0.5.0 - dev: true - /regjsparser@0.9.1: resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} hasBin: true From d0dbe772b9dbe23b394bbcc7e96af238804fbfdb Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Mon, 4 Dec 2023 17:04:49 -0800 Subject: [PATCH 03/16] path params working! --- packages/rest/package.json | 1 + .../rest/src/transform/controller-meta.ts | 130 ++++++++++++------ packages/test/src/controller.ts | 14 +- pnpm-lock.yaml | 11 +- 4 files changed, 107 insertions(+), 49 deletions(-) diff --git a/packages/rest/package.json b/packages/rest/package.json index 0862773..c61b0cb 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@jest/globals": "^29.5.0", "@types/jest": "^29.4.0", + "@types/json-schema": "^7.0.15", "@types/lodash": "^4.14.202", "@types/node": "^18.15.11", "eslint": "^8.45.0", diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index 617d868..56db98a 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -7,6 +7,7 @@ import { Project } from "./project"; import { FileTransformer } from "./transformers/file-transformer"; import { JSONSchemaType } from "ajv"; +import { JSONSchema7 } from "json-schema"; import { ReferenceType, SubNodeParser, UnionType } from "ts-json-schema-generator"; export abstract class OpenApiSpecHolder { @@ -251,6 +252,7 @@ export class ControllerMeta { description: "OK", }, }, + parameters: this.generatePathParamsSpecs(route), }, }, }, @@ -266,36 +268,44 @@ export class ControllerMeta { if (property == undefined) throw new Error("No pathParams property found"); const declarations = property.getDeclarations() as tsm.PropertySignature[]; const typeNodes = declarations.map((declaration) => declaration.getTypeNodeOrThrow()); - const paramDeclaractions = typeNodes.map(typeNode => typeNode.getType()) - .map(type => type.getProperties()) - .flat() - .reduce((acc, property) => { - const name = property.getName(); - const arr = acc[name] || []; - const declarations = property.getDeclarations() as tsm.PropertySignature[]; - arr.push(...declarations); - acc[name] = arr; - return acc; - }, {} as Record); const paramObjectSchema = this.project.schemaGenerator.createSchemaFromNodes( [ts.factory.createUnionTypeNode(typeNodes.map((typeNode) => typeNode.compilerNode))], ); - const schemas = Object.fromEntries( - Object.entries(paramDeclaractions).map(([name, declarations]) => { - const typeSymbols = declarations.map((declaration) => declaration.getSymbolOrThrow()); - const typeRefs = typeSymbols.map(symbol => { - const typeRef = ts.factory.createTypeReferenceNode(name, []); - - (typeRef as any).symbol = symbol.compilerSymbol; - return typeRef; - }); - const schema = this.project.schemaGenerator.createSchemaFromNodes(typeRefs); - return [name, schema] as const; - }), - ); - return []; + const propertySchemas = getUnifiedPropertySchemas(paramObjectSchema, "/"); + + return Object.entries(propertySchemas).map(([name, schema]) => { + // Just take the first provided description and example for now + const description = schema.schemaSet.find((schema) => schema.description)?.description; + let example = (schema.schemaSet.find((schema) => schema.examples))?.examples; + if (Array.isArray(example)) { + example = example[0]; + } + + // If every schema is deprecated, then the parameter is deprecated + const deprecated = schema.schemaSet.every((schema) => (schema as { deprecated?: boolean }).deprecated); + + const mergedSchema = schema?.schemaSet.length === 1 + ? schema.schemaSet[0] + : { + anyOf: schema.schemaSet, + }; + + mergedSchema.definitions = paramObjectSchema.definitions; + + const paramObject: OpenAPIV3_1.ParameterObject = { + name, + in: "path", + required: schema.required, + description, + example, + deprecated, + schema: mergedSchema as OpenAPIV3.NonArraySchemaObject, + }; + + return paramObject; + }); } // private buildRequestInfo(routeIndex: RouteIndex, inputType: ts.Type): RequestInfo { @@ -478,25 +488,57 @@ export class ControllerMeta { } // Traverses the schema and extracts a unified definition for the property at the given path -// export function getUnifiedPropertySchema(schema: IJsonSchema, path: string) { -// // Take a path from the json schema and convert it to a path in validated object -// const convertJsonSchemaPathToPropertyPath = (path: string) => { -// return path -// // replace properties -// .replace(/\/properties\//g, ".") -// // replace items and index -// .replace(/\/items\/(\d+)\//g, "[].") -// // replace anyOf and index -// .replace(/\/anyOf\/(\d+)\//g, ".") -// // replace oneOf and index -// .replace(/\/oneOf\/(\d+)\//g, ".") -// // replace allOf and index -// .replace(/\/allOf\/(\d+)\//g, ".") -// } -// const schema -// -// traverse() -// } +export function getUnifiedPropertySchemas(schema: JSONSchema7, parentPath: string) { + // Take a path from the json schema and convert it to a path in validated object + const convertJsonSchemaPathIfPropertyPath = (path: string) => { + if (path.split("/").at(-2) !== "properties") { + return "/"; + } + + return path + // replace properties + .replace(/\/properties\//g, "/") + // replace items and index + .replace(/\/items\/(\d+)\//g, "/") + // replace anyOf and index + .replace(/\/anyOf\/(\d+)\//g, "/") + // replace oneOf and index + .replace(/\/oneOf\/(\d+)\//g, "/") + // replace allOf and index + .replace(/\/allOf\/(\d+)\//g, "/"); + }; + + const isDirectChildPath = (childPath: string, parentPath: string) => { + const childPathParts = childPath.split("/").filter(part => part !== ""); + const parentPathParts = parentPath.split("/").filter(part => part !== ""); + + if (childPathParts.length !== parentPathParts.length + 1) { + return false; + } + + return parentPathParts.every((part, index) => part === childPathParts[index]); + }; + + const schemas: Record = {}; + + traverse(schema, { + allKeys: false, + cb: { + pre: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { + const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); + + if (isDirectChildPath(convertedPath, parentPath)) { + const schemaSet = schemas[keyIndex || ""] || { schemaSet: [], required: true }; + schemaSet.required = !schemaSet.required ? false : parentSchema?.required?.includes(keyIndex) ?? false; + schemaSet.schemaSet.push(schema); + schemas[keyIndex || ""] = schemaSet; + } + }, + }, + }); + + return schemas; +} function deparameterizePath(path: string) { return path.replaceAll(/:[^/]+/g, ":param"); diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index bba9e06..0401ce8 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -29,9 +29,15 @@ interface RoutePostInputJSON extends HttpRequest { /** * Very cool property that does a thing * @pattern ^[a-z]+$ + * @example "true" */ reallyCool: "true" | "false"; - } + + /** + * Even cooler property + */ + evenCooler?: number; + }; } interface RoutePostInputCSV extends HttpRequest { @@ -44,12 +50,14 @@ interface RoutePostInputCSV extends HttpRequest { * @minLength 5 */ "csv-header": string; - }; body: TestStringType; pathParams: { + /** + * @deprecated + */ reallyCool: "stuff"; - } + }; } type RoutePostInput = RoutePostInputJSON | RoutePostInputCSV; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63a6beb..9f0e8c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@types/jest': specifier: ^29.4.0 version: 29.4.0 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/lodash': specifier: ^4.14.202 version: 4.14.202 @@ -249,7 +252,7 @@ packages: engines: {node: '>= 16'} dependencies: '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.12 + '@types/json-schema': 7.0.15 '@types/lodash.clonedeep': 4.5.7 js-yaml: 4.1.0 lodash.clonedeep: 4.5.0 @@ -2370,6 +2373,10 @@ packages: /@types/json-schema@7.0.12: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} /@types/liftoff@4.0.0: resolution: {integrity: sha512-Ny/PJkO6nxWAQnaet8q/oWz15lrfwvdvBpuY4treB0CSsBO1CG0fVuNLngR3m3bepQLd+E4c3Y3DlC2okpUvPw==} @@ -6518,7 +6525,7 @@ packages: engines: {node: '>=10.0.0'} hasBin: true dependencies: - '@types/json-schema': 7.0.12 + '@types/json-schema': 7.0.15 commander: 11.0.0 glob: 8.1.0 json5: 2.2.3 From 91edb68eba115f1f327f393c61a5373c86c8c8a1 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Sat, 9 Dec 2023 17:06:59 -0800 Subject: [PATCH 04/16] Working body and responses with content type discrimination --- packages/core/package.json | 2 +- packages/rest/package.json | 5 +- packages/rest/src/runtime/http-event.mts | 310 +++++++++------ packages/rest/src/runtime/index.mts | 16 +- packages/rest/src/runtime/route-holder.mts | 4 +- packages/rest/src/runtime/router.mts | 4 +- .../rest/src/transform/controller-meta.ts | 368 ++++++------------ .../rest/src/transform/json-schema-utils.ts | 268 +++++++++++++ packages/rest/src/transform/project.ts | 1 + packages/rest/src/transform/transform.ts | 11 +- .../transformers/node-transformer.ts | 2 +- .../processors/chain-route-processor.ts | 44 ++- packages/test/package.json | 2 +- packages/test/src/controller.ts | 115 ++++-- packages/test/src/rest.ts | 9 +- pnpm-lock.yaml | 55 ++- 16 files changed, 746 insertions(+), 470 deletions(-) create mode 100644 packages/rest/src/transform/json-schema-utils.ts diff --git a/packages/core/package.json b/packages/core/package.json index cd7bead..97c06b0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,7 @@ "author": "John Conley", "devDependencies": { "@jest/globals": "^29.5.0", - "@nrfcloud/ts-json-schema-transformer": "^1.2.4", + "@nrfcloud/ts-json-schema-transformer": "^1.2.5", "@types/jest": "^29.4.0", "@types/node": "^18.15.11", "esbuild": "^0.17.18", diff --git a/packages/rest/package.json b/packages/rest/package.json index c61b0cb..a4b69bf 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -3,8 +3,9 @@ "description": "A nornir library", "version": "1.3.0", "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.1.0", "@nornir/core": "workspace:^", - "@nrfcloud/ts-json-schema-transformer": "^1.2.4", + "@nrfcloud/ts-json-schema-transformer": "^1.2.5", "@types/aws-lambda": "^8.10.115", "ajv": "^8.12.0", "atlassian-openapi": "^1.0.18", @@ -13,7 +14,7 @@ "openapi-types": "^12.1.0", "trouter": "^3.2.1", "ts-is-present": "^1.2.2", - "ts-json-schema-generator": "^1.4.0", + "ts-json-schema-generator": "^1.5.0", "ts-morph": "^20.0.0", "tsutils": "^3.21.0" }, diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index f7f9d0e..95e08a7 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -13,35 +13,34 @@ export type UnparsedHttpEvent = Omit & { rawQuery: string } -// export type HttpHeaders = { -// readonly "content-type": MimeType; -// } & Record - export type HttpHeadersWithContentType = { - readonly "content-type": MimeType | AnyMimeType + readonly "content-type": MimeType } & HttpHeaders; +export type HttpHeadersWithoutContentType = { + readonly "content-type"?: undefined +} & HttpHeaders + export type HttpHeaders = Record; export interface HttpRequest { - readonly headers: HttpHeadersWithContentType; + readonly headers: HttpHeadersWithContentType | HttpHeadersWithoutContentType; - readonly query: Record | Array>; + readonly query: QueryParams; readonly body?: unknown; readonly pathParams: PathParams; } -/** - * @nornirIgnore - */ +type QueryParams = Record | Array>; + type PathParams = Record; export interface HttpRequestEmpty extends HttpRequest { headers: { - "content-type": AnyMimeType; + "content-type": never } // body?: undefined; } @@ -55,7 +54,7 @@ export interface HttpRequestJSON extends HttpRequest { export interface HttpResponse { readonly statusCode: HttpStatusCode; - readonly headers: HttpHeadersWithContentType; + readonly headers: HttpHeadersWithContentType | HttpHeadersWithoutContentType; readonly body?: unknown; } @@ -65,106 +64,197 @@ export interface SerializedHttpResponse extends Omit { export interface HttpResponseEmpty extends HttpResponse { headers: { - "content-type": AnyMimeType; + "content-type": never }, body?: undefined; } -export enum HttpStatusCode { - Continue = "100", - SwitchingProtocols = "101", - Processing = "102", - Ok = "200", - Created = "201", - Accepted = "202", - NonAuthoritativeInformation = "203", - NoContent = "204", - ResetContent = "205", - PartialContent = "206", - MultiStatus = "207", - AlreadyReported = "208", - IMUsed = "226", - MultipleChoices = "300", - MovedPermanently = "301", - Found = "302", - SeeOther = "303", - NotModified = "304", - UseProxy = "305", - TemporaryRedirect = "307", - PermanentRedirect = "308", - BadRequest = "400", - Unauthorized = "401", - PaymentRequired = "402", - Forbidden = "403", - NotFound = "404", - MethodNotAllowed = "405", - NotAcceptable = "406", - ProxyAuthenticationRequired = "407", - RequestTimeout = "408", - Conflict = "409", - Gone = "410", - LengthRequired = "411", - PreconditionFailed = "412", - PayloadTooLarge = "413", - RequestURITooLong = "414", - UnsupportedMediaType = "415", - RequestedRangeNotSatisfiable = "416", - ExpectationFailed = "417", - ImATeapot = "418", - MisdirectedRequest = "421", - UnprocessableEntity = "422", - Locked = "423", - FailedDependency = "424", - UpgradeRequired = "426", - PreconditionRequired = "428", - TooManyRequests = "429", - RequestHeaderFieldsTooLarge = "431", - UnavailableForLegalReasons = "451", - InternalServerError = "500", - NotImplemented = "501", - BadGateway = "502", - ServiceUnavailable = "503", - GatewayTimeout = "504", - HTTPVersionNotSupported = "505", - VariantAlsoNegotiates = "506", - InsufficientStorage = "507", - LoopDetected = "508", - NotExtended = "510", -} +// export enum HttpStatusCode { +// Continue = "100", +// SwitchingProtocols = "101", +// Processing = "102", +// Ok = "200", +// Created = "201", +// Accepted = "202", +// NonAuthoritativeInformation = "203", +// NoContent = "204", +// ResetContent = "205", +// PartialContent = "206", +// MultiStatus = "207", +// AlreadyReported = "208", +// IMUsed = "226", +// MultipleChoices = "300", +// MovedPermanently = "301", +// Found = "302", +// SeeOther = "303", +// NotModified = "304", +// UseProxy = "305", +// TemporaryRedirect = "307", +// PermanentRedirect = "308", +// BadRequest = "400", +// Unauthorized = "401", +// PaymentRequired = "402", +// Forbidden = "403", +// NotFound = "404", +// MethodNotAllowed = "405", +// NotAcceptable = "406", +// ProxyAuthenticationRequired = "407", +// RequestTimeout = "408", +// Conflict = "409", +// Gone = "410", +// LengthRequired = "411", +// PreconditionFailed = "412", +// PayloadTooLarge = "413", +// RequestURITooLong = "414", +// UnsupportedMediaType = "415", +// RequestedRangeNotSatisfiable = "416", +// ExpectationFailed = "417", +// ImATeapot = "418", +// MisdirectedRequest = "421", +// UnprocessableEntity = "422", +// Locked = "423", +// FailedDependency = "424", +// UpgradeRequired = "426", +// PreconditionRequired = "428", +// TooManyRequests = "429", +// RequestHeaderFieldsTooLarge = "431", +// UnavailableForLegalReasons = "451", +// InternalServerError = "500", +// NotImplemented = "501", +// BadGateway = "502", +// ServiceUnavailable = "503", +// GatewayTimeout = "504", +// HTTPVersionNotSupported = "505", +// VariantAlsoNegotiates = "506", +// InsufficientStorage = "507", +// LoopDetected = "508", +// NotExtended = "510", +// } -export type AnyMimeType = Nominal -export const AnyMimeType = "*/*" as AnyMimeType; - -export enum MimeType { - None = "", - ApplicationJson = "application/json", - ApplicationOctetStream = "application/octet-stream", - ApplicationPdf = "application/pdf", - ApplicationXWwwFormUrlencoded = "application/x-www-form-urlencoded", - ApplicationZip = "application/zip", - ApplicationGzip = "application/gzip", - ApplicationBzip = "application/bzip", - ApplicationBzip2 = "application/bzip2", - ApplicationLdJson = "application/ld+json", - FontWoff = "font/woff", - FontWoff2 = "font/woff2", - FontTtf = "font/ttf", - FontOtf = "font/otf", - AudioMpeg = "audio/mpeg", - AudioXWav = "audio/x-wav", - ImageGif = "image/gif", - ImageJpeg = "image/jpeg", - ImagePng = "image/png", - MultipartFormData = "multipart/form-data", - TextCss = "text/css", - TextCsv = "text/csv", - TextHtml = "text/html", - TextPlain = "text/plain", - TextXml = "text/xml", - VideoMpeg = "video/mpeg", - VideoMp4 = "video/mp4", - VideoQuicktime = "video/quicktime", - VideoXMsVideo = "video/x-msvideo", - VideoXFlv = "video/x-flv", - VideoWebm = "video/webm", -} +export type HttpStatusCode = + | "100" + | "101" + | "102" + | "200" + | "201" + | "202" + | "203" + | "204" + | "205" + | "206" + | "207" + | "208" + | "226" + | "300" + | "301" + | "302" + | "303" + | "304" + | "305" + | "307" + | "308" + | "400" + | "401" + | "402" + | "403" + | "404" + | "405" + | "406" + | "407" + | "408" + | "409" + | "410" + | "411" + | "412" + | "413" + | "414" + | "415" + | "416" + | "417" + | "418" + | "421" + | "422" + | "423" + | "424" + | "426" + | "428" + | "429" + | "431" + | "451" + | "500" + | "501" + | "502" + | "503" + | "504" + | "505" + | "506" + | "507" + | "508" + | "510"; + + +// export enum MimeType { +// Any = "*/*", +// ApplicationJson = "application/json", +// ApplicationOctetStream = "application/octet-stream", +// ApplicationPdf = "application/pdf", +// ApplicationXWwwFormUrlencoded = "application/x-www-form-urlencoded", +// ApplicationZip = "application/zip", +// ApplicationGzip = "application/gzip", +// ApplicationBzip = "application/bzip", +// ApplicationBzip2 = "application/bzip2", +// ApplicationLdJson = "application/ld+json", +// FontWoff = "font/woff", +// FontWoff2 = "font/woff2", +// FontTtf = "font/ttf", +// FontOtf = "font/otf", +// AudioMpeg = "audio/mpeg", +// AudioXWav = "audio/x-wav", +// ImageGif = "image/gif", +// ImageJpeg = "image/jpeg", +// ImagePng = "image/png", +// MultipartFormData = "multipart/form-data", +// TextCss = "text/css", +// TextCsv = "text/csv", +// TextHtml = "text/html", +// TextPlain = "text/plain", +// TextXml = "text/xml", +// VideoMpeg = "video/mpeg", +// VideoMp4 = "video/mp4", +// VideoQuicktime = "video/quicktime", +// VideoXMsVideo = "video/x-msvideo", +// VideoXFlv = "video/x-flv", +// VideoWebm = "video/webm", +// } +export type MimeType = + | "*/*" + | "application/json" + | "application/octet-stream" + | "application/pdf" + | "application/x-www-form-urlencoded" + | "application/zip" + | "application/gzip" + | "application/bzip" + | "application/bzip2" + | "application/ld+json" + | "font/woff" + | "font/woff2" + | "font/ttf" + | "font/otf" + | "audio/mpeg" + | "audio/x-wav" + | "image/gif" + | "image/jpeg" + | "image/png" + | "multipart/form-data" + | "text/css" + | "text/csv" + | "text/html" + | "text/plain" + | "text/xml" + | "video/mpeg" + | "video/mp4" + | "video/quicktime" + | "video/x-msvideo" + | "video/x-flv" + | "video/webm"; diff --git a/packages/rest/src/runtime/index.mts b/packages/rest/src/runtime/index.mts index bbe738d..03e7b60 100644 --- a/packages/rest/src/runtime/index.mts +++ b/packages/rest/src/runtime/index.mts @@ -1,4 +1,11 @@ import {nornir} from "@nornir/core"; +import {UnparsedHttpEvent} from './http-event.mjs'; +import {normalizeEventHeaders} from "./utils.mjs" +import {httpEventParser} from './parse.mjs' +import {httpResponseSerializer} from './serialize.mjs' + +import {Router} from './router.mjs'; +import {httpErrorHandler} from "./error.mjs"; export { GetChain, Controller, PostChain, DeleteChain, HeadChain, OptionsChain, PatchChain, PutChain, @@ -7,7 +14,7 @@ export { export { HttpResponse, HttpRequest, HttpEvent, HttpMethod, HttpRequestEmpty, HttpResponseEmpty, HttpStatusCode, HttpRequestJSON, HttpHeaders, MimeType, - UnparsedHttpEvent, SerializedHttpResponse, AnyMimeType + UnparsedHttpEvent, SerializedHttpResponse } from './http-event.mjs'; export {RouteHolder, NornirRestRequestValidationError} from './route-holder.mjs' export {NornirRestRequestError, NornirRestError, httpErrorHandler, mapError, mapErrorClass} from './error.mjs' @@ -17,13 +24,6 @@ export {httpResponseSerializer, HttpBodySerializer, HttpBodySerializerMap} from export {normalizeEventHeaders, normalizeHeaders, getContentType} from "./utils.mjs" export {Router} from "./router.mjs" -import {UnparsedHttpEvent} from './http-event.mjs'; -import {normalizeEventHeaders} from "./utils.mjs" -import {httpEventParser} from './parse.mjs' -import {httpResponseSerializer} from './serialize.mjs' - -import {Router} from './router.mjs'; -import {httpErrorHandler} from "./error.mjs"; export const router = Router.build export function restChain() { diff --git a/packages/rest/src/runtime/route-holder.mts b/packages/rest/src/runtime/route-holder.mts index dd43827..1a01cb5 100644 --- a/packages/rest/src/runtime/route-holder.mts +++ b/packages/rest/src/runtime/route-holder.mts @@ -50,10 +50,10 @@ export class NornirRestRequestValidationError exten toHttpResponse(): HttpResponse { return { - statusCode: HttpStatusCode.UnprocessableEntity, + statusCode: "422", body: {errors: this.errors}, headers: { - 'content-type': MimeType.ApplicationJson, + 'content-type': "application/json" }, } } diff --git a/packages/rest/src/runtime/router.mts b/packages/rest/src/runtime/router.mts index 20b2331..97bed8f 100644 --- a/packages/rest/src/runtime/router.mts +++ b/packages/rest/src/runtime/router.mts @@ -90,10 +90,10 @@ export class NornirRouteNotFoundError extends NornirRestRequestError(); @@ -25,6 +31,9 @@ export abstract class OpenApiSpecHolder { oas: spec, dispute: { alwaysApply: true, + mergeDispute: true, + prefix: "", + suffix: "", }, })) as MergeInput; @@ -213,26 +222,33 @@ export class ControllerMeta { throw new Error(`Route already registered: ${index.method} ${index.path}`); } - OpenApiSpecHolder.addSpecForFile(this.source, this.generateRouteSpec(routeInfo)); - - methods.set(index.method, { + const modifiedRouteInfo = { method: routeInfo.method, path: this.basePath + routeInfo.path.toLowerCase(), description: routeInfo.description, // requestInfo: this.buildRequestInfo(index, routeInfo.input), // responseInfo: this.buildResponseInfo(index, routeInfo.output), + outputSchema: routeInfo.outputSchema, + inputSchema: routeInfo.inputSchema, filePath: routeInfo.filePath, summary: routeInfo.summary, deprecated: routeInfo.deprecated, operationId: routeInfo.operationId, tags: routeInfo.tags, input: routeInfo.input, - output: routeInfo.output, - }); + }; + + OpenApiSpecHolder.addSpecForFile(this.source, this.generateRouteSpec(modifiedRouteInfo)); + + methods.set(index.method, modifiedRouteInfo); } private generateRouteSpec(route: RouteInfo): OpenAPIV3_1.Document { - this.generatePathParamsSpecs(route); + const inputSchema = moveRefsToAllOf(route.inputSchema); + const routeIndex = this.getRouteIndex(route); + const dereferencedInputSchema = dereferenceSchema(inputSchema); + const outputSchema = moveRefsToAllOf(route.outputSchema); + const dereferencedOutputSchema = dereferenceSchema(outputSchema); return { openapi: "3.0.3", info: { @@ -240,40 +256,35 @@ export class ControllerMeta { version: "1.0.0", }, paths: { - [this.basePath + route.path.toLowerCase()]: { + [route.path]: { [route.method.toLowerCase()]: { deprecated: route.deprecated, tags: route.tags, operationId: route.operationId, summary: route.summary, description: route.description, - responses: { - 200: { - description: "OK", - }, - }, - parameters: this.generatePathParamsSpecs(route), + responses: this.generateOutputType(routeIndex, dereferencedOutputSchema), + requestBody: this.generateRequestBody(routeIndex, dereferencedInputSchema), + parameters: [ + ...this.generateParametersForSchemaPath(dereferencedInputSchema, "/pathParams", "path"), + ...this.generateParametersForSchemaPath(dereferencedInputSchema, "/query", "query"), + ...this.generateParametersForSchemaPath(dereferencedInputSchema, "/headers", "header"), + ], }, }, }, components: { + schemas: { + ...rewriteRefsForOpenApi(inputSchema).definitions, + ...rewriteRefsForOpenApi(outputSchema).definitions, + }, parameters: {}, }, } as OpenAPIV3_1.Document; } - private generatePathParamsSpecs(routeInfo: RouteInfo): OpenAPIV3_1.ParameterObject[] { - const wrapped = tsm.createWrappedNode(routeInfo.input, { typeChecker: this.project.checker }); - const property = wrapped.getType().getProperty("pathParams"); - if (property == undefined) throw new Error("No pathParams property found"); - const declarations = property.getDeclarations() as tsm.PropertySignature[]; - const typeNodes = declarations.map((declaration) => declaration.getTypeNodeOrThrow()); - - const paramObjectSchema = this.project.schemaGenerator.createSchemaFromNodes( - [ts.factory.createUnionTypeNode(typeNodes.map((typeNode) => typeNode.compilerNode))], - ); - - const propertySchemas = getUnifiedPropertySchemas(paramObjectSchema, "/"); + private generateParametersForSchemaPath(inputSchema: JSONSchema7, schemaPath: string, paramType: string) { + const propertySchemas = getUnifiedPropertySchemas(inputSchema, schemaPath); return Object.entries(propertySchemas).map(([name, schema]) => { // Just take the first provided description and example for now @@ -284,7 +295,11 @@ export class ControllerMeta { } // If every schema is deprecated, then the parameter is deprecated - const deprecated = schema.schemaSet.every((schema) => (schema as { deprecated?: boolean }).deprecated); + const deprecated = schema.schemaSet.every((schema) => + (schema as { + deprecated?: boolean; + }).deprecated + ); const mergedSchema = schema?.schemaSet.length === 1 ? schema.schemaSet[0] @@ -292,252 +307,88 @@ export class ControllerMeta { anyOf: schema.schemaSet, }; - mergedSchema.definitions = paramObjectSchema.definitions; - const paramObject: OpenAPIV3_1.ParameterObject = { name, - in: "path", + in: paramType, required: schema.required, description, example, deprecated, - schema: mergedSchema as OpenAPIV3.NonArraySchemaObject, + schema: rewriteRefsForOpenApi(unresolveRefs(mergedSchema)) as OpenAPIV3.NonArraySchemaObject, }; return paramObject; }); } - // private buildRequestInfo(routeIndex: RouteIndex, inputType: ts.Type): RequestInfo { - // const paramterData: { [key in ParameterType]: { [name: string]: ParameterMeta } } = { - // path: {}, - // header: {}, - // query: {}, - // }; - // const body: { [contentType: string]: Metadata } = {}; - // - // const meta = MetadataFactory.generate( - // this.project.checker, - // ControllerMeta.metadataCollection, - // inputType, - // { resolve: false, constant: true }, - // ); - // for (const object of meta.objects) { - // for (const property of object.properties) { - // const key = MetadataUtils.getSoleLiteral(property.key); - // if (key != null && !isRequestTypeField(key)) { - // throw new Error(`Invalid request field: ${key}`); - // } - // switch (key) { - // case "query": - // this.buildParameterInfo(property.value, "query", paramterData); - // break; - // case "pathParams": - // this.buildParameterInfo(property.value, "path", paramterData); - // break; - // case "headers": - // this.buildParameterInfo(property.value, "header", paramterData); - // break; - // } - // } - // this.buildBodyInfo(routeIndex, object, body); - // } - // - // return { - // body, - // parameters: [ - // ...Object.values(paramterData.path), - // ...Object.values(paramterData.header), - // ...Object.values(paramterData.query), - // ], - // }; - // } + private generateOutputType(routeIndex: RouteIndex, outputSchema: JSONSchema7): OpenAPIV3_1.ResponsesObject { + const responses: OpenAPIV3_1.ResponsesObject = {}; + const statusCodeDiscriminatedSchemas = resolveDiscriminantProperty(outputSchema, "/statusCode"); - // private buildResponseInfo(routeIndex: RouteIndex, outputType: ts.Type): ResponseInfo { - // const responses: ResponseInfo = {}; - // const meta = MetadataFactory.generate( - // this.project.checker, - // ControllerMeta.metadataCollection, - // outputType, - // { resolve: false, constant: true }, - // ); - // for (const object of meta.objects) { - // const statusCodeProp = MetadataUtils.getPropertyByStringIndex(object, "statusCode"); - // if (statusCodeProp == null) { - // throw new Error("Response must have a statusCode property"); - // } - // let statusCodes = statusCodeProp.constants.map((c) => c.values).flat().map(v => v.toString()); - // if (HttpStatusCodes.every((c) => statusCodes.includes(c))) { - // strictError( - // this.project, - // new StrictTransformationError( - // "Response status codes must be literal values", - // "Defaulting response status code to 200", - // routeIndex, - // ), - // ); - // statusCodes = ["200"]; - // } - // - // if (statusCodes.length === 0) { - // throw new Error("Literal status codes must be specified"); - // } - // - // for (const statusCode of statusCodes) { - // if (responses[statusCode] != null) { - // throw new Error(`Response already defined for status code ${statusCode}`); - // } - // const headerParamHolder: { [key in ParameterType]: { [name: string]: ParameterMeta } } = { - // path: {}, - // header: {}, - // query: {}, - // }; - // const headerProp = MetadataUtils.getPropertyByStringIndex(object, "headers"); - // if (headerProp != null) { - // this.buildParameterInfo(headerProp, "header", headerParamHolder); - // } - // const result: ResponseInfo[string] = { - // body: {}, - // headers: Object.values(headerParamHolder.header), - // }; - // this.buildBodyInfo(routeIndex, object, result.body); - // responses[statusCode] = result; - // } - // } - // return responses; - // } + if (statusCodeDiscriminatedSchemas == null) { + throw new TransformationError("Could not resolve status codes for some responses", routeIndex); + } - // private buildBodyInfo( - // routeIndex: RouteIndex, - // object: MetadataObject, - // bodyTypes: { [contentType: string]: Metadata }, - // ) { - // let contentType = this.getContentTypeFromObject(object); - // const bodyType = MetadataUtils.getPropertyByStringIndex(object, "body"); - // if (bodyType == null || (bodyType.empty() && !bodyType.nullable)) { - // return; - // } - // if (contentType == null) { - // strictError( - // this.project, - // new StrictTransformationError( - // "No content type specified for body", - // "No content type specified, defaulting to application/json", - // routeIndex, - // ), - // ); - // } - // contentType = contentType || DEFAULT_CONTENT_TYPE; - // const existingBody = bodyTypes[contentType]; - // if (existingBody != null) { - // if (MetadataUtils.equal(existingBody, bodyType)) { - // return; - // } else { - // throw new Error(`Content type ${contentType} already defined`); - // } - // } else { - // bodyTypes[contentType] = bodyType; - // } - // } + for (const [statusCode, schema] of Object.entries(statusCodeDiscriminatedSchemas)) { + const headers = this.generateParametersForSchemaPath(schema, "/headers", "header"); + const contentTypeDiscriminatedSchemas = resolveDiscriminantProperty(schema, "/headers/content-type"); + const bodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; + if (contentTypeDiscriminatedSchemas == null && bodySchema != null) { + throw new TransformationError(`Could not resolve content types for "${statusCode}" responses`, routeIndex); + } - // private getContentTypeFromObject(metaObject: MetadataObject): string | null { - // const headers = MetadataUtils.getPropertyByStringIndex(metaObject, "headers"); - // if (headers == null) { - // return null; - // } - // if (headers.objects.length !== 1) { - // return null; - // } - // const headerObject = headers.objects[0]; - // const contentType = MetadataUtils.getPropertyByStringIndex(headerObject, "content-type"); - // if (contentType == null) { - // return null; - // } - // return MetadataUtils.getSoleLiteral(contentType); - // } + const content = contentTypeDiscriminatedSchemas == null ? undefined : Object.fromEntries( + Object.entries(contentTypeDiscriminatedSchemas).map(([contentType, schema]) => { + const branchBodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; + return [ + contentType, + { + schema: rewriteRefsForOpenApi( + unresolveRefs(joinSchemas(branchBodySchema.schemaSet)), + ) as OpenAPIV3.NonArraySchemaObject, + }, + ]; + }), + ); + + responses[statusCode] = { + description: schema.description || "", + headers: Object.fromEntries(headers.map(header => [header.name, header])), + content, + }; + } - // private buildParameterInfo( - // inputMeta: Metadata, - // parameterType: ParameterType, - // parameterData: { [key in ParameterType]: { [name: string]: ParameterMeta } }, - // ) { - // for (const object of inputMeta.objects) { - // for (const property of object.properties) { - // const key = MetadataUtils.getSoleLiteral(property.key); - // if (key == null) { - // continue; - // } - // const meta = property.value; - // const existingParameter = parameterData[parameterType][key]; - // if (existingParameter != null) { - // parameterData[parameterType][key] = { - // name: key, - // meta: Metadata.merge(existingParameter.meta, meta), - // type: parameterType, - // }; - // } else { - // parameterData[parameterType][key] = { - // name: key, - // meta, - // type: parameterType, - // }; - // } - // } - // } - // } -} + return responses; + } -// Traverses the schema and extracts a unified definition for the property at the given path -export function getUnifiedPropertySchemas(schema: JSONSchema7, parentPath: string) { - // Take a path from the json schema and convert it to a path in validated object - const convertJsonSchemaPathIfPropertyPath = (path: string) => { - if (path.split("/").at(-2) !== "properties") { - return "/"; + private generateRequestBody(routeIndex: RouteIndex, inputSchema: JSONSchema7): OpenAPIV3_1.RequestBodyObject | void { + const bodySchema = getUnifiedPropertySchemas(inputSchema, "/")["body"]; + const contentTypeDiscriminatedSchemas = resolveDiscriminantProperty(inputSchema, "/headers/content-type"); + if (bodySchema == null) { + return; } - return path - // replace properties - .replace(/\/properties\//g, "/") - // replace items and index - .replace(/\/items\/(\d+)\//g, "/") - // replace anyOf and index - .replace(/\/anyOf\/(\d+)\//g, "/") - // replace oneOf and index - .replace(/\/oneOf\/(\d+)\//g, "/") - // replace allOf and index - .replace(/\/allOf\/(\d+)\//g, "/"); - }; - - const isDirectChildPath = (childPath: string, parentPath: string) => { - const childPathParts = childPath.split("/").filter(part => part !== ""); - const parentPathParts = parentPath.split("/").filter(part => part !== ""); - - if (childPathParts.length !== parentPathParts.length + 1) { - return false; + if (contentTypeDiscriminatedSchemas == null) { + throw new TransformationError("Could not resolve content types for request body", routeIndex); } - return parentPathParts.every((part, index) => part === childPathParts[index]); - }; - - const schemas: Record = {}; - - traverse(schema, { - allKeys: false, - cb: { - pre: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { - const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); - - if (isDirectChildPath(convertedPath, parentPath)) { - const schemaSet = schemas[keyIndex || ""] || { schemaSet: [], required: true }; - schemaSet.required = !schemaSet.required ? false : parentSchema?.required?.includes(keyIndex) ?? false; - schemaSet.schemaSet.push(schema); - schemas[keyIndex || ""] = schemaSet; - } - }, - }, - }); + const content = Object.fromEntries( + Object.entries(contentTypeDiscriminatedSchemas).map(([contentType, schema]) => { + const branchBodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; + return [ + contentType, + { + schema: rewriteRefsForOpenApi(unresolveRefs(joinSchemas(branchBodySchema.schemaSet))), + }, + ]; + }), + ); - return schemas; + return { + required: bodySchema.required, + content, + } as OpenAPIV3_1.RequestBodyObject; + } } function deparameterizePath(path: string) { @@ -550,7 +401,8 @@ export interface RouteInfo { description?: string; summary?: string; input: ts.TypeNode; - output: ts.TypeNode; + inputSchema: JSONSchema7; + outputSchema: JSONSchema7; filePath: string; tags?: string[]; deprecated?: boolean; diff --git a/packages/rest/src/transform/json-schema-utils.ts b/packages/rest/src/transform/json-schema-utils.ts new file mode 100644 index 0000000..82a777a --- /dev/null +++ b/packages/rest/src/transform/json-schema-utils.ts @@ -0,0 +1,268 @@ +// Traverses the schema and extracts a unified definitions for the properties under the given path +import $RefParser from "@apidevtools/json-schema-ref-parser"; +import dereference from "@apidevtools/json-schema-ref-parser/dist/lib/dereference.js"; +import { JSONSchema7 } from "json-schema"; +import traverse from "json-schema-traverse"; +import { cloneDeep } from "lodash"; + +const refParser = new $RefParser(); + +export function dereferenceSchema(schema: JSONSchema7) { + const clonedSchema = cloneDeep(schema); + refParser.parse(clonedSchema); + refParser.schema = clonedSchema; + dereference(refParser, { + dereference: { + circular: true, + onDereference(path: string, value: JSONSchema7) { + (value as { "x-resolved-ref": string })["x-resolved-ref"] = path; + }, + }, + }); + return clonedSchema; +} + +interface UnifiedPropertySchema { + schemaSet: JSONSchema7[]; + required: boolean; +} + +export function joinSchemas(schemaSet: JSONSchema7[]): JSONSchema7 { + if (schemaSet.length === 0) { + return {}; + } + if (schemaSet.length === 1) { + return schemaSet[0]; + } + + const joinedSchema: JSONSchema7 = { + allOf: schemaSet, + }; + + return joinedSchema; +} + +export function getUnifiedPropertySchemas( + schema: JSONSchema7, + parentPath: string, + ignoreAdditionalProperties = false, +): Record { + // Take a path from the json schema and convert it to a path in validated object + + const convertJsonSchemaPathIfPropertyPath = (path: string) => { + if (path.split("/").at(-2) !== "properties") { + return undefined; + } + + return path + // replace properties + .replace(/\/properties\//g, "/") + // replace items and index + .replace(/\/items\/(\d+)(\/|$)/g, "$2") + // replace anyOf and index + .replace(/\/anyOf\/(\d+)(\/|$)/g, "$2") + // replace oneOf and index + .replace(/\/oneOf\/(\d+)(\/|$)/g, "$2") + // replace allOf and index + .replace(/\/allOf\/(\d+)(\/|$)/g, "$2"); + }; + + const isUnifiedSchemaEmpty = (unifiedSchema: UnifiedPropertySchema) => { + return !unifiedSchema.required && ( + unifiedSchema.schemaSet.length === 0 + || unifiedSchema.schemaSet.every(schema => isSchemaEmpty(schema)) + ); + }; + + const isDirectChildPath = (childPath: string, parentPath: string) => { + const childPathParts = childPath.split("/").filter(part => part !== ""); + const parentPathParts = parentPath.split("/").filter(part => part !== ""); + + if (childPathParts.length !== parentPathParts.length + 1) { + return false; + } + + return parentPathParts.every((part, index) => part === childPathParts[index]); + }; + + const schemas: Record = {}; + let parentSchemas = 0; + + traverse(schema, { + allKeys: false, + cb: { + pre(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) { + const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); + if (convertedPath === parentPath && parentJsonPtr != "") { + parentSchemas++; + } + }, + post: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { + const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); + + const convertedParentPath = convertJsonSchemaPathIfPropertyPath(parentJsonPtr || "/"); + + if (convertedPath != null && isDirectChildPath(convertedPath, parentPath)) { + const schemaSet = schemas[keyIndex || ""] || { + schemaSet: [], + required: true, + }; + schemaSet.required = !schemaSet.required ? false : parentSchema?.required?.includes(keyIndex) ?? false; + schemaSet.schemaSet.push(schema); + schemas[keyIndex || ""] = schemaSet; + } + }, + }, + }); + + return Object.fromEntries( + Object.entries(schemas).map(([key, value]) => { + return [key, { + schemaSet: value.schemaSet, + required: value.required ? (parentSchemas) <= value.schemaSet.length : false, + }]; + }).filter(([, value]) => !isUnifiedSchemaEmpty(value as UnifiedPropertySchema)), + ); +} + +export function unresolveRefs(schema: JSONSchema7) { + const clonedSchema = cloneDeep(schema); + traverse(clonedSchema, { + cb: { + pre: (subSchema) => { + if (subSchema["x-resolved-ref"]) { + const ref = subSchema["x-resolved-ref"]; + Object.keys(subSchema).forEach(key => delete subSchema[key]); + subSchema.$ref = ref; + } + }, + }, + }); + + return clonedSchema; +} + +const MOVED_REF_MARKER = Symbol("moved-ref-marker"); +export function moveRefsToAllOf(schema: JSONSchema7, markMovedRef = true) { + const clonedSchema = cloneDeep(schema); + traverse(clonedSchema, { + cb: { + pre: (schema) => { + if (schema.$ref && !(schema as { [MOVED_REF_MARKER]: unknown })[MOVED_REF_MARKER]) { + const allOf = schema.allOf || []; + const ref = schema.$ref; // schema.$ref.replace("#/definitions/", "#/components/schemas/"); + delete schema.$ref; + schema.allOf = [ + ...allOf, + { + $ref: ref, + [MOVED_REF_MARKER]: true, + }, + ]; + } + }, + }, + }); + + return clonedSchema; +} + +export function rewriteRefsForOpenApi(schema: JSONSchema7) { + const clonedSchema = cloneDeep(schema); + traverse(clonedSchema, { + cb: { + pre: (schema) => { + if (schema.$ref) { + schema.$ref = schema.$ref.replace("#/definitions/", "#/components/schemas/"); + } + }, + }, + }); + + return clonedSchema; +} + +export function resolveDiscriminantProperty(schema: JSONSchema7, propertyPath: string) { + type SchemaBranch = { discriminatorValue: string | number; branchSchema: JSONSchema7 }; + if (schema.allOf != null && Object.keys(schema.allOf).length === 1) { + return resolveDiscriminantProperty(schema.allOf[0] as JSONSchema7, propertyPath); + } + + const discriminatorMap: Record = {}; + const addToDiscriminatorMap = (branch: SchemaBranch): boolean => { + if (discriminatorMap[branch.discriminatorValue] != null) { + return false; + } + discriminatorMap[branch.discriminatorValue] = branch.branchSchema; + return true; + }; + + const addManyToDiscriminatorMap = (discriminatorValues: string[], schema: JSONSchema7): boolean => { + return discriminatorValues.every(value => + addToDiscriminatorMap({ discriminatorValue: value, branchSchema: schema }) + ); + }; + + const analyzeBranch = (branchSchema: JSONSchema7, propertyPath: string): void | string[] => { + const parentPath = propertyPath.split("/").slice(0, -1).join("/"); + const propertyName = propertyPath.split("/").at(-1); + + if (propertyName == null) { + return; + } + + const branchSchemaProperties = getUnifiedPropertySchemas(branchSchema as JSONSchema7, parentPath); + const discriminantProperty = branchSchemaProperties[propertyName]; + if (discriminantProperty == null) { + return; + } + if (!discriminantProperty.required) { + return; + } + const discriminatorValues: string[] = []; + + for (const schema of discriminantProperty.schemaSet) { + if ( + !(schema.type === "string" || schema.type === "number") + || (schema.const == null && schema.enum == null) + ) { + return; + } + + if (schema.const != null) { + discriminatorValues.push(schema.const as string); + } + if (schema.enum != null) { + discriminatorValues.push(...(schema.enum as string[])); + } + } + + return discriminatorValues; + }; + + if (schema.anyOf == null) { + const result = analyzeBranch(schema, propertyPath); + if (result == null || !addManyToDiscriminatorMap(result, schema)) { + return; + } + return discriminatorMap; + } + + for (const branchSchema of schema.anyOf) { + const result = analyzeBranch(branchSchema as JSONSchema7, propertyPath); + if (result == null || !addManyToDiscriminatorMap(result, branchSchema as JSONSchema7)) { + return; + } + } + + return discriminatorMap; +} + +export function isSchemaEmpty(schema: JSONSchema7) { + return Object.keys(schema) + .filter(key => !(key.startsWith("x-") || key.startsWith("$"))) + .length === 0; +} diff --git a/packages/rest/src/transform/project.ts b/packages/rest/src/transform/project.ts index 0a96af7..b474c93 100644 --- a/packages/rest/src/transform/project.ts +++ b/packages/rest/src/transform/project.ts @@ -44,6 +44,7 @@ export const SCHEMA_DEFAULTS: Config = { encodeRefs: true, additionalProperties: true, topRef: false, + discriminatorType: "open-api", }; export type Options = AJVOptions & SchemaConfig; diff --git a/packages/rest/src/transform/transform.ts b/packages/rest/src/transform/transform.ts index 06ec4fa..4a3a7b9 100644 --- a/packages/rest/src/transform/transform.ts +++ b/packages/rest/src/transform/transform.ts @@ -6,6 +6,7 @@ import { NeverType, ReferenceType, SchemaGenerator, + StringType, SubNodeParser, } from "ts-json-schema-generator"; import ts from "typescript"; @@ -16,14 +17,13 @@ let project: Project; // let files: string[] = []; // let openapi: OpenAPIV3.Document; -export class NornirIgnoreParser implements SubNodeParser { +export class TemplateExpressionNodeParser implements SubNodeParser { supportsNode(node: ts.Node): boolean { - const tags = ts.getJSDocTags(node); - return tags.some((tag) => tag.tagName.getText() === "nornirIgnore"); + return ts.isTemplateExpression(node); } createType(node: ts.Node, context: Context, reference?: ReferenceType): BaseType { - return new NeverType(); + return new StringType(); } } @@ -46,7 +46,7 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme ...SCHEMA_DEFAULTS, jsDoc: jsDoc || SCHEMA_DEFAULTS.jsDoc, strictTuples: strictTuples || SCHEMA_DEFAULTS.strictTuples, - encodeRefs: encodeRefs || SCHEMA_DEFAULTS.encodeRefs, + encodeRefs: false, additionalProperties: additionalProperties || SCHEMA_DEFAULTS.additionalProperties, sortProps: sortProps || SCHEMA_DEFAULTS.sortProps, expose, @@ -65,6 +65,7 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme ...schemaConfig, }, (prs) => { // prs.addNodeParser(new NornirIgnoreParser()); + prs.addNodeParser(new TemplateExpressionNodeParser()); }); const typeFormatter = createFormatter({ ...schemaConfig, diff --git a/packages/rest/src/transform/transformers/node-transformer.ts b/packages/rest/src/transform/transformers/node-transformer.ts index adfa6ae..ef42beb 100644 --- a/packages/rest/src/transform/transformers/node-transformer.ts +++ b/packages/rest/src/transform/transformers/node-transformer.ts @@ -10,7 +10,7 @@ export abstract class NodeTransformer { context: ts.TransformationContext, ): ts.Node { if (ts.isClassDeclaration(node)) { - return ClassTransformer.transform(project, source, node, context); + return ClassTransformer.transform(project, source, ts.getOriginalNode(node) as ts.ClassDeclaration, context); } return node; diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index 1e1ab22..f42864b 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -1,8 +1,9 @@ import { schemaToValidator } from "@nrfcloud/ts-json-schema-transformer/utils"; -import { createWrappedNode } from "ts-morph"; +import tsp from "ts-morph"; import { isTypeReference } from "tsutils"; import ts from "typescript"; import { ControllerMeta } from "../../controller-meta"; +import { moveRefsToAllOf } from "../../json-schema-utils"; import { getStringLiteralOrConst, isNornirNode, NornirDecoratorInfo, separateNornirDecorators } from "../../lib"; import { Project } from "../../project"; @@ -49,11 +50,13 @@ export abstract class ChainRouteProcessor { const { typeNode: inputTypeNode, type: inputType } = ChainRouteProcessor.resolveInputType(project, node); - // const outputType = ChainRouteProcessor.resolveOutputType(project, methodSignature); + const outputType = ChainRouteProcessor.resolveOutputType(project, node); + + const outputSchema = project.schemaGenerator.createSchemaFromNodes([outputType.node]); const inputSchema = project.schemaGenerator.createSchemaFromNodes([inputTypeNode]); - const inputValidator = schemaToValidator(inputSchema, project.options.validation); + const inputValidator = schemaToValidator(moveRefsToAllOf(inputSchema, false), project.options.validation); const parsedDocComments = ChainRouteProcessor.parseJSDoc(project, node); @@ -61,9 +64,9 @@ export abstract class ChainRouteProcessor { method, path, input: inputTypeNode, - + inputSchema, + outputSchema: outputSchema, // FIXME: this should be the output type not the input type - output: inputTypeNode, description: parsedDocComments.description, summary: parsedDocComments.summary, filePath: source.fileName, @@ -205,20 +208,21 @@ export abstract class ChainRouteProcessor { }; } - private static resolveOutputType(project: Project, methodSignature: ts.Signature): ts.Type { - const returnType = methodSignature.getReturnType(); - const returnTypeDeclaration = returnType.symbol?.declarations?.[0]; - if (!returnTypeDeclaration) { - throw new Error("Handler chain return type declaration not found"); - } - if (!isNornirNode(returnTypeDeclaration)) { - throw new Error("Handler chain return must be a Nornir class"); - } - if (!isTypeReference(returnType)) { - throw new Error("Handler chain return must use a type reference"); - } - const typeArguments = project.checker.getTypeArguments(returnType); - const [outputType] = typeArguments; - return outputType; + private static resolveOutputType( + project: Project, + methodDeclaration: ts.MethodDeclaration, + ): { type: ts.Type; node: ts.Node } { + const wrapped = tsp.createWrappedNode(methodDeclaration, { typeChecker: project.checker }); + const type = wrapped.getReturnType(); + const typeArgs = type.getTypeArguments(); + + const [, outputType] = typeArgs; + const symbol = outputType.getSymbol(); + const declaration = symbol?.getDeclarations()?.[0]; + return { + type: outputType.compilerType, + node: declaration?.compilerNode + || project.checker.typeToTypeNode(outputType.compilerType, undefined, undefined) as ts.TypeReferenceNode, + }; } } diff --git a/packages/test/package.json b/packages/test/package.json index 0068033..75446c4 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@jest/globals": "^29.5.0", - "@nrfcloud/ts-json-schema-transformer": "^1.2.4", + "@nrfcloud/ts-json-schema-transformer": "^1.2.5", "@types/aws-lambda": "^8.10.115", "@types/jest": "^29.4.0", "@types/node": "^18.15.11", diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index 0401ce8..87ab427 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -1,10 +1,10 @@ import { Nornir } from "@nornir/core"; import { - AnyMimeType, Controller, GetChain, type HttpRequest, HttpRequestEmpty, + HttpResponse, HttpStatusCode, MimeType, PostChain, @@ -12,15 +12,21 @@ import { import { assertValid } from "@nrfcloud/ts-json-schema-transformer"; interface RouteGetInput extends HttpRequestEmpty { - headers: { - "content-type": AnyMimeType; + pathParams: { + /** + * @pattern ^[a-z]+$ + */ + cool: TestStringType; }; } interface RoutePostInputJSON extends HttpRequest { headers: { - "content-type": MimeType.ApplicationJson; - }; + "content-type": "application/json"; + } | { "content-type": "text/plain" }; + /** + * @contentMediaType application/json + */ body: RoutePostBodyInput; query: { test: "boolean"; @@ -42,7 +48,7 @@ interface RoutePostInputJSON extends HttpRequest { interface RoutePostInputCSV extends HttpRequest { headers: { - "content-type": MimeType.TextCsv; + "content-type": "text/csv"; /** * This is a CSV header * @example "cool,cool2" @@ -51,16 +57,56 @@ interface RoutePostInputCSV extends HttpRequest { */ "csv-header": string; }; + /** + * @contentMediaType text/csv + */ body: TestStringType; pathParams: { /** * @deprecated */ - reallyCool: "stuff"; + reallyCool: TestStringType; }; } -type RoutePostInput = RoutePostInputJSON | RoutePostInputCSV; +export type RoutePostInput = RoutePostInputCSV | RoutePostInputJSONAlias; + +export type RoutePostInputJSONAlias = RoutePostInputJSON; + +/** + * This is a comment + */ +export interface RouteGetOutputSuccess extends HttpResponse { + /** + * This is a property + */ + statusCode: "200" | "201"; + body: { + bleep: string; + bloop: number; + }; + headers: { + "content-type": "application/json"; + }; +} + +/** + * This is a comment on RouteGetOutputError + */ +export interface RouteGetOutputError extends HttpResponse { + statusCode: "400"; + body: { + message: string; + }; + headers: { + "content-type": "application/json"; + }; +} + +/** + * Output of the GET route + */ +export type RouteGetOutput = RouteGetOutputSuccess | RouteGetOutputError; /** * this is a comment @@ -78,7 +124,7 @@ interface RoutePostBodyInput { * @pattern ^[a-z]+$ * @minLength 5 */ -type TestStringType = Nominal; +export type TestStringType = Nominal; export declare class Tagged { protected _nominal_: N; @@ -104,30 +150,33 @@ const basePath = `${overallBase}/basepath`; */ @Controller(basePath) export class TestController { - // /** - // * Cool get route - // */ - // @GetChain("/route") - // public getRoute(chain: Nornir) { - // return chain - // .use(input => { - // assertValid(input); - // return input; - // }) - // .use(input => input.headers["content-type"]) - // .use(contentType => ({ - // statusCode: HttpStatusCode.Ok, - // body: `Content-Type: ${contentType}`, - // headers: { - // "content-type": MimeType.TextPlain, - // }, - // })); - // } + /** + * Cool get route + */ + @GetChain("/route/{cool}") + public getRoute(chain: Nornir) { + return chain + .use(input => { + assertValid(input); + return input; + }) + .use(input => input.headers?.toString()) + .use(contentType => ({ + statusCode: "200" as const, + body: { + bleep: "bloop", + bloop: 5, + }, + headers: { + "content-type": "application/json" as const, + } as const, + } as RouteGetOutput)); + } /** * A simple post route * @summary Cool Route - * @tags cool, route + * @tags cool * @deprecated * @operationId coolRoute */ @@ -135,11 +184,9 @@ export class TestController { public postRoute(chain: Nornir) { return chain .use(contentType => ({ - statusCode: HttpStatusCode.Ok, - body: `Content-Type: ${contentType}`, - headers: { - "content-type": MimeType.TextPlain, - }, + statusCode: "200" as const, + // body: `Content-Type: ${contentType}`, + headers: {}, })); } } diff --git a/packages/test/src/rest.ts b/packages/test/src/rest.ts index 8a2e88e..a3c8f82 100644 --- a/packages/test/src/rest.ts +++ b/packages/test/src/rest.ts @@ -1,6 +1,5 @@ import nornir from "@nornir/core"; import { - AnyMimeType, ApiGatewayProxyV2, httpErrorHandler, httpEventParser, @@ -38,14 +37,12 @@ const frameworkChain = nornir() .use(router()) .useResult(httpErrorHandler([ mapErrorClass(TestError, (err) => ({ - statusCode: HttpStatusCode.InternalServerError, - headers: { - "content-type": AnyMimeType, - }, + statusCode: "500", + headers: {}, })), ])) .use(httpResponseSerializer({ - [MimeType.ApplicationZip]: () => Buffer.from(""), + ["application/bzip"]: () => Buffer.from(""), })); export const handler: APIGatewayProxyHandlerV2 = nornir() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f0e8c7..52b298b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^29.5.0 version: 29.5.0 '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.4 - version: 1.2.4(typescript@5.2.2) + specifier: ^1.2.5 + version: 1.2.5(typescript@5.2.2) '@types/jest': specifier: ^29.4.0 version: 29.4.0 @@ -122,12 +122,15 @@ importers: packages/rest: dependencies: + '@apidevtools/json-schema-ref-parser': + specifier: ^11.1.0 + version: 11.1.0 '@nornir/core': specifier: workspace:^ version: link:../core '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.4 - version: 1.2.4(typescript@5.2.2) + specifier: ^1.2.5 + version: 1.2.5(typescript@5.2.2) '@types/aws-lambda': specifier: ^8.10.115 version: 8.10.115 @@ -153,8 +156,8 @@ importers: specifier: ^1.2.2 version: 1.2.2 ts-json-schema-generator: - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.5.0 + version: 1.5.0 ts-morph: specifier: ^20.0.0 version: 20.0.0 @@ -205,8 +208,8 @@ importers: specifier: ^29.5.0 version: 29.5.0 '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.4 - version: 1.2.4(typescript@5.2.2) + specifier: ^1.2.5 + version: 1.2.5(typescript@5.2.2) '@types/aws-lambda': specifier: ^8.10.115 version: 8.10.115 @@ -257,6 +260,17 @@ packages: js-yaml: 4.1.0 lodash.clonedeep: 4.5.0 + /@apidevtools/json-schema-ref-parser@11.1.0: + resolution: {integrity: sha512-g/VW9ZQEFJAOwAyUb8JFf7MLiLy2uEB4rU270rGzDwICxnxMlPy0O11KVePSgS36K1NI29gSlK84n5INGhd4Ag==} + engines: {node: '>= 16'} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + '@types/lodash.clonedeep': 4.5.7 + js-yaml: 4.1.0 + lodash.clonedeep: 4.5.0 + dev: false + /@babel/code-frame@7.21.4: resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} engines: {node: '>=6.9.0'} @@ -2248,8 +2262,8 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - /@nrfcloud/ts-json-schema-transformer@1.2.4(typescript@5.2.2): - resolution: {integrity: sha512-JyZkblORtJCRzE2wOYQC3pIeXjkxg/irNYtC7ufcErCPBB9NHzvqMoAaIPu0bZJdxCLvLKL4qTh7NYpm1gg/Gg==} + /@nrfcloud/ts-json-schema-transformer@1.2.5(typescript@5.2.2): + resolution: {integrity: sha512-AL1ZYkwtusprB4Hsf5CuySaRT0hPUhVA8rWb58FS13FcZd9DKi8mKg6s09Jmy7Ersud0Q5WnkxHvEM+ltz8tqA==} engines: {node: '>=18.0.0'} peerDependencies: typescript: '>=5' @@ -2258,7 +2272,7 @@ packages: ajv: 8.12.0 esbuild: 0.17.18 json-schema-faker: /@jfconley/json-schema-faker@0.5.0-rcv.48 - ts-json-schema-generator: 1.4.0 + ts-json-schema-generator: 1.5.0 typescript: 5.2.2 /@parcel/source-map@2.1.1: @@ -2371,10 +2385,6 @@ packages: pretty-format: 29.5.0 dev: true - /@types/json-schema@7.0.12: - resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} - dev: true - /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2583,7 +2593,7 @@ packages: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@types/json-schema': 7.0.12 + '@types/json-schema': 7.0.15 '@types/semver': 7.5.0 '@typescript-eslint/scope-manager': 5.59.2 '@typescript-eslint/types': 5.59.2 @@ -2603,7 +2613,7 @@ packages: eslint: ^7.0.0 || ^8.0.0 dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0) - '@types/json-schema': 7.0.12 + '@types/json-schema': 7.0.15 '@types/semver': 7.5.0 '@typescript-eslint/scope-manager': 6.2.0 '@typescript-eslint/types': 6.2.0 @@ -6520,8 +6530,8 @@ packages: resolution: {integrity: sha512-cA5MPLWGWYXvnlJb4TamUUx858HVHBsxxdy8l7jxODOLDyGYnQOllob2A2jyDghGa5iJHs2gzFNHvwGJ0ZfR8g==} dev: false - /ts-json-schema-generator@1.4.0: - resolution: {integrity: sha512-wm8vyihmGgYpxrqRshmYkWGNwEk+sf3xV2rUgxv8Ryeh7bSpMO7pZQOht+2rS002eDkFTxR7EwRPXVzrS0WJTg==} + /ts-json-schema-generator@1.5.0: + resolution: {integrity: sha512-RkiaJ6YxGc5EWVPfyHxszTmpGxX8HC2XBvcFlAl1zcvpOG4tjjh+eXioStXJQYTvr9MoK8zCOWzAUlko3K0DiA==} engines: {node: '>=10.0.0'} hasBin: true dependencies: @@ -6531,7 +6541,7 @@ packages: json5: 2.2.3 normalize-path: 3.0.0 safe-stable-stringify: 2.4.3 - typescript: 5.2.2 + typescript: 5.3.3 /ts-morph@20.0.0: resolution: {integrity: sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg==} @@ -6723,6 +6733,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + /ufo@1.1.2: resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} dev: true From 6d2d8d94eafefb2bf4a6be59b129ce5dfe066b0a Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Sun, 10 Dec 2023 12:52:10 -0800 Subject: [PATCH 05/16] support examples and descriptions --- packages/rest/src/runtime/http-event.mts | 8 +- .../rest/src/transform/controller-meta.ts | 30 ++- .../rest/src/transform/json-schema-utils.ts | 37 +++- .../transformers/file-transformer.ts | 1 - packages/test/src/controller.ts | 9 +- packages/test/src/controller2.ts | 197 +++++++++--------- 6 files changed, 156 insertions(+), 126 deletions(-) diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index 95e08a7..5bde8d7 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -39,10 +39,7 @@ type QueryParams = Record | Ar type PathParams = Record; export interface HttpRequestEmpty extends HttpRequest { - headers: { - "content-type": never - } - // body?: undefined; + body?: undefined; } export interface HttpRequestJSON extends HttpRequest { @@ -63,9 +60,6 @@ export interface SerializedHttpResponse extends Omit { } export interface HttpResponseEmpty extends HttpResponse { - headers: { - "content-type": never - }, body?: undefined; } diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index 86e01bf..a2229c3 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -4,6 +4,8 @@ import ts from "typescript"; import { TransformationError } from "./error"; import { dereferenceSchema, + getFirstExample, + getSchemaOrAllOf, getUnifiedPropertySchemas, isSchemaEmpty, joinSchemas, @@ -339,20 +341,23 @@ export class ControllerMeta { const content = contentTypeDiscriminatedSchemas == null ? undefined : Object.fromEntries( Object.entries(contentTypeDiscriminatedSchemas).map(([contentType, schema]) => { - const branchBodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; + const branchBodySchemaSet = getUnifiedPropertySchemas(schema, "/")["body"]; + const branchBodySchema = rewriteRefsForOpenApi( + unresolveRefs(joinSchemas(branchBodySchemaSet.schemaSet)), + ); + const example = getFirstExample(branchBodySchema); return [ contentType, { - schema: rewriteRefsForOpenApi( - unresolveRefs(joinSchemas(branchBodySchema.schemaSet)), - ) as OpenAPIV3.NonArraySchemaObject, + ...(example == null ? {} : { example }), + schema: branchBodySchema as OpenAPIV3.NonArraySchemaObject, }, ]; }), ); responses[statusCode] = { - description: schema.description || "", + description: getSchemaOrAllOf(schema).description || "", headers: Object.fromEntries(headers.map(header => [header.name, header])), content, }; @@ -374,17 +379,28 @@ export class ControllerMeta { const content = Object.fromEntries( Object.entries(contentTypeDiscriminatedSchemas).map(([contentType, schema]) => { - const branchBodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; + const branchBodySchemaSet = getUnifiedPropertySchemas(schema, "/")["body"]; + const branchBodySchema = rewriteRefsForOpenApi(unresolveRefs(joinSchemas(branchBodySchemaSet.schemaSet))); + const example = getFirstExample(branchBodySchema); + return [ contentType, { - schema: rewriteRefsForOpenApi(unresolveRefs(joinSchemas(branchBodySchema.schemaSet))), + ...(example == null ? {} : { example }), + schema: branchBodySchema, }, ]; }), ); + // If there is exactly one branch, then we can use the description from that branch. + // Otherwise, don't use a description. + const description = Object.keys(content).length === 1 + ? getSchemaOrAllOf(Object.values(content)[0].schema).description + : undefined; + return { + description, required: bodySchema.required, content, } as OpenAPIV3_1.RequestBodyObject; diff --git a/packages/rest/src/transform/json-schema-utils.ts b/packages/rest/src/transform/json-schema-utils.ts index 82a777a..e6b5a64 100644 --- a/packages/rest/src/transform/json-schema-utils.ts +++ b/packages/rest/src/transform/json-schema-utils.ts @@ -4,6 +4,7 @@ import dereference from "@apidevtools/json-schema-ref-parser/dist/lib/dereferenc import { JSONSchema7 } from "json-schema"; import traverse from "json-schema-traverse"; import { cloneDeep } from "lodash"; +import { OpenAPIV3_1 } from "openapi-types"; const refParser = new $RefParser(); @@ -45,7 +46,6 @@ export function joinSchemas(schemaSet: JSONSchema7[]): JSONSchema7 { export function getUnifiedPropertySchemas( schema: JSONSchema7, parentPath: string, - ignoreAdditionalProperties = false, ): Record { // Take a path from the json schema and convert it to a path in validated object @@ -185,6 +185,25 @@ export function rewriteRefsForOpenApi(schema: JSONSchema7) { return clonedSchema; } +export function getSchemaOrAllOf(schema: JSONSchema7): JSONSchema7 { + if (schema.allOf != null && schema.allOf.length === 1) { + return schema.allOf[0] as JSONSchema7; + } + + return schema; +} + +export function getFirstExample(schema: JSONSchema7): unknown { + schema = getSchemaOrAllOf(schema); + if ((schema as { example?: string }).example != null) { + return (schema as { example?: string }).example; + } + if (schema.examples != null) { + return Array.isArray(schema.examples) ? schema.examples[0] : schema.examples; + } + return undefined; +} + export function resolveDiscriminantProperty(schema: JSONSchema7, propertyPath: string) { type SchemaBranch = { discriminatorValue: string | number; branchSchema: JSONSchema7 }; if (schema.allOf != null && Object.keys(schema.allOf).length === 1) { @@ -261,8 +280,16 @@ export function resolveDiscriminantProperty(schema: JSONSchema7, propertyPath: s return discriminatorMap; } -export function isSchemaEmpty(schema: JSONSchema7) { - return Object.keys(schema) - .filter(key => !(key.startsWith("x-") || key.startsWith("$"))) - .length === 0; +export function isSchemaEmpty(schema: JSONSchema7): boolean { + const keys = Object.keys(schema); + for (const key of keys) { + if (key.startsWith("x-") || key.startsWith("$")) { + continue; + } + if (key === "not" && isSchemaEmpty(schema.not as JSONSchema7)) { + continue; + } + return false; + } + return true; } diff --git a/packages/rest/src/transform/transformers/file-transformer.ts b/packages/rest/src/transform/transformers/file-transformer.ts index 3ce73f2..8a0cbd1 100644 --- a/packages/rest/src/transform/transformers/file-transformer.ts +++ b/packages/rest/src/transform/transformers/file-transformer.ts @@ -40,7 +40,6 @@ export abstract class FileTransformer { ); const mergedSpec = OpenApiSpecHolder.getSpecForFile(file); - console.log("Merged Spec:", JSON.stringify(mergedSpec, null, 2)); compilerHost.writeFile(schemaFileName, JSON.stringify(mergedSpec, null, 2), false, undefined, []); diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index 87ab427..9997e5c 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -25,7 +25,8 @@ interface RoutePostInputJSON extends HttpRequest { "content-type": "application/json"; } | { "content-type": "text/plain" }; /** - * @contentMediaType application/json + * A cool json input + * @example { "cool": "stuff" } */ body: RoutePostBodyInput; query: { @@ -58,7 +59,8 @@ interface RoutePostInputCSV extends HttpRequest { "csv-header": string; }; /** - * @contentMediaType text/csv + * This is a CSV body + * @example "cool,cool2" */ body: TestStringType; pathParams: { @@ -95,6 +97,9 @@ export interface RouteGetOutputSuccess extends HttpResponse { */ export interface RouteGetOutputError extends HttpResponse { statusCode: "400"; + /** + * @example { "message": "Bad Request"} + */ body: { message: string; }; diff --git a/packages/test/src/controller2.ts b/packages/test/src/controller2.ts index b80703e..67a883b 100644 --- a/packages/test/src/controller2.ts +++ b/packages/test/src/controller2.ts @@ -1,104 +1,93 @@ -// import { Nornir } from "@nornir/core"; -// import { -// AnyMimeType, -// Controller, -// GetChain, -// HttpRequest, -// HttpRequestEmpty, -// HttpResponse, -// HttpResponseEmpty, -// HttpStatusCode, -// MimeType, -// Provider, -// PutChain, -// } from "@nornir/rest"; -// -// interface RouteGetInput extends HttpRequestEmpty { -// headers: GetHeaders; -// } -// interface GetHeaders { -// "content-type": AnyMimeType; -// [key: string]: string; -// } -// -// interface RoutePostInputJSON extends HttpRequest { -// headers: { -// "content-type": MimeType.ApplicationJson; -// }; -// body: RoutePostBodyInput; -// } -// -// interface RoutePostInputCSV extends HttpRequest { -// headers: { -// "content-type": MimeType.TextCsv; -// }; -// body: string; -// } -// -// type RoutePutInput = RoutePostInputJSON | RoutePostInputCSV; -// -// interface RoutePostBodyInput { -// cool: string; -// } -// -// const basePath = "/basepath/2"; -// -// /** -// * This is a second controller -// * @summary This is a summary -// */ -// @Controller(basePath, "test") -// export class TestController { -// @Provider() -// public static test() { -// return new TestController(); -// } -// -// /** -// * The second simple GET route. -// * @summary Get route -// */ -// @GetChain("/route") -// public getRoute(chain: Nornir) { -// return chain -// .use(input => input.headers["content-type"]) -// .use(contentType => ({ -// statusCode: HttpStatusCode.Ok, -// body: `Content-Type: ${contentType}`, -// headers: { -// "content-type": MimeType.TextPlain, -// }, -// })); -// } -// -// /** -// * The second simple PUT route. -// * @summary Put route -// */ -// @PutChain("/route") -// public postRoute(chain: Nornir): Nornir { -// return chain -// .use(() => ({ -// statusCode: HttpStatusCode.Created, -// headers: { -// "content-type": AnyMimeType, -// }, -// })); -// } -// } -// -// type PutResponse = PutSuccessResponse | PutBadRequestResponse; -// -// interface PutSuccessResponse extends HttpResponseEmpty { -// statusCode: HttpStatusCode.Created; -// } -// -// interface PutBadRequestResponse extends HttpResponse { -// statusCode: HttpStatusCode.BadRequest; -// headers: { -// "content-type": MimeType.ApplicationJson; -// }; -// body: { -// potato: boolean; -// }; -// } +import { Nornir } from "@nornir/core"; +import { + Controller, + GetChain, + HttpRequest, + HttpRequestEmpty, + HttpResponse, + HttpResponseEmpty, + Provider, + PutChain, +} from "@nornir/rest"; + +interface RouteGetInput extends HttpRequestEmpty { +} + +interface RoutePostInputJSON extends HttpRequest { + headers: { + "content-type": "application/json"; + }; + body: RoutePostBodyInput; +} + +interface RoutePostInputCSV extends HttpRequest { + headers: { + "content-type": "text/csv"; + }; + body: string; +} + +type RoutePutInput = RoutePostInputJSON | RoutePostInputCSV; + +interface RoutePostBodyInput { + cool: string; +} + +const basePath = "/basepath/2"; + +/** + * This is a second controller + * @summary This is a summary + */ +@Controller(basePath, "test") +export class TestController { + @Provider() + public static test() { + return new TestController(); + } + + /** + * The second simple GET route. + * @summary Get route + */ + @GetChain("/route") + public getRoute(chain: Nornir) { + return chain + .use(contentType => ({ + statusCode: "200", + body: `Content-Type: ${contentType}`, + headers: { + "content-type": "text/plain", + }, + } as const)); + } + + /** + * The second simple PUT route. + * @summary Put route + */ + @PutChain("/route") + public postRoute(chain: Nornir): Nornir { + return chain + .use(() => ({ + statusCode: "201", + headers: {}, + })); + } +} + +type PutResponse = PutSuccessResponse | PutBadRequestResponse; + +interface PutSuccessResponse extends HttpResponseEmpty { + statusCode: "201"; +} + +interface PutBadRequestResponse extends HttpResponse { + statusCode: "422"; + headers: { + "content-type": "application/json"; + }; + body: { + potato: boolean; + }; +} From 01d17ae997087fa37d9031f7cc5d508258566a79 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Mon, 11 Dec 2023 14:55:16 -0800 Subject: [PATCH 06/16] simple spec collector --- packages/rest/package.json | 9 +- packages/rest/src/cli/cli.ts | 42 + packages/rest/src/cli/lib/collect.ts | 30 + packages/rest/src/cli/lib/ts-utils.ts | 25 + .../rest/src/transform/controller-meta.ts | 7 +- packages/test/base.openapi.json | 8 + packages/test/openapi.json | 753 ++++++++++++++++++ pnpm-lock.yaml | 345 +++++++- 8 files changed, 1184 insertions(+), 35 deletions(-) create mode 100644 packages/rest/src/cli/cli.ts create mode 100644 packages/rest/src/cli/lib/collect.ts create mode 100644 packages/rest/src/cli/lib/ts-utils.ts create mode 100644 packages/test/base.openapi.json create mode 100644 packages/test/openapi.json diff --git a/packages/rest/package.json b/packages/rest/package.json index a4b69bf..5d56a2e 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -2,6 +2,9 @@ "name": "@nornir/rest", "description": "A nornir library", "version": "1.3.0", + "bin": { + "nornir-oas": "./dist/cli/cli.js" + }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", "@nornir/core": "workspace:^", @@ -9,14 +12,17 @@ "@types/aws-lambda": "^8.10.115", "ajv": "^8.12.0", "atlassian-openapi": "^1.0.18", + "glob": "^10.3.10", "json-schema-traverse": "^1.0.0", "lodash": "^4.17.21", + "openapi-diff": "^0.23.6", "openapi-types": "^12.1.0", "trouter": "^3.2.1", "ts-is-present": "^1.2.2", "ts-json-schema-generator": "^1.5.0", "ts-morph": "^20.0.0", - "tsutils": "^3.21.0" + "tsutils": "^3.21.0", + "yargs": "^17.7.2" }, "devDependencies": { "@jest/globals": "^29.5.0", @@ -24,6 +30,7 @@ "@types/json-schema": "^7.0.15", "@types/lodash": "^4.14.202", "@types/node": "^18.15.11", + "@types/yargs": "^17.0.32", "eslint": "^8.45.0", "jest": "^29.5.0", "ts-patch": "^3.0.2", diff --git a/packages/rest/src/cli/cli.ts b/packages/rest/src/cli/cli.ts new file mode 100644 index 0000000..a43130a --- /dev/null +++ b/packages/rest/src/cli/cli.ts @@ -0,0 +1,42 @@ +import { readFileSync, writeFileSync } from "fs"; +import { merge } from "lodash"; +import path from "path"; +import yargs from "yargs"; +import { isErrorResult } from "../transform/openapi-merge"; +import { getMergedSpec, getSpecFiles } from "./lib/collect"; +import { resolveTsConfigOutdir } from "./lib/ts-utils"; + +yargs + .scriptName("nornir-oas") + .command("collect", "Build OpenAPI spec from collected spec files", args => + args + .option("output", { + default: path.join(process.cwd(), "openapi.json"), + alias: "o", + type: "string", + description: "Output file for the generated OpenAPI spec", + }) + .option("scanDirectory", { + default: resolveTsConfigOutdir() ?? path.join(process.cwd(), "dist"), + type: "string", + alias: "s", + description: + "Directory to scan for spec files. This is probably the directory where your compiled typescript files are.", + }) + .option("overrideSpec", { + type: "string", + alias: "b", + description: "Path to an openapi JSON file to deeply merge with collected specification", + }), args => { + const mergedSpec = getMergedSpec(args.scanDirectory); + if (isErrorResult(mergedSpec)) { + throw new Error("Failed to merge spec files"); + } + let spec = mergedSpec.output; + if (args.overrideSpec) { + const overrideSpec = JSON.parse(readFileSync(args.overrideSpec, "utf-8")); + spec = merge(spec, overrideSpec); + } + + writeFileSync(args.output, JSON.stringify(spec, null, 2)); + }).strictCommands().demandCommand().parse(); diff --git a/packages/rest/src/cli/lib/collect.ts b/packages/rest/src/cli/lib/collect.ts new file mode 100644 index 0000000..af5c176 --- /dev/null +++ b/packages/rest/src/cli/lib/collect.ts @@ -0,0 +1,30 @@ +import { Swagger } from "atlassian-openapi"; +import { readFileSync } from "fs"; +import { sync } from "glob"; +import { OpenAPIV3_1 } from "openapi-types"; +import { merge } from "../../transform/openapi-merge/index.js"; +import SwaggerV3 = Swagger.SwaggerV3; + +export function getSpecFiles(scanDir: string) { + return sync(`${scanDir}/**/*.nornir.oas.json`); +} + +export function readSpecFiles(paths: string[]) { + return paths.map(path => { + return JSON.parse(readFileSync(path, "utf-8")) as OpenAPIV3_1.Document; + }); +} + +export function getMergedSpec(scanDir: string) { + const files = getSpecFiles(scanDir); + const specs = readSpecFiles(files); + return merge(specs.map(spec => ({ + dispute: { + // mergeDispute: true, + // alwaysApply: true, + // prefix: "", + // suffix: "" + }, + oas: spec as SwaggerV3, + }))); +} diff --git a/packages/rest/src/cli/lib/ts-utils.ts b/packages/rest/src/cli/lib/ts-utils.ts new file mode 100644 index 0000000..74b1321 --- /dev/null +++ b/packages/rest/src/cli/lib/ts-utils.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import { dirname, resolve } from "node:path"; +import ts from "typescript"; + +export function resolveTsConfigOutdir(searchPath = process.cwd(), configName = "tsconfig.json") { + const path = ts.findConfigFile(searchPath, ts.sys.fileExists, configName); + if (path == undefined) { + return; + } + const config = ts.readConfigFile(path, ts.sys.readFile); + if (config.error) { + return; + } + const compilerOptions = config.config as { compilerOptions: ts.CompilerOptions }; + + const pathDir = dirname(path); + + const outDir = compilerOptions.compilerOptions.outDir; + + if (outDir == undefined) { + return undefined; + } + + return resolve(pathDir, outDir); +} diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index a2229c3..3306fd4 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -31,12 +31,7 @@ export abstract class OpenApiSpecHolder { const mergeInputs: MergeInput = (this.specFileMap.get(file.fileName) || []) .map((spec) => ({ oas: spec, - dispute: { - alwaysApply: true, - mergeDispute: true, - prefix: "", - suffix: "", - }, + dispute: {}, })) as MergeInput; const merged = merge(mergeInputs); diff --git a/packages/test/base.openapi.json b/packages/test/base.openapi.json new file mode 100644 index 0000000..d7df19d --- /dev/null +++ b/packages/test/base.openapi.json @@ -0,0 +1,8 @@ +{ + "openapi": "3.1.0", + "info": { + "description": "A test api", + "version": "1.0.0", + "title": "Test API" + } +} diff --git a/packages/test/openapi.json b/packages/test/openapi.json new file mode 100644 index 0000000..d501cb6 --- /dev/null +++ b/packages/test/openapi.json @@ -0,0 +1,753 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test API", + "version": "1.0.0", + "description": "A test api" + }, + "paths": { + "/basepath/2/route": { + "get": { + "summary": "Get route", + "description": "The second simple GET route.", + "responses": { + "200": { + "description": "", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "const": "text/plain" + } + } + }, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + }, + "parameters": [ + { + "name": "content-type", + "in": "header", + "required": false, + "deprecated": false, + "schema": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/MimeType" + } + ] + }, + { + "not": {} + } + ] + } + } + ] + }, + "put": { + "summary": "Put route", + "description": "The second simple PUT route.", + "responses": { + "201": { + "description": "", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": false, + "deprecated": false, + "schema": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/MimeType" + } + ] + }, + { + "not": {} + } + ] + } + } + } + }, + "422": { + "description": "", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "const": "application/json" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "potato": { + "type": "boolean" + } + }, + "required": [ + "potato" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cool": { + "type": "string" + } + }, + "required": [ + "cool" + ] + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "parameters": [ + { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "anyOf": [ + { + "type": "string", + "const": "application/json" + }, + { + "type": "string", + "const": "text/csv" + } + ] + } + } + ] + } + }, + "/root/basepath/route/{cool}": { + "get": { + "description": "Cool get route", + "responses": { + "200": { + "description": "This is a comment", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "const": "application/json" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bleep": { + "type": "string" + }, + "bloop": { + "type": "number" + } + }, + "required": [ + "bleep", + "bloop" + ] + } + } + } + }, + "201": { + "description": "This is a comment", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "const": "application/json" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bleep": { + "type": "string" + }, + "bloop": { + "type": "number" + } + }, + "required": [ + "bleep", + "bloop" + ] + } + } + } + }, + "400": { + "description": "This is a comment on RouteGetOutputError", + "headers": { + "content-type": { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "const": "application/json" + } + } + }, + "content": { + "application/json": { + "example": { + "message": "Bad Request" + }, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "examples": [ + { + "message": "Bad Request" + } + ] + } + } + } + } + }, + "parameters": [ + { + "name": "cool", + "in": "path", + "required": true, + "deprecated": false, + "schema": { + "pattern": "^[a-z]+$", + "allOf": [ + { + "$ref": "#/components/schemas/TestStringType" + } + ] + } + }, + { + "name": "content-type", + "in": "header", + "required": false, + "deprecated": false, + "schema": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/MimeType" + } + ] + }, + { + "not": {} + } + ] + } + } + ] + }, + "post": { + "deprecated": true, + "tags": [ + "cool" + ], + "operationId": "coolRoute", + "summary": "Cool Route", + "description": "A simple post route", + "responses": { + "200": { + "description": "", + "headers": {} + } + }, + "requestBody": { + "required": true, + "content": { + "text/csv": { + "schema": { + "description": "This is a CSV body", + "examples": [ + "cool,cool2" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TestStringType" + } + ] + } + }, + "application/json": { + "example": { + "cool": "stuff" + }, + "schema": { + "type": "object", + "properties": { + "cool": { + "type": "string", + "description": "This is a cool property", + "minLength": 5 + } + }, + "required": [ + "cool" + ], + "description": "A cool json input", + "examples": [ + { + "cool": "stuff" + } + ] + } + }, + "text/plain": { + "example": { + "cool": "stuff" + }, + "schema": { + "type": "object", + "properties": { + "cool": { + "type": "string", + "description": "This is a cool property", + "minLength": 5 + } + }, + "required": [ + "cool" + ], + "description": "A cool json input", + "examples": [ + { + "cool": "stuff" + } + ] + } + } + } + }, + "parameters": [ + { + "name": "reallyCool", + "in": "path", + "required": true, + "description": "Very cool property that does a thing", + "example": "true", + "deprecated": false, + "schema": { + "anyOf": [ + { + "deprecated": true, + "allOf": [ + { + "$ref": "#/components/schemas/TestStringType" + } + ] + }, + { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Very cool property that does a thing", + "examples": [ + "true" + ], + "pattern": "^[a-z]+$" + } + ] + } + }, + { + "name": "evenCooler", + "in": "path", + "required": false, + "description": "Even cooler property", + "deprecated": false, + "schema": { + "type": "number", + "description": "Even cooler property" + } + }, + { + "name": "test", + "in": "query", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "const": "boolean" + } + }, + { + "name": "content-type", + "in": "header", + "required": true, + "deprecated": false, + "schema": { + "anyOf": [ + { + "type": "string", + "const": "text/csv" + }, + { + "type": "string", + "const": "application/json" + }, + { + "type": "string", + "const": "text/plain" + } + ] + } + }, + { + "name": "csv-header", + "in": "header", + "required": false, + "description": "This is a CSV header", + "example": "cool,cool2", + "deprecated": false, + "schema": { + "type": "string", + "description": "This is a CSV header", + "examples": [ + "cool,cool2" + ], + "pattern": "^[a-z]+,[a-z]+$", + "minLength": 5 + } + } + ] + } + } + }, + "components": { + "schemas": { + "HttpHeadersWithContentType": { + "type": "object", + "properties": { + "content-type": { + "allOf": [ + { + "$ref": "#/components/schemas/MimeType" + } + ] + } + }, + "required": [ + "content-type" + ] + }, + "MimeType": { + "type": "string", + "enum": [ + "*/*", + "application/json", + "application/octet-stream", + "application/pdf", + "application/x-www-form-urlencoded", + "application/zip", + "application/gzip", + "application/bzip", + "application/bzip2", + "application/ld+json", + "font/woff", + "font/woff2", + "font/ttf", + "font/otf", + "audio/mpeg", + "audio/x-wav", + "image/gif", + "image/jpeg", + "image/png", + "multipart/form-data", + "text/css", + "text/csv", + "text/html", + "text/plain", + "text/xml", + "video/mpeg", + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/x-flv", + "video/webm" + ] + }, + "HttpHeadersWithoutContentType": { + "type": "object", + "properties": { + "content-type": { + "not": {} + } + } + }, + "TestStringType": { + "description": "Amazing string", + "pattern": "^[a-z]+$", + "minLength": 5, + "allOf": [ + { + "$ref": "#/components/schemas/Nominal" + } + ] + }, + "Nominal": { + "type": [ + "string" + ], + "description": "Constructs a nominal type of type `T`. Useful to prevent any value of type `T` from being used or modified in places it shouldn't (think `id`s)." + }, + "RouteGetOutputSuccess": { + "type": "object", + "properties": { + "statusCode": { + "type": "string", + "enum": [ + "200", + "201" + ], + "description": "This is a property" + }, + "headers": { + "type": "object", + "properties": { + "content-type": { + "type": "string", + "const": "application/json" + } + }, + "required": [ + "content-type" + ] + }, + "body": { + "type": "object", + "properties": { + "bleep": { + "type": "string" + }, + "bloop": { + "type": "number" + } + }, + "required": [ + "bleep", + "bloop" + ] + } + }, + "required": [ + "body", + "headers", + "statusCode" + ], + "description": "This is a comment" + }, + "RouteGetOutputError": { + "type": "object", + "properties": { + "statusCode": { + "type": "string", + "const": "400" + }, + "headers": { + "type": "object", + "properties": { + "content-type": { + "type": "string", + "const": "application/json" + } + }, + "required": [ + "content-type" + ] + }, + "body": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "examples": [ + { + "message": "Bad Request" + } + ] + } + }, + "required": [ + "body", + "headers", + "statusCode" + ], + "description": "This is a comment on RouteGetOutputError" + }, + "RoutePostInputJSONAlias": { + "type": "object", + "properties": { + "headers": { + "anyOf": [ + { + "type": "object", + "properties": { + "content-type": { + "type": "string", + "const": "application/json" + } + }, + "required": [ + "content-type" + ] + }, + { + "type": "object", + "properties": { + "content-type": { + "type": "string", + "const": "text/plain" + } + }, + "required": [ + "content-type" + ] + } + ] + }, + "query": { + "type": "object", + "properties": { + "test": { + "type": "string", + "const": "boolean" + } + }, + "required": [ + "test" + ] + }, + "body": { + "type": "object", + "properties": { + "cool": { + "type": "string", + "description": "This is a cool property", + "minLength": 5 + } + }, + "required": [ + "cool" + ], + "description": "A cool json input", + "examples": [ + { + "cool": "stuff" + } + ] + }, + "pathParams": { + "type": "object", + "properties": { + "reallyCool": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Very cool property that does a thing", + "examples": [ + "true" + ], + "pattern": "^[a-z]+$" + }, + "evenCooler": { + "type": "number", + "description": "Even cooler property" + } + }, + "required": [ + "reallyCool" + ] + } + }, + "required": [ + "body", + "headers", + "pathParams", + "query" + ] + } + }, + "parameters": {} + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52b298b..8971096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,12 +140,18 @@ importers: atlassian-openapi: specifier: ^1.0.18 version: 1.0.18 + glob: + specifier: ^10.3.10 + version: 10.3.10 json-schema-traverse: specifier: ^1.0.0 version: 1.0.0 lodash: specifier: ^4.17.21 version: 4.17.21 + openapi-diff: + specifier: ^0.23.6 + version: 0.23.6(openapi-types@12.1.0) openapi-types: specifier: ^12.1.0 version: 12.1.0 @@ -164,6 +170,9 @@ importers: tsutils: specifier: ^3.21.0 version: 3.21.0(typescript@5.2.2) + yargs: + specifier: ^17.7.2 + version: 17.7.2 devDependencies: '@jest/globals': specifier: ^29.5.0 @@ -180,6 +189,9 @@ importers: '@types/node': specifier: ^18.15.11 version: 18.15.11 + '@types/yargs': + specifier: ^17.0.32 + version: 17.0.32 eslint: specifier: ^8.45.0 version: 8.45.0 @@ -271,6 +283,47 @@ packages: lodash.clonedeep: 4.5.0 dev: false + /@apidevtools/json-schema-ref-parser@9.0.9: + resolution: {integrity: sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.0 + dev: false + + /@apidevtools/json-schema-ref-parser@9.1.2: + resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.0 + dev: false + + /@apidevtools/openapi-schemas@2.1.0: + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + dev: false + + /@apidevtools/swagger-methods@3.0.2: + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + dev: false + + /@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.0): + resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==} + peerDependencies: + openapi-types: '>=7' + dependencies: + '@apidevtools/json-schema-ref-parser': 9.1.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + call-me-maybe: 1.0.2 + openapi-types: 12.1.0 + z-schema: 5.0.5 + dev: false + /@babel/code-frame@7.21.4: resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} engines: {node: '>=6.9.0'} @@ -1946,6 +1999,18 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.0.1 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: false + /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2171,7 +2236,7 @@ packages: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 '@types/node': 18.15.11 - '@types/yargs': 17.0.24 + '@types/yargs': 17.0.32 chalk: 4.1.2 dev: true @@ -2282,6 +2347,13 @@ packages: detect-libc: 1.0.3 dev: true + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: false + optional: true + /@sinclair/typebox@0.25.24: resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} dev: true @@ -2441,8 +2513,8 @@ packages: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true - /@types/yargs@17.0.24: - resolution: {integrity: sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==} + /@types/yargs@17.0.32: + resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} dependencies: '@types/yargs-parser': 21.0.0 dev: true @@ -2718,12 +2790,10 @@ packages: /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true /ansi-regex@6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -2737,13 +2807,17 @@ packages: engines: {node: '>=8'} dependencies: color-convert: 2.0.1 - dev: true /ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} dev: true + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: false + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2810,6 +2884,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + dev: false + /atlassian-openapi@1.0.18: resolution: {integrity: sha512-IXgF/cYD8DW1mYB/ejDm/lKQMNXi2iCsxus2Y0ffZOxfa/SLoz0RuEZ4xu4suSRjtlda7qZDonQ6TAkQPVuQig==} dependencies: @@ -2822,6 +2901,14 @@ packages: engines: {node: '>= 0.4'} dev: true + /axios@0.24.0: + resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} + dependencies: + follow-redirects: 1.15.3 + transitivePeerDependencies: + - debug + dev: false + /babel-jest@29.5.0(@babel/core@7.21.4): resolution: {integrity: sha512-mA4eCDh5mSo2EcA9xQjVTpmbbNk32Zb3Q3QFQsNhaK56Q+yoXowzFodLux30HRgyOho5rsQ6B0P9QpMkvvnJ0Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3030,6 +3117,10 @@ packages: get-intrinsic: 1.2.1 dev: true + /call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + dev: false + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3174,7 +3265,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true /clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} @@ -3205,7 +3295,6 @@ packages: engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 - dev: true /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} @@ -3213,7 +3302,6 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true /commander@10.0.0: resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==} @@ -3224,6 +3312,23 @@ packages: resolution: {integrity: sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==} engines: {node: '>=16'} + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: false + + /commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + dev: false + + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + requiresBuild: true + dev: false + optional: true + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -3250,6 +3355,10 @@ packages: browserslist: 4.21.5 dev: true + /core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + dev: false + /cosmiconfig@8.0.0: resolution: {integrity: sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ==} engines: {node: '>=14'} @@ -3275,7 +3384,6 @@ packages: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true /csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} @@ -3428,6 +3536,10 @@ packages: - supports-color dev: true + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: false + /electron-to-chromium@1.4.380: resolution: {integrity: sha512-XKGdI4pWM78eLH2cbXJHiBnWUwFSzZM7XujsB6stDiGu9AeSqziedP6amNLpJzE3i0rLTcfAwdCTs5ecP5yeSg==} dev: true @@ -3439,7 +3551,10 @@ packages: /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: false /enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} @@ -3555,7 +3670,6 @@ packages: /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} - dev: true /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} @@ -3821,6 +3935,11 @@ packages: tmp: 0.0.33 dev: true + /extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3948,6 +4067,16 @@ packages: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true + /follow-redirects@1.15.3: + resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -3966,6 +4095,14 @@ packages: for-in: 1.0.2 dev: true + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: false + /fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4039,7 +4176,6 @@ packages: /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: true /get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} @@ -4081,6 +4217,18 @@ packages: is-glob: 4.0.3 dev: true + /glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.3 + minipass: 7.0.4 + path-scurry: 1.10.1 + dev: false + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -4463,7 +4611,6 @@ packages: /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - dev: true /is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} @@ -4622,7 +4769,6 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} @@ -4675,6 +4821,15 @@ packages: istanbul-lib-report: 3.0.0 dev: true + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: false + /jest-changed-files@29.5.0: resolution: {integrity: sha512-IFG34IUMUaNBIxjQXF/iu7g6EcdMrGRRxaUSw92I/2g2YC6vCdTltl4nHvt7Ci5nSJwXIkCu8Ka1DKF+X7Z1Ag==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5134,6 +5289,31 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-diff@0.17.1: + resolution: {integrity: sha512-bBflcH+NRM/bKbw2G0WIh0ltCZb3PCyruTdopx3hZaXSHKM1+F7ILfDzyl9CxbLAS40/6EhkBYQUMFBefhBkgg==} + engines: {node: '>=6.14.1'} + hasBin: true + dependencies: + ajv: 8.12.0 + commander: 7.2.0 + json-schema-ref-parser: 9.0.9 + json-schema-spec-types: 0.1.2 + lodash: 4.17.21 + verror: 1.10.1 + dev: false + + /json-schema-ref-parser@9.0.9: + resolution: {integrity: sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==} + engines: {node: '>=10'} + deprecated: Please switch to @apidevtools/json-schema-ref-parser + dependencies: + '@apidevtools/json-schema-ref-parser': 9.0.9 + dev: false + + /json-schema-spec-types@0.1.2: + resolution: {integrity: sha512-MDl8fA8ONckmQOm2+eXKJaFJNvxk7eGin+XFofNjS3q3PRKSoEvgMVb0ehOpCAYkUiLoMiqdU7obV7AmzAmyLw==} + dev: false + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -5172,6 +5352,11 @@ packages: resolution: {integrity: sha512-890w2Pjtj0iswAxalRlt2kHthi6HKrXEfZcn+ZNZptv7F3rUGIeDuZo+C+h4vXBHLEsVjJrHeCm35nYeZLzSBQ==} engines: {node: '>=10.0.0'} + /jsonpointer@4.1.0: + resolution: {integrity: sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg==} + engines: {node: '>=0.10.0'} + dev: false + /jsonpointer@5.0.1: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} @@ -5256,7 +5441,10 @@ packages: /lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} - dev: true + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -5291,6 +5479,11 @@ packages: tslib: 2.5.0 dev: true + /lru-cache@10.1.0: + resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==} + engines: {node: 14 || >=16.14} + dev: false + /lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} dependencies: @@ -5414,6 +5607,13 @@ packages: brace-expansion: 2.0.1 dev: false + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -5427,6 +5627,11 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true + /minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + dev: false + /mixme@0.5.9: resolution: {integrity: sha512-VC5fg6ySUscaWUpI4gxCBTQMH2RdUpNrk+MsbpCYtIvf9SBJdiUey4qE7BXviJsJR4nDQxCZ+3yaYNW3guz/Pw==} engines: {node: '>= 8.0.0'} @@ -5592,10 +5797,35 @@ packages: is-wsl: 2.2.0 dev: true + /openapi-diff@0.23.6(openapi-types@12.1.0): + resolution: {integrity: sha512-ukUueb7BnumShfwpuwYs0ncE/WWLggph1L6IN5En02sEkaN0RrUFp/a0WgE/NNRm5S2JX/dvemsdUNrKPqiw5Q==} + engines: {node: '>=6.11.4'} + hasBin: true + dependencies: + axios: 0.24.0 + commander: 8.3.0 + js-yaml: 4.1.0 + json-schema-diff: 0.17.1 + jsonpointer: 4.1.0 + lodash: 4.17.21 + openapi3-ts: 2.0.2 + swagger-parser: 10.0.3(openapi-types@12.1.0) + verror: 1.10.1 + transitivePeerDependencies: + - debug + - openapi-types + dev: false + /openapi-types@12.1.0: resolution: {integrity: sha512-XpeCy01X6L5EpP+6Hc3jWN7rMZJ+/k1lwki/kTmWzbVhdPie3jd5O2ZtedEx8Yp58icJ0osVldLMrTB/zslQXA==} dev: false + /openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + dependencies: + yaml: 1.10.2 + dev: false + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -5768,7 +5998,6 @@ packages: /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -5786,6 +6015,14 @@ packages: path-root-regex: 0.1.2 dev: true + /path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.1.0 + minipass: 7.0.4 + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -6027,7 +6264,6 @@ packages: /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: true /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} @@ -6199,7 +6435,6 @@ packages: engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 - dev: true /shebang-regex@1.0.0: resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} @@ -6209,7 +6444,6 @@ packages: /shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} @@ -6223,6 +6457,11 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: false + /sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true @@ -6337,7 +6576,15 @@ packages: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.0.1 + dev: false /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} @@ -6375,14 +6622,12 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi@7.0.1: resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==} engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} @@ -6437,6 +6682,15 @@ packages: engines: {node: '>= 0.4'} dev: true + /swagger-parser@10.0.3(openapi-types@12.1.0): + resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} + engines: {node: '>=10'} + dependencies: + '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.0) + transitivePeerDependencies: + - openapi-types + dev: false + /syncpack@9.8.4: resolution: {integrity: sha512-i81rO+dHuJ2dO8YQq6SCExcyN0x9ZVTY7cVPn8pWjS5Dml0A8uM0cOaneOludFesdrLXMZUA/uEWa74ddBgkPQ==} engines: {node: '>=14'} @@ -6859,6 +7113,20 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /validator@13.11.0: + resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} + engines: {node: '>= 0.10'} + dev: false + + /verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + dev: false + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: @@ -6917,7 +7185,6 @@ packages: hasBin: true dependencies: isexe: 2.0.0 - dev: true /wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -6939,7 +7206,15 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.0.1 + dev: false /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6963,7 +7238,6 @@ packages: /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - dev: true /yallist@2.1.2: resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} @@ -6977,6 +7251,11 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: false + /yaml@2.3.1: resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} engines: {node: '>= 14'} @@ -6993,7 +7272,6 @@ packages: /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - dev: true /yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} @@ -7023,7 +7301,6 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - dev: true /yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -7037,6 +7314,18 @@ packages: engines: {node: '>=10'} dev: true + /z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.11.0 + optionalDependencies: + commander: 9.5.0 + dev: false + /zod@3.20.6: resolution: {integrity: sha512-oyu0m54SGCtzh6EClBVqDDlAYRz4jrVtKwQ7ZnsEmMI9HnzuZFj8QFwAY1M5uniIYACdGvv0PBWPF2kO0aNofA==} dev: true From 5bcacad69af80b04c206517d7ae2ebfe1d6624a6 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Mon, 11 Dec 2023 16:17:53 -0800 Subject: [PATCH 07/16] docs(changeset): OpenAPI 3 spec generation --- .changeset/witty-teachers-smoke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/witty-teachers-smoke.md diff --git a/.changeset/witty-teachers-smoke.md b/.changeset/witty-teachers-smoke.md new file mode 100644 index 0000000..748dff0 --- /dev/null +++ b/.changeset/witty-teachers-smoke.md @@ -0,0 +1,5 @@ +--- +"@nornir/rest": major +--- + +OpenAPI 3 spec generation From 71de82fac3e66515545a2b4fbafdbdf8b85ba6d7 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Mon, 11 Dec 2023 16:26:40 -0800 Subject: [PATCH 08/16] fix merge conflicts --- packages/rest/src/runtime/parse.mts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/runtime/parse.mts b/packages/rest/src/runtime/parse.mts index de0c646..19667bf 100644 --- a/packages/rest/src/runtime/parse.mts +++ b/packages/rest/src/runtime/parse.mts @@ -1,9 +1,8 @@ -import {HttpEvent, HttpResponse, HttpStatusCode, MimeType, UnparsedHttpEvent} from "./http-event.mjs"; +import {HttpEvent, HttpResponse, MimeType, UnparsedHttpEvent} from "./http-event.mjs"; import querystring from "node:querystring"; import {getContentType} from "./utils.mjs"; import {NornirRestError} from "./error.mjs"; -import {AttachmentRegistry} from "@nornir/core"; export type HttpBodyParser = (body: Buffer) => unknown @@ -17,9 +16,9 @@ export class NornirRestParseError extends NornirRestError { public toHttpResponse(): HttpResponse { return { - statusCode: HttpStatusCode.UnprocessableEntity, + statusCode: "422", headers: { - "content-type": MimeType.TextPlain, + "content-type": "text/plain", }, body: this.message } From 2c92f15dfda3de502a4c42fcbb62a8a13e38e57a Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Tue, 12 Dec 2023 14:20:10 -0800 Subject: [PATCH 09/16] fixes for spec generation --- packages/rest/__tests__/src/routing.spec.mts | 45 ++++---- packages/rest/src/cli/cli.ts | 2 +- packages/rest/src/cli/lib/ts-utils.ts | 1 - packages/rest/src/runtime/decorators.mts | 1 - packages/rest/src/runtime/http-event.mts | 106 +----------------- packages/rest/src/runtime/route-holder.mts | 2 +- packages/rest/src/runtime/router.mts | 10 +- .../rest/src/transform/controller-meta.ts | 43 +++---- .../rest/src/transform/json-schema-utils.ts | 14 +-- packages/rest/src/transform/project.ts | 2 +- packages/rest/src/transform/transform.ts | 43 +++++-- .../controller-method-transformer.ts | 11 +- .../transformers/file-transformer.ts | 1 - .../processors/chain-route-processor.ts | 12 +- packages/test/__tests__/tsconfig.json | 2 +- packages/test/src/controller.ts | 29 ++--- packages/test/src/rest.ts | 6 +- 17 files changed, 115 insertions(+), 215 deletions(-) diff --git a/packages/rest/__tests__/src/routing.spec.mts b/packages/rest/__tests__/src/routing.spec.mts index d5addc6..42be9a8 100644 --- a/packages/rest/__tests__/src/routing.spec.mts +++ b/packages/rest/__tests__/src/routing.spec.mts @@ -1,11 +1,9 @@ import { - AnyMimeType, Controller, GetChain, HttpEvent, HttpRequest, - HttpStatusCode, - MimeType, + HttpRequestEmpty, normalizeEventHeaders, NornirRestRequestValidationError, PostChain, @@ -15,13 +13,13 @@ import {nornir, Nornir} from "@nornir/core"; import {describe} from "@jest/globals"; import {NornirRouteNotFoundError} from "../../dist/runtime/router.mjs"; -interface RouteGetInput extends HttpRequest { +interface RouteGetInput extends HttpRequestEmpty { } interface RoutePostInputJSON extends HttpRequest { headers: { - "content-type": MimeType.ApplicationJson; + "content-type": "application/json"; }; body: RoutePostBodyInput; query: { @@ -31,7 +29,7 @@ interface RoutePostInputJSON extends HttpRequest { interface RoutePostInputCSV extends HttpRequest { headers: { - "content-type": MimeType.TextCsv; + "content-type": "text/csv"; /** * This is a CSV header * @example "cool,cool2" @@ -84,24 +82,24 @@ class TestController { return chain .use(console.log) .use(() => ({ - statusCode: HttpStatusCode.Ok, + statusCode: "200", body: `cool`, headers: { - "content-type": MimeType.TextPlain, + "content-type": "text/plain" }, - })); + } as const)); } @GetChain("/route2") public getEmptyRoute(chain: Nornir) { return chain - .use(() => ({ - statusCode: HttpStatusCode.Ok, - body: undefined, - headers: { - "content-type": AnyMimeType - }, - })); + .use(() => { + return { + statusCode: "200" as const, + body: undefined, + headers: {}, + } + }); } @PostChain("/route") @@ -109,12 +107,12 @@ class TestController { return chain .use(input => input.headers["content-type"]) .use(contentType => ({ - statusCode: HttpStatusCode.Ok, + statusCode: "200", body: `Content-Type: ${contentType}`, headers: { - "content-type": MimeType.TextPlain, + "content-type": "text/plain" }, - })); + } as const)); } } @@ -133,7 +131,7 @@ describe("REST tests", () => { headers: {}, query: {} }); - expect(response.statusCode).toEqual(HttpStatusCode.Ok); + expect(response.statusCode).toEqual("200"); expect(response.body).toBe("cool"); expect(response.headers["content-type"]).toBe("text/plain"); }) @@ -153,7 +151,7 @@ describe("REST tests", () => { } }); expect(response).toEqual({ - statusCode: HttpStatusCode.Ok, + statusCode: "200", body: "Content-Type: application/json", headers: { "content-type": "text/plain" @@ -169,10 +167,9 @@ describe("REST tests", () => { query: {} }); expect(response).toEqual({ - statusCode: HttpStatusCode.Ok, + statusCode: "200", body: undefined, headers: { - "content-type": AnyMimeType } }) }) @@ -195,7 +192,7 @@ describe("REST tests", () => { method: "POST", path: "/basepath/route", headers: { - "content-type": MimeType.ApplicationJson, + "content-type": "application/json", }, body: { cool: "cool" diff --git a/packages/rest/src/cli/cli.ts b/packages/rest/src/cli/cli.ts index a43130a..392e937 100644 --- a/packages/rest/src/cli/cli.ts +++ b/packages/rest/src/cli/cli.ts @@ -3,7 +3,7 @@ import { merge } from "lodash"; import path from "path"; import yargs from "yargs"; import { isErrorResult } from "../transform/openapi-merge"; -import { getMergedSpec, getSpecFiles } from "./lib/collect"; +import { getMergedSpec } from "./lib/collect"; import { resolveTsConfigOutdir } from "./lib/ts-utils"; yargs diff --git a/packages/rest/src/cli/lib/ts-utils.ts b/packages/rest/src/cli/lib/ts-utils.ts index 74b1321..f743775 100644 --- a/packages/rest/src/cli/lib/ts-utils.ts +++ b/packages/rest/src/cli/lib/ts-utils.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import { dirname, resolve } from "node:path"; import ts from "typescript"; diff --git a/packages/rest/src/runtime/decorators.mts b/packages/rest/src/runtime/decorators.mts index 01b9525..4126b5f 100644 --- a/packages/rest/src/runtime/decorators.mts +++ b/packages/rest/src/runtime/decorators.mts @@ -96,6 +96,5 @@ export function Provider() { } } -type Exact = IfEquals; type IfEquals = (() => G extends T ? 1 : 2) extends (() => G extends U ? 1 : 2) ? Y : N; diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index 5bde8d7..e907e10 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -1,5 +1,3 @@ -import {Nominal} from "./utils.mjs"; - export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; export type HttpEvent = Omit & { @@ -22,10 +20,10 @@ export type HttpHeadersWithoutContentType = { } & HttpHeaders -export type HttpHeaders = Record; +export type HttpHeaders = Record; export interface HttpRequest { - readonly headers: HttpHeadersWithContentType | HttpHeadersWithoutContentType; + readonly headers: HttpHeadersWithoutContentType | HttpHeadersWithContentType; readonly query: QueryParams; @@ -39,7 +37,8 @@ type QueryParams = Record | Ar type PathParams = Record; export interface HttpRequestEmpty extends HttpRequest { - body?: undefined; + headers: HttpHeadersWithoutContentType + body?: undefined } export interface HttpRequestJSON extends HttpRequest { @@ -60,70 +59,9 @@ export interface SerializedHttpResponse extends Omit { } export interface HttpResponseEmpty extends HttpResponse { - body?: undefined; + readonly body?: undefined } -// export enum HttpStatusCode { -// Continue = "100", -// SwitchingProtocols = "101", -// Processing = "102", -// Ok = "200", -// Created = "201", -// Accepted = "202", -// NonAuthoritativeInformation = "203", -// NoContent = "204", -// ResetContent = "205", -// PartialContent = "206", -// MultiStatus = "207", -// AlreadyReported = "208", -// IMUsed = "226", -// MultipleChoices = "300", -// MovedPermanently = "301", -// Found = "302", -// SeeOther = "303", -// NotModified = "304", -// UseProxy = "305", -// TemporaryRedirect = "307", -// PermanentRedirect = "308", -// BadRequest = "400", -// Unauthorized = "401", -// PaymentRequired = "402", -// Forbidden = "403", -// NotFound = "404", -// MethodNotAllowed = "405", -// NotAcceptable = "406", -// ProxyAuthenticationRequired = "407", -// RequestTimeout = "408", -// Conflict = "409", -// Gone = "410", -// LengthRequired = "411", -// PreconditionFailed = "412", -// PayloadTooLarge = "413", -// RequestURITooLong = "414", -// UnsupportedMediaType = "415", -// RequestedRangeNotSatisfiable = "416", -// ExpectationFailed = "417", -// ImATeapot = "418", -// MisdirectedRequest = "421", -// UnprocessableEntity = "422", -// Locked = "423", -// FailedDependency = "424", -// UpgradeRequired = "426", -// PreconditionRequired = "428", -// TooManyRequests = "429", -// RequestHeaderFieldsTooLarge = "431", -// UnavailableForLegalReasons = "451", -// InternalServerError = "500", -// NotImplemented = "501", -// BadGateway = "502", -// ServiceUnavailable = "503", -// GatewayTimeout = "504", -// HTTPVersionNotSupported = "505", -// VariantAlsoNegotiates = "506", -// InsufficientStorage = "507", -// LoopDetected = "508", -// NotExtended = "510", -// } export type HttpStatusCode = | "100" @@ -186,40 +124,6 @@ export type HttpStatusCode = | "508" | "510"; - -// export enum MimeType { -// Any = "*/*", -// ApplicationJson = "application/json", -// ApplicationOctetStream = "application/octet-stream", -// ApplicationPdf = "application/pdf", -// ApplicationXWwwFormUrlencoded = "application/x-www-form-urlencoded", -// ApplicationZip = "application/zip", -// ApplicationGzip = "application/gzip", -// ApplicationBzip = "application/bzip", -// ApplicationBzip2 = "application/bzip2", -// ApplicationLdJson = "application/ld+json", -// FontWoff = "font/woff", -// FontWoff2 = "font/woff2", -// FontTtf = "font/ttf", -// FontOtf = "font/otf", -// AudioMpeg = "audio/mpeg", -// AudioXWav = "audio/x-wav", -// ImageGif = "image/gif", -// ImageJpeg = "image/jpeg", -// ImagePng = "image/png", -// MultipartFormData = "multipart/form-data", -// TextCss = "text/css", -// TextCsv = "text/csv", -// TextHtml = "text/html", -// TextPlain = "text/plain", -// TextXml = "text/xml", -// VideoMpeg = "video/mpeg", -// VideoMp4 = "video/mp4", -// VideoQuicktime = "video/quicktime", -// VideoXMsVideo = "video/x-msvideo", -// VideoXFlv = "video/x-flv", -// VideoWebm = "video/webm", -// } export type MimeType = | "*/*" | "application/json" diff --git a/packages/rest/src/runtime/route-holder.mts b/packages/rest/src/runtime/route-holder.mts index 1a01cb5..a46806d 100644 --- a/packages/rest/src/runtime/route-holder.mts +++ b/packages/rest/src/runtime/route-holder.mts @@ -1,4 +1,4 @@ -import {HttpMethod, HttpRequest, HttpResponse, HttpStatusCode, MimeType} from './http-event.mjs'; +import {HttpMethod, HttpRequest, HttpResponse} from './http-event.mjs'; import {Nornir} from '@nornir/core'; import {NornirRestRequestError} from './error.mjs'; import {type ErrorObject, type ValidateFunction} from 'ajv' diff --git a/packages/rest/src/runtime/router.mts b/packages/rest/src/runtime/router.mts index e3ea5f4..2a3a30c 100644 --- a/packages/rest/src/runtime/router.mts +++ b/packages/rest/src/runtime/router.mts @@ -1,14 +1,6 @@ import Trouter from 'trouter'; import {RouteBuilder, RouteHolder} from './route-holder.mjs'; -import { - HttpEvent, - HttpHeadersWithContentType, - HttpMethod, - HttpRequest, - HttpResponse, - HttpStatusCode, - MimeType -} from './http-event.mjs'; +import {HttpEvent, HttpHeadersWithContentType, HttpMethod, HttpRequest, HttpResponse} from './http-event.mjs'; import {AttachmentRegistry, Nornir, Result} from '@nornir/core'; import {NornirRestRequestError} from "./error.mjs"; diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index 3306fd4..d756dbf 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -7,7 +7,6 @@ import { getFirstExample, getSchemaOrAllOf, getUnifiedPropertySchemas, - isSchemaEmpty, joinSchemas, moveRefsToAllOf, resolveDiscriminantProperty, @@ -34,6 +33,16 @@ export abstract class OpenApiSpecHolder { dispute: {}, })) as MergeInput; + if (mergeInputs.length === 0) { + return { + openapi: "3.0.3", + info: { + title: "Nornir API", + version: "1.0.0", + }, + components: {}, + }; + } const merged = merge(mergeInputs); if (isErrorResult(merged)) { throw new Error(merged.message); @@ -52,18 +61,6 @@ export class ControllerMeta { public readonly initializationStatements: ts.Statement[] = []; private instanceProviderExpression: ts.Expression; - // public static getRoutes(): RouteInfo[] { - // const methods = ControllerMeta.routes.values(); - // const routes = Array.from(methods).map((method) => Array.from(method.values())); - // return routes.flat(); - // } - - // public static getAndClearRoutes(): RouteInfo[] { - // const routes = this.getRoutes(); - // this.routes.clear(); - // return routes; - // } - public static clearCache() { this.cache.clear(); } @@ -95,14 +92,6 @@ export class ControllerMeta { return this.cache.get(name); } - public static getAssert(route: ts.ClassDeclaration): ControllerMeta { - const meta = this.get(route); - if (!meta) { - throw new Error("Route not found: " + route.getText()); - } - return meta; - } - private constructor( private readonly project: Project, public readonly source: ts.SourceFile, @@ -337,10 +326,12 @@ export class ControllerMeta { const content = contentTypeDiscriminatedSchemas == null ? undefined : Object.fromEntries( Object.entries(contentTypeDiscriminatedSchemas).map(([contentType, schema]) => { const branchBodySchemaSet = getUnifiedPropertySchemas(schema, "/")["body"]; - const branchBodySchema = rewriteRefsForOpenApi( - unresolveRefs(joinSchemas(branchBodySchemaSet.schemaSet)), - ); - const example = getFirstExample(branchBodySchema); + const branchBodySchema = branchBodySchemaSet != null + ? rewriteRefsForOpenApi( + unresolveRefs(joinSchemas(branchBodySchemaSet.schemaSet)), + ) + : undefined; + const example = branchBodySchema != null ? getFirstExample(branchBodySchema) : undefined; return [ contentType, { @@ -364,7 +355,7 @@ export class ControllerMeta { private generateRequestBody(routeIndex: RouteIndex, inputSchema: JSONSchema7): OpenAPIV3_1.RequestBodyObject | void { const bodySchema = getUnifiedPropertySchemas(inputSchema, "/")["body"]; const contentTypeDiscriminatedSchemas = resolveDiscriminantProperty(inputSchema, "/headers/content-type"); - if (bodySchema == null) { + if (bodySchema == null || (bodySchema.schemaSet.length === 1)) { return; } diff --git a/packages/rest/src/transform/json-schema-utils.ts b/packages/rest/src/transform/json-schema-utils.ts index e6b5a64..bdbaabc 100644 --- a/packages/rest/src/transform/json-schema-utils.ts +++ b/packages/rest/src/transform/json-schema-utils.ts @@ -4,7 +4,6 @@ import dereference from "@apidevtools/json-schema-ref-parser/dist/lib/dereferenc import { JSONSchema7 } from "json-schema"; import traverse from "json-schema-traverse"; import { cloneDeep } from "lodash"; -import { OpenAPIV3_1 } from "openapi-types"; const refParser = new $RefParser(); @@ -36,11 +35,9 @@ export function joinSchemas(schemaSet: JSONSchema7[]): JSONSchema7 { return schemaSet[0]; } - const joinedSchema: JSONSchema7 = { + return { allOf: schemaSet, }; - - return joinedSchema; } export function getUnifiedPropertySchemas( @@ -94,7 +91,7 @@ export function getUnifiedPropertySchemas( traverse(schema, { allKeys: false, cb: { - pre(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) { + pre(schema, jsonPtr, rootSchema, parentJsonPtr) { const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); if (convertedPath === parentPath && parentJsonPtr != "") { parentSchemas++; @@ -103,8 +100,6 @@ export function getUnifiedPropertySchemas( post: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); - const convertedParentPath = convertJsonSchemaPathIfPropertyPath(parentJsonPtr || "/"); - if (convertedPath != null && isDirectChildPath(convertedPath, parentPath)) { const schemaSet = schemas[keyIndex || ""] || { schemaSet: [], @@ -146,7 +141,7 @@ export function unresolveRefs(schema: JSONSchema7) { } const MOVED_REF_MARKER = Symbol("moved-ref-marker"); -export function moveRefsToAllOf(schema: JSONSchema7, markMovedRef = true) { +export function moveRefsToAllOf(schema: JSONSchema7) { const clonedSchema = cloneDeep(schema); traverse(clonedSchema, { cb: { @@ -286,9 +281,6 @@ export function isSchemaEmpty(schema: JSONSchema7): boolean { if (key.startsWith("x-") || key.startsWith("$")) { continue; } - if (key === "not" && isSchemaEmpty(schema.not as JSONSchema7)) { - continue; - } return false; } return true; diff --git a/packages/rest/src/transform/project.ts b/packages/rest/src/transform/project.ts index b474c93..db45c26 100644 --- a/packages/rest/src/transform/project.ts +++ b/packages/rest/src/transform/project.ts @@ -41,7 +41,7 @@ export const SCHEMA_DEFAULTS: Config = { jsDoc: "extended", sortProps: true, strictTuples: false, - encodeRefs: true, + encodeRefs: false, additionalProperties: true, topRef: false, discriminatorType: "open-api", diff --git a/packages/rest/src/transform/transform.ts b/packages/rest/src/transform/transform.ts index 4a3a7b9..db72ef2 100644 --- a/packages/rest/src/transform/transform.ts +++ b/packages/rest/src/transform/transform.ts @@ -1,38 +1,59 @@ import { BaseType, - Context, createFormatter, createParser, - NeverType, - ReferenceType, + Definition, SchemaGenerator, StringType, SubNodeParser, + SubTypeFormatter, + UndefinedType, } from "ts-json-schema-generator"; import ts from "typescript"; import { AJV_DEFAULTS, AJVOptions, Options, Project, SCHEMA_DEFAULTS, SchemaConfig } from "./project.js"; import { FileTransformer } from "./transformers/file-transformer.js"; let project: Project; -// let files: string[] = []; -// let openapi: OpenAPIV3.Document; export class TemplateExpressionNodeParser implements SubNodeParser { supportsNode(node: ts.Node): boolean { return ts.isTemplateExpression(node); } - createType(node: ts.Node, context: Context, reference?: ReferenceType): BaseType { + createType(): BaseType { return new StringType(); } } +export class UndefinedIdentifierParser implements SubNodeParser { + supportsNode(node: ts.Node): boolean { + return ts.isIdentifier(node) && node.text === "undefined"; + } + + createType(): BaseType { + return new UndefinedType(); + } +} + +export class UndefinedFormatter implements SubTypeFormatter { + public supportsType(type: BaseType): boolean { + return type instanceof UndefinedType; + } + + getChildren(): BaseType[] { + return []; + } + + getDefinition(): Definition { + return {}; + } +} + export function transform(program: ts.Program, options?: Options): ts.TransformerFactory { const { loopEnum, loopRequired, additionalProperties, - encodeRefs, strictTuples, jsDoc, removeAdditional, @@ -46,7 +67,6 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme ...SCHEMA_DEFAULTS, jsDoc: jsDoc || SCHEMA_DEFAULTS.jsDoc, strictTuples: strictTuples || SCHEMA_DEFAULTS.strictTuples, - encodeRefs: false, additionalProperties: additionalProperties || SCHEMA_DEFAULTS.additionalProperties, sortProps: sortProps || SCHEMA_DEFAULTS.sortProps, expose, @@ -66,9 +86,12 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme }, (prs) => { // prs.addNodeParser(new NornirIgnoreParser()); prs.addNodeParser(new TemplateExpressionNodeParser()); + prs.addNodeParser(new UndefinedIdentifierParser()); }); const typeFormatter = createFormatter({ ...schemaConfig, + }, frm => { + frm.addTypeFormatter(new UndefinedFormatter()); }); const schemaGenerator = new SchemaGenerator( @@ -82,6 +105,8 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme ? ts.createIncrementalCompilerHost(program.getCompilerOptions()) : ts.createCompilerHost(program.getCompilerOptions()); + const configPath = program.getCompilerOptions().configFilePath as string; + const parseConfigHost: ts.ParseConfigFileHost = { useCaseSensitiveFileNames: true, fileExists: fileName => compilerHost!.fileExists(fileName), @@ -110,7 +135,7 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme typeFormatter, compilerHost, parsedCommandLine: ts.getParsedCommandLineOfConfigFile( - ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json") as string, + configPath, program.getCompilerOptions(), parseConfigHost, )!, diff --git a/packages/rest/src/transform/transformers/controller-method-transformer.ts b/packages/rest/src/transform/transformers/controller-method-transformer.ts index 47c7bec..16f993b 100644 --- a/packages/rest/src/transform/transformers/controller-method-transformer.ts +++ b/packages/rest/src/transform/transformers/controller-method-transformer.ts @@ -1,5 +1,6 @@ import ts from "typescript"; import { ControllerMeta } from "../controller-meta"; +import { TransformationError } from "../error"; import { NornirDecoratorInfo, separateNornirDecorators } from "../lib"; import { Project } from "../project"; import { ChainMethodDecoratorTypes, ChainRouteProcessor } from "./processors/chain-route-processor"; @@ -28,7 +29,15 @@ export abstract class ControllerMethodTransformer { if (!method) return node; - return METHOD_DECORATOR_PROCESSORS[method](methodDecorator, project, source, node, controller); + try { + return METHOD_DECORATOR_PROCESSORS[method](methodDecorator, project, source, node, controller); + } catch (e) { + console.error(e); + if (e instanceof TransformationError) { + throw e; + } + return node; + } } public static transformControllerMethods( diff --git a/packages/rest/src/transform/transformers/file-transformer.ts b/packages/rest/src/transform/transformers/file-transformer.ts index 8a0cbd1..fe70cb6 100644 --- a/packages/rest/src/transform/transformers/file-transformer.ts +++ b/packages/rest/src/transform/transformers/file-transformer.ts @@ -1,5 +1,4 @@ import { rmSync } from "fs"; -import { parseJsDocOfNode } from "tsutils"; import ts from "typescript"; import { ControllerMeta, OpenApiSpecHolder } from "../controller-meta"; import { TransformationError } from "../error"; diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index f42864b..89911dd 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -1,6 +1,5 @@ import { schemaToValidator } from "@nrfcloud/ts-json-schema-transformer/utils"; import tsp from "ts-morph"; -import { isTypeReference } from "tsutils"; import ts from "typescript"; import { ControllerMeta } from "../../controller-meta"; import { moveRefsToAllOf } from "../../json-schema-utils"; @@ -48,7 +47,7 @@ export abstract class ChainRouteProcessor { // const wrappedNode = createWrappedNode(node, { typeChecker: project.checker }) as MethodDeclaration; - const { typeNode: inputTypeNode, type: inputType } = ChainRouteProcessor.resolveInputType(project, node); + const { typeNode: inputTypeNode } = ChainRouteProcessor.resolveInputType(project, node); const outputType = ChainRouteProcessor.resolveOutputType(project, node); @@ -56,7 +55,7 @@ export abstract class ChainRouteProcessor { const inputSchema = project.schemaGenerator.createSchemaFromNodes([inputTypeNode]); - const inputValidator = schemaToValidator(moveRefsToAllOf(inputSchema, false), project.options.validation); + const inputValidator = schemaToValidator(moveRefsToAllOf(inputSchema), project.options.validation); const parsedDocComments = ChainRouteProcessor.parseJSDoc(project, node); @@ -103,9 +102,12 @@ export abstract class ChainRouteProcessor { return recreatedNode; } - private static parseJSDoc(project: Project, method: ts.MethodDeclaration): RouteTags { + private static parseJSDoc(_project: Project, method: ts.MethodDeclaration): RouteTags { const docs = ts.getJSDocCommentsAndTags(ts.getOriginalNode(method)); const topLevel = docs[0]; + if (!topLevel) { + return {}; + } const description = ts.getTextOfJSDocComment(topLevel.comment); if (!ts.isJSDoc(topLevel)) { return {}; @@ -168,7 +170,7 @@ export abstract class ChainRouteProcessor { return path; } - private static getMethod(project: Project, methodDecorator: NornirDecoratorInfo): string { + private static getMethod(_project: Project, methodDecorator: NornirDecoratorInfo): string { const name = methodDecorator.symbol.name; return ChainMethodDecoratorTypeMap[name as keyof typeof ChainMethodDecoratorTypeMap]; } diff --git a/packages/test/__tests__/tsconfig.json b/packages/test/__tests__/tsconfig.json index 6aefe25..bda034e 100644 --- a/packages/test/__tests__/tsconfig.json +++ b/packages/test/__tests__/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "pretty": true, - + "incremental": false, "baseUrl": ".", "outDir": "dist", "rootDir": "src", diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index 9997e5c..51c383d 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -1,14 +1,5 @@ import { Nornir } from "@nornir/core"; -import { - Controller, - GetChain, - type HttpRequest, - HttpRequestEmpty, - HttpResponse, - HttpStatusCode, - MimeType, - PostChain, -} from "@nornir/rest"; +import { Controller, GetChain, type HttpRequest, HttpRequestEmpty, HttpResponse, PostChain } from "@nornir/rest"; import { assertValid } from "@nrfcloud/ts-json-schema-transformer"; interface RouteGetInput extends HttpRequestEmpty { @@ -97,12 +88,13 @@ export interface RouteGetOutputSuccess extends HttpResponse { */ export interface RouteGetOutputError extends HttpResponse { statusCode: "400"; - /** - * @example { "message": "Bad Request"} - */ - body: { - message: string; - }; + // /** + // * @example { "message": "Bad Request"} + // */ + // body: { + // message: string; + // }; + body: undefined; headers: { "content-type": "application/json"; }; @@ -166,7 +158,7 @@ export class TestController { return input; }) .use(input => input.headers?.toString()) - .use(contentType => ({ + .use(_contentType => ({ statusCode: "200" as const, body: { bleep: "bloop", @@ -188,8 +180,9 @@ export class TestController { @PostChain("/route/{cool}") public postRoute(chain: Nornir) { return chain - .use(contentType => ({ + .use(_contentType => ({ statusCode: "200" as const, + body: undefined, // body: `Content-Type: ${contentType}`, headers: {}, })); diff --git a/packages/test/src/rest.ts b/packages/test/src/rest.ts index a3c8f82..58c1906 100644 --- a/packages/test/src/rest.ts +++ b/packages/test/src/rest.ts @@ -4,9 +4,7 @@ import { httpErrorHandler, httpEventParser, httpResponseSerializer, - HttpStatusCode, mapErrorClass, - MimeType, normalizeEventHeaders, router, startLocalServer, @@ -32,11 +30,11 @@ export class TestError implements NodeJS.ErrnoException { const frameworkChain = nornir() .use(normalizeEventHeaders) .use(httpEventParser({ - "text/csv": body => ({ cool: "stuff" }), + "text/csv": _body => ({ cool: "stuff" }), })) .use(router()) .useResult(httpErrorHandler([ - mapErrorClass(TestError, (err) => ({ + mapErrorClass(TestError, (_err) => ({ statusCode: "500", headers: {}, })), From 8771d52741ea4b3405012bae97274933bfc50385 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Fri, 15 Dec 2023 12:32:33 -0800 Subject: [PATCH 10/16] update to typescript 5.3 --- package.json | 2 +- packages/core/package.json | 4 +- packages/rest/package.json | 8 +- packages/test/package.json | 4 +- pnpm-lock.yaml | 382 +++++++++++++++++++++++++++++-------- 5 files changed, 315 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index f3abbe8..ee86fea 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "syncpack": "^9.8.4", "ts-patch": "^3.1.1", "turbo": "^1.9.2", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index e01b08d..0521159 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,14 +5,14 @@ "author": "John Conley", "devDependencies": { "@jest/globals": "^29.5.0", - "@nrfcloud/ts-json-schema-transformer": "^1.2.5", + "@nrfcloud/ts-json-schema-transformer": "^1.3.0", "@types/jest": "^29.4.0", "@types/node": "^18.15.11", "esbuild": "^0.17.18", "eslint": "^8.45.0", "jest": "^29.5.0", "ts-patch": "^3.1.1", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0", diff --git a/packages/rest/package.json b/packages/rest/package.json index 1da6198..4650620 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -8,7 +8,7 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", "@nornir/core": "workspace:^", - "@nrfcloud/ts-json-schema-transformer": "^1.2.5", + "@nrfcloud/ts-json-schema-transformer": "^1.3.0", "@types/aws-lambda": "^8.10.115", "ajv": "^8.12.0", "atlassian-openapi": "^1.0.18", @@ -20,7 +20,7 @@ "trouter": "^3.2.1", "ts-is-present": "^1.2.2", "ts-json-schema-generator": "^1.5.0", - "ts-morph": "^20.0.0", + "ts-morph": "^21.0.1", "tsutils": "^3.21.0", "yargs": "^17.7.2" }, @@ -34,7 +34,7 @@ "eslint": "^8.45.0", "jest": "^29.5.0", "ts-patch": "^3.1.1", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0", @@ -84,6 +84,8 @@ "test": "pnpm tests" }, "tsp": { + "name": "@nornir/rest", + "transform": "./dist/transform/transform.js", "tscOptions": { "parseAllJsDoc": true } diff --git a/packages/test/package.json b/packages/test/package.json index 780173e..509b568 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@jest/globals": "^29.5.0", - "@nrfcloud/ts-json-schema-transformer": "^1.2.5", + "@nrfcloud/ts-json-schema-transformer": "^1.3.0", "@types/aws-lambda": "^8.10.115", "@types/jest": "^29.4.0", "@types/node": "^18.15.11", @@ -16,7 +16,7 @@ "eslint": "^8.45.0", "jest": "^29.5.0", "ts-patch": "^3.1.1", - "typescript": "^5.2.2" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d744e10..f788bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,10 +31,10 @@ importers: version: 18.15.11 '@typescript-eslint/eslint-plugin': specifier: ^6.2.0 - version: 6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.2.2) + version: 6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^6.2.0 - version: 6.2.0(eslint@8.45.0)(typescript@5.2.2) + version: 6.2.0(eslint@8.45.0)(typescript@5.3.3) dprint: specifier: ^0.34.5 version: 0.34.5 @@ -52,7 +52,7 @@ importers: version: 3.2.0(eslint@8.45.0) eslint-plugin-jest: specifier: ^27.2.3 - version: 27.2.3(@typescript-eslint/eslint-plugin@6.2.0)(eslint@8.45.0)(jest@29.5.0)(typescript@5.2.2) + version: 27.2.3(@typescript-eslint/eslint-plugin@6.2.0)(eslint@8.45.0)(jest@29.5.0)(typescript@5.3.3) eslint-plugin-no-secrets: specifier: ^0.8.9 version: 0.8.9(eslint@8.45.0) @@ -87,8 +87,8 @@ importers: specifier: ^1.9.2 version: 1.9.2 typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.3.3 + version: 5.3.3 packages/core: devDependencies: @@ -96,8 +96,8 @@ importers: specifier: ^29.5.0 version: 29.5.0 '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.5 - version: 1.2.5(typescript@5.2.2) + specifier: ^1.3.0 + version: 1.3.0(typescript@5.3.3) '@types/jest': specifier: ^29.4.0 version: 29.4.0 @@ -117,8 +117,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.3.3 + version: 5.3.3 packages/rest: dependencies: @@ -129,8 +129,8 @@ importers: specifier: workspace:^ version: link:../core '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.5 - version: 1.2.5(typescript@5.2.2) + specifier: ^1.3.0 + version: 1.3.0(typescript@5.3.3) '@types/aws-lambda': specifier: ^8.10.115 version: 8.10.115 @@ -165,11 +165,11 @@ importers: specifier: ^1.5.0 version: 1.5.0 ts-morph: - specifier: ^20.0.0 - version: 20.0.0 + specifier: ^21.0.1 + version: 21.0.1 tsutils: specifier: ^3.21.0 - version: 3.21.0(typescript@5.2.2) + version: 3.21.0(typescript@5.3.3) yargs: specifier: ^17.7.2 version: 17.7.2 @@ -202,8 +202,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.3.3 + version: 5.3.3 packages/scripts: {} @@ -220,8 +220,8 @@ importers: specifier: ^29.5.0 version: 29.5.0 '@nrfcloud/ts-json-schema-transformer': - specifier: ^1.2.5 - version: 1.2.5(typescript@5.2.2) + specifier: ^1.3.0 + version: 1.3.0(typescript@5.3.3) '@types/aws-lambda': specifier: ^8.10.115 version: 8.10.115 @@ -244,8 +244,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.3.3 + version: 5.3.3 packages: @@ -1755,6 +1755,15 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.19.9: + resolution: {integrity: sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true optional: true /@esbuild/android-arm@0.17.18: @@ -1763,6 +1772,15 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.19.9: + resolution: {integrity: sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true optional: true /@esbuild/android-x64@0.17.18: @@ -1771,6 +1789,15 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.19.9: + resolution: {integrity: sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true optional: true /@esbuild/darwin-arm64@0.17.18: @@ -1779,6 +1806,15 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.19.9: + resolution: {integrity: sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/darwin-x64@0.17.18: @@ -1787,6 +1823,15 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.19.9: + resolution: {integrity: sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/freebsd-arm64@0.17.18: @@ -1795,6 +1840,15 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.19.9: + resolution: {integrity: sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/freebsd-x64@0.17.18: @@ -1803,6 +1857,15 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.19.9: + resolution: {integrity: sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/linux-arm64@0.17.18: @@ -1811,6 +1874,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.19.9: + resolution: {integrity: sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-arm@0.17.18: @@ -1819,6 +1891,15 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.19.9: + resolution: {integrity: sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ia32@0.17.18: @@ -1827,6 +1908,15 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.19.9: + resolution: {integrity: sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-loong64@0.17.18: @@ -1835,6 +1925,15 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.19.9: + resolution: {integrity: sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-mips64el@0.17.18: @@ -1843,6 +1942,15 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.19.9: + resolution: {integrity: sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ppc64@0.17.18: @@ -1851,6 +1959,15 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.19.9: + resolution: {integrity: sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-riscv64@0.17.18: @@ -1859,6 +1976,15 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.19.9: + resolution: {integrity: sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-s390x@0.17.18: @@ -1867,6 +1993,15 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.19.9: + resolution: {integrity: sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-x64@0.17.18: @@ -1875,6 +2010,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.19.9: + resolution: {integrity: sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true optional: true /@esbuild/netbsd-x64@0.17.18: @@ -1883,6 +2027,15 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.19.9: + resolution: {integrity: sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true optional: true /@esbuild/openbsd-x64@0.17.18: @@ -1891,6 +2044,15 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.19.9: + resolution: {integrity: sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true optional: true /@esbuild/sunos-x64@0.17.18: @@ -1899,6 +2061,15 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.19.9: + resolution: {integrity: sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true optional: true /@esbuild/win32-arm64@0.17.18: @@ -1907,6 +2078,15 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.19.9: + resolution: {integrity: sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-ia32@0.17.18: @@ -1915,6 +2095,15 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.19.9: + resolution: {integrity: sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-x64@0.17.18: @@ -1923,6 +2112,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.19.9: + resolution: {integrity: sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.45.0): @@ -2327,18 +2525,18 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - /@nrfcloud/ts-json-schema-transformer@1.2.5(typescript@5.2.2): - resolution: {integrity: sha512-AL1ZYkwtusprB4Hsf5CuySaRT0hPUhVA8rWb58FS13FcZd9DKi8mKg6s09Jmy7Ersud0Q5WnkxHvEM+ltz8tqA==} + /@nrfcloud/ts-json-schema-transformer@1.3.0(typescript@5.3.3): + resolution: {integrity: sha512-b/WjwnLSYtikoADzb6gNPLM4BVELePplR2xOKEYTvj4ozGITlX6RaHVc70SXm4GwyX+riagL3dW2MmFBeM6NLw==} engines: {node: '>=18.0.0'} peerDependencies: typescript: '>=5' dependencies: '@apidevtools/json-schema-ref-parser': 10.1.0 ajv: 8.12.0 - esbuild: 0.17.18 + esbuild: 0.19.9 json-schema-faker: /@jfconley/json-schema-faker@0.5.0-rcv.48 ts-json-schema-generator: 1.5.0 - typescript: 5.2.2 + typescript: 5.3.3 /@parcel/source-map@2.1.1: resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} @@ -2370,12 +2568,12 @@ packages: '@sinonjs/commons': 2.0.0 dev: true - /@ts-morph/common@0.21.0: - resolution: {integrity: sha512-ES110Mmne5Vi4ypUKrtVQfXFDtCsDXiUiGxF6ILVlE90dDD4fdpC1LSjydl/ml7xJWKSDZwUYD2zkOePMSrPBA==} + /@ts-morph/common@0.22.0: + resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==} dependencies: - fast-glob: 3.2.12 - minimatch: 7.4.6 - mkdirp: 2.1.6 + fast-glob: 3.3.2 + minimatch: 9.0.3 + mkdirp: 3.0.1 path-browserify: 1.0.1 dev: false @@ -2519,7 +2717,7 @@ packages: '@types/yargs-parser': 21.0.0 dev: true - /@typescript-eslint/eslint-plugin@6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.2.2): + /@typescript-eslint/eslint-plugin@6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.3.3): resolution: {integrity: sha512-rClGrMuyS/3j0ETa1Ui7s6GkLhfZGKZL3ZrChLeAiACBE/tRc1wq8SNZESUuluxhLj9FkUefRs2l6bCIArWBiQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -2531,10 +2729,10 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 6.2.0(eslint@8.45.0)(typescript@5.2.2) + '@typescript-eslint/parser': 6.2.0(eslint@8.45.0)(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.2.0 - '@typescript-eslint/type-utils': 6.2.0(eslint@8.45.0)(typescript@5.2.2) - '@typescript-eslint/utils': 6.2.0(eslint@8.45.0)(typescript@5.2.2) + '@typescript-eslint/type-utils': 6.2.0(eslint@8.45.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.2.0(eslint@8.45.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.2.0 debug: 4.3.4 eslint: 8.45.0 @@ -2543,13 +2741,13 @@ packages: natural-compare: 1.4.0 natural-compare-lite: 1.4.0 semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.1(typescript@5.3.3) + typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@6.2.0(eslint@8.45.0)(typescript@5.2.2): + /@typescript-eslint/parser@6.2.0(eslint@8.45.0)(typescript@5.3.3): resolution: {integrity: sha512-igVYOqtiK/UsvKAmmloQAruAdUHihsOCvplJpplPZ+3h4aDkC/UKZZNKgB6h93ayuYLuEymU3h8nF1xMRbh37g==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -2561,11 +2759,11 @@ packages: dependencies: '@typescript-eslint/scope-manager': 6.2.0 '@typescript-eslint/types': 6.2.0 - '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.2.0 debug: 4.3.4 eslint: 8.45.0 - typescript: 5.2.2 + typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true @@ -2586,7 +2784,7 @@ packages: '@typescript-eslint/visitor-keys': 6.2.0 dev: true - /@typescript-eslint/type-utils@6.2.0(eslint@8.45.0)(typescript@5.2.2): + /@typescript-eslint/type-utils@6.2.0(eslint@8.45.0)(typescript@5.3.3): resolution: {integrity: sha512-DnGZuNU2JN3AYwddYIqrVkYW0uUQdv0AY+kz2M25euVNlujcN2u+rJgfJsBFlUEzBB6OQkUqSZPyuTLf2bP5mw==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -2596,12 +2794,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.2.2) - '@typescript-eslint/utils': 6.2.0(eslint@8.45.0)(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.2.0(eslint@8.45.0)(typescript@5.3.3) debug: 4.3.4 eslint: 8.45.0 - ts-api-utils: 1.0.1(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.1(typescript@5.3.3) + typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true @@ -2616,7 +2814,7 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@5.59.2(typescript@5.2.2): + /@typescript-eslint/typescript-estree@5.59.2(typescript@5.3.3): resolution: {integrity: sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2631,13 +2829,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@5.2.2) - typescript: 5.2.2 + tsutils: 3.21.0(typescript@5.3.3) + typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/typescript-estree@6.2.0(typescript@5.2.2): + /@typescript-eslint/typescript-estree@6.2.0(typescript@5.3.3): resolution: {integrity: sha512-Mts6+3HQMSM+LZCglsc2yMIny37IhUgp1Qe8yJUYVyO6rHP7/vN0vajKu3JvHCBIy8TSiKddJ/Zwu80jhnGj1w==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -2652,13 +2850,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.1(typescript@5.3.3) + typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.59.2(eslint@8.45.0)(typescript@5.2.2): + /@typescript-eslint/utils@5.59.2(eslint@8.45.0)(typescript@5.3.3): resolution: {integrity: sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2669,7 +2867,7 @@ packages: '@types/semver': 7.5.0 '@typescript-eslint/scope-manager': 5.59.2 '@typescript-eslint/types': 5.59.2 - '@typescript-eslint/typescript-estree': 5.59.2(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 5.59.2(typescript@5.3.3) eslint: 8.45.0 eslint-scope: 5.1.1 semver: 7.5.4 @@ -2678,7 +2876,7 @@ packages: - typescript dev: true - /@typescript-eslint/utils@6.2.0(eslint@8.45.0)(typescript@5.2.2): + /@typescript-eslint/utils@6.2.0(eslint@8.45.0)(typescript@5.3.3): resolution: {integrity: sha512-RCFrC1lXiX1qEZN8LmLrxYRhOkElEsPKTVSNout8DMzf8PeWoQG7Rxz2SadpJa3VSh5oYKGwt7j7X/VRg+Y3OQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -2689,7 +2887,7 @@ packages: '@types/semver': 7.5.0 '@typescript-eslint/scope-manager': 6.2.0 '@typescript-eslint/types': 6.2.0 - '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.3.3) eslint: 8.45.0 semver: 7.5.4 transitivePeerDependencies: @@ -3666,6 +3864,36 @@ packages: '@esbuild/win32-arm64': 0.17.18 '@esbuild/win32-ia32': 0.17.18 '@esbuild/win32-x64': 0.17.18 + dev: true + + /esbuild@0.19.9: + resolution: {integrity: sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.19.9 + '@esbuild/android-arm64': 0.19.9 + '@esbuild/android-x64': 0.19.9 + '@esbuild/darwin-arm64': 0.19.9 + '@esbuild/darwin-x64': 0.19.9 + '@esbuild/freebsd-arm64': 0.19.9 + '@esbuild/freebsd-x64': 0.19.9 + '@esbuild/linux-arm': 0.19.9 + '@esbuild/linux-arm64': 0.19.9 + '@esbuild/linux-ia32': 0.19.9 + '@esbuild/linux-loong64': 0.19.9 + '@esbuild/linux-mips64el': 0.19.9 + '@esbuild/linux-ppc64': 0.19.9 + '@esbuild/linux-riscv64': 0.19.9 + '@esbuild/linux-s390x': 0.19.9 + '@esbuild/linux-x64': 0.19.9 + '@esbuild/netbsd-x64': 0.19.9 + '@esbuild/openbsd-x64': 0.19.9 + '@esbuild/sunos-x64': 0.19.9 + '@esbuild/win32-arm64': 0.19.9 + '@esbuild/win32-ia32': 0.19.9 + '@esbuild/win32-x64': 0.19.9 /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -3706,7 +3934,7 @@ packages: ignore: 5.2.4 dev: true - /eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.2.0)(eslint@8.45.0)(jest@29.5.0)(typescript@5.2.2): + /eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.2.0)(eslint@8.45.0)(jest@29.5.0)(typescript@5.3.3): resolution: {integrity: sha512-sRLlSCpICzWuje66Gl9zvdF6mwD5X86I4u55hJyFBsxYOsBCmT5+kSUjf+fkFWVMMgpzNEupjW8WzUqi83hJAQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -3719,8 +3947,8 @@ packages: jest: optional: true dependencies: - '@typescript-eslint/eslint-plugin': 6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.2.2) - '@typescript-eslint/utils': 5.59.2(eslint@8.45.0)(typescript@5.2.2) + '@typescript-eslint/eslint-plugin': 6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.45.0)(typescript@5.3.3) + '@typescript-eslint/utils': 5.59.2(eslint@8.45.0)(typescript@5.3.3) eslint: 8.45.0 jest: 29.5.0(@types/node@18.15.11) transitivePeerDependencies: @@ -3952,6 +4180,18 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 + dev: true + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: false /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -5600,13 +5840,6 @@ packages: brace-expansion: 2.0.1 dev: true - /minimatch@7.4.6: - resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} - engines: {node: '>=10'} - dependencies: - brace-expansion: 2.0.1 - dev: false - /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -5643,8 +5876,8 @@ packages: hasBin: true dev: true - /mkdirp@2.1.6: - resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + /mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} hasBin: true dev: false @@ -6771,13 +7004,13 @@ packages: regexparam: 1.3.0 dev: false - /ts-api-utils@1.0.1(typescript@5.2.2): + /ts-api-utils@1.0.1(typescript@5.3.3): resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} engines: {node: '>=16.13.0'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.2.2 + typescript: 5.3.3 dev: true /ts-is-present@1.2.2: @@ -6797,10 +7030,10 @@ packages: safe-stable-stringify: 2.4.3 typescript: 5.3.3 - /ts-morph@20.0.0: - resolution: {integrity: sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg==} + /ts-morph@21.0.1: + resolution: {integrity: sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==} dependencies: - '@ts-morph/common': 0.21.0 + '@ts-morph/common': 0.22.0 code-block-writer: 12.0.0 dev: false @@ -6823,14 +7056,14 @@ packages: resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} dev: true - /tsutils@3.21.0(typescript@5.2.2): + /tsutils@3.21.0(typescript@5.3.3): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 5.2.2 + typescript: 5.3.3 /tty-table@4.2.1: resolution: {integrity: sha512-xz0uKo+KakCQ+Dxj1D/tKn2FSyreSYWzdkL/BYhgN6oMW808g8QRMuh1atAV9fjTPbWBjfbkKQpI/5rEcnAc7g==} @@ -6982,11 +7215,6 @@ packages: is-typed-array: 1.1.12 dev: true - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} - engines: {node: '>=14.17'} - hasBin: true - /typescript@5.3.3: resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} engines: {node: '>=14.17'} From bf5e1862c842aa17ebb1f0416df0d13336d87eb0 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:30:44 -0800 Subject: [PATCH 11/16] bring back mime type and status code enum --- packages/rest/src/runtime/http-event.mts | 249 +++++++++++++-------- packages/rest/src/runtime/parse.mts | 6 +- packages/rest/src/runtime/route-holder.mts | 6 +- packages/rest/src/runtime/router.mts | 14 +- packages/test/src/controller.ts | 29 ++- packages/test/src/rest.ts | 3 +- 6 files changed, 193 insertions(+), 114 deletions(-) diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index e907e10..2c7cf5b 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -62,97 +62,158 @@ export interface HttpResponseEmpty extends HttpResponse { readonly body?: undefined } - -export type HttpStatusCode = - | "100" - | "101" - | "102" - | "200" - | "201" - | "202" - | "203" - | "204" - | "205" - | "206" - | "207" - | "208" - | "226" - | "300" - | "301" - | "302" - | "303" - | "304" - | "305" - | "307" - | "308" - | "400" - | "401" - | "402" - | "403" - | "404" - | "405" - | "406" - | "407" - | "408" - | "409" - | "410" - | "411" - | "412" - | "413" - | "414" - | "415" - | "416" - | "417" - | "418" - | "421" - | "422" - | "423" - | "424" - | "426" - | "428" - | "429" - | "431" - | "451" - | "500" - | "501" - | "502" - | "503" - | "504" - | "505" - | "506" - | "507" - | "508" - | "510"; - -export type MimeType = - | "*/*" - | "application/json" - | "application/octet-stream" - | "application/pdf" - | "application/x-www-form-urlencoded" - | "application/zip" - | "application/gzip" - | "application/bzip" - | "application/bzip2" - | "application/ld+json" - | "font/woff" - | "font/woff2" - | "font/ttf" - | "font/otf" - | "audio/mpeg" - | "audio/x-wav" - | "image/gif" - | "image/jpeg" - | "image/png" - | "multipart/form-data" - | "text/css" - | "text/csv" - | "text/html" - | "text/plain" - | "text/xml" - | "video/mpeg" - | "video/mp4" - | "video/quicktime" - | "video/x-msvideo" - | "video/x-flv" - | "video/webm"; +export enum HttpStatusCode { + Continue = "100", + SwitchingProtocols = "101", + Processing = "102", + Ok = "200", + Created = "201", + Accepted = "202", + NonAuthoritativeInformation = "203", + NoContent = "204", + ResetContent = "205", + PartialContent = "206", + MultiStatus = "207", + AlreadyReported = "208", + IMUsed = "226", + MultipleChoices = "300", + MovedPermanently = "301", + Found = "302", + SeeOther = "303", + NotModified = "304", + UseProxy = "305", + TemporaryRedirect = "307", + PermanentRedirect = "308", + BadRequest = "400", + Unauthorized = "401", + PaymentRequired = "402", + Forbidden = "403", + NotFound = "404", + MethodNotAllowed = "405", + NotAcceptable = "406", + ProxyAuthenticationRequired = "407", + RequestTimeout = "408", + Conflict = "409", + Gone = "410", + LengthRequired = "411", + PreconditionFailed = "412", + PayloadTooLarge = "413", + RequestURITooLong = "414", + UnsupportedMediaType = "415", + RequestedRangeNotSatisfiable = "416", + ExpectationFailed = "417", + ImATeapot = "418", + MisdirectedRequest = "421", + UnprocessableEntity = "422", + Locked = "423", + FailedDependency = "424", + UpgradeRequired = "426", + PreconditionRequired = "428", + TooManyRequests = "429", + RequestHeaderFieldsTooLarge = "431", + UnavailableForLegalReasons = "451", + InternalServerError = "500", + NotImplemented = "501", + BadGateway = "502", + ServiceUnavailable = "503", + GatewayTimeout = "504", + HTTPVersionNotSupported = "505", + VariantAlsoNegotiates = "506", + InsufficientStorage = "507", + LoopDetected = "508", + NotExtended = "510", +} +// +// export type HttpStatusCode = +// | "100" +// | "101" +// | "102" +// | "200" +// | "201" +// | "202" +// | "203" +// | "204" +// | "205" +// | "206" +// | "207" +// | "208" +// | "226" +// | "300" +// | "301" +// | "302" +// | "303" +// | "304" +// | "305" +// | "307" +// | "308" +// | "400" +// | "401" +// | "402" +// | "403" +// | "404" +// | "405" +// | "406" +// | "407" +// | "408" +// | "409" +// | "410" +// | "411" +// | "412" +// | "413" +// | "414" +// | "415" +// | "416" +// | "417" +// | "418" +// | "421" +// | "422" +// | "423" +// | "424" +// | "426" +// | "428" +// | "429" +// | "431" +// | "451" +// | "500" +// | "501" +// | "502" +// | "503" +// | "504" +// | "505" +// | "506" +// | "507" +// | "508" +// | "510"; + +export enum MimeType { + ApplicationJson = "application/json", + ApplicationOctetStream = "application/octet-stream", + ApplicationPdf = "application/pdf", + ApplicationXWwwFormUrlencoded = "application/x-www-form-urlencoded", + ApplicationZip = "application/zip", + ApplicationGzip = "application/gzip", + ApplicationBzip = "application/bzip", + ApplicationBzip2 = "application/bzip2", + ApplicationLdJson = "application/ld+json", + FontWoff = "font/woff", + FontWoff2 = "font/woff2", + FontTtf = "font/ttf", + FontOtf = "font/otf", + AudioMpeg = "audio/mpeg", + AudioXWav = "audio/x-wav", + ImageGif = "image/gif", + ImageJpeg = "image/jpeg", + ImagePng = "image/png", + MultipartFormData = "multipart/form-data", + TextCss = "text/css", + TextCsv = "text/csv", + TextHtml = "text/html", + TextPlain = "text/plain", + TextXml = "text/xml", + VideoMpeg = "video/mpeg", + VideoMp4 = "video/mp4", + VideoQuicktime = "video/quicktime", + VideoXMsVideo = "video/x-msvideo", + VideoXFlv = "video/x-flv", + VideoWebm = "video/webm", +} diff --git a/packages/rest/src/runtime/parse.mts b/packages/rest/src/runtime/parse.mts index 19667bf..8832f2d 100644 --- a/packages/rest/src/runtime/parse.mts +++ b/packages/rest/src/runtime/parse.mts @@ -1,4 +1,4 @@ -import {HttpEvent, HttpResponse, MimeType, UnparsedHttpEvent} from "./http-event.mjs"; +import {HttpEvent, HttpResponse, HttpStatusCode, MimeType, UnparsedHttpEvent} from "./http-event.mjs"; import querystring from "node:querystring"; import {getContentType} from "./utils.mjs"; @@ -16,9 +16,9 @@ export class NornirRestParseError extends NornirRestError { public toHttpResponse(): HttpResponse { return { - statusCode: "422", + statusCode: HttpStatusCode.UnprocessableEntity, headers: { - "content-type": "text/plain", + "content-type": MimeType.ApplicationJson, }, body: this.message } diff --git a/packages/rest/src/runtime/route-holder.mts b/packages/rest/src/runtime/route-holder.mts index a46806d..cafcc9e 100644 --- a/packages/rest/src/runtime/route-holder.mts +++ b/packages/rest/src/runtime/route-holder.mts @@ -1,4 +1,4 @@ -import {HttpMethod, HttpRequest, HttpResponse} from './http-event.mjs'; +import {HttpMethod, HttpRequest, HttpResponse, HttpStatusCode, MimeType} from './http-event.mjs'; import {Nornir} from '@nornir/core'; import {NornirRestRequestError} from './error.mjs'; import {type ErrorObject, type ValidateFunction} from 'ajv' @@ -50,10 +50,10 @@ export class NornirRestRequestValidationError exten toHttpResponse(): HttpResponse { return { - statusCode: "422", + statusCode: HttpStatusCode.UnprocessableEntity, body: {errors: this.errors}, headers: { - 'content-type': "application/json" + 'content-type': MimeType.ApplicationJson }, } } diff --git a/packages/rest/src/runtime/router.mts b/packages/rest/src/runtime/router.mts index 2a3a30c..b7e0be9 100644 --- a/packages/rest/src/runtime/router.mts +++ b/packages/rest/src/runtime/router.mts @@ -1,6 +1,14 @@ import Trouter from 'trouter'; import {RouteBuilder, RouteHolder} from './route-holder.mjs'; -import {HttpEvent, HttpHeadersWithContentType, HttpMethod, HttpRequest, HttpResponse} from './http-event.mjs'; +import { + HttpEvent, + HttpHeadersWithContentType, + HttpMethod, + HttpRequest, + HttpResponse, + HttpStatusCode, + MimeType +} from './http-event.mjs'; import {AttachmentRegistry, Nornir, Result} from '@nornir/core'; import {NornirRestRequestError} from "./error.mjs"; @@ -82,10 +90,10 @@ export class NornirRouteNotFoundError extends NornirRestRequestError, - ): Nornir }> { + ): Nornir }> { return chain .use(_contentType => ({ - statusCode: "200" as const, + statusCode: HttpStatusCode.Ok, body: undefined, // body: `Content-Type: ${contentType}`, headers: {}, diff --git a/packages/test/src/rest.ts b/packages/test/src/rest.ts index 58c1906..a976c39 100644 --- a/packages/test/src/rest.ts +++ b/packages/test/src/rest.ts @@ -4,6 +4,7 @@ import { httpErrorHandler, httpEventParser, httpResponseSerializer, + HttpStatusCode, mapErrorClass, normalizeEventHeaders, router, @@ -35,7 +36,7 @@ const frameworkChain = nornir() .use(router()) .useResult(httpErrorHandler([ mapErrorClass(TestError, (_err) => ({ - statusCode: "500", + statusCode: HttpStatusCode.BadRequest, headers: {}, })), ])) From 650141672fa7da65f514a05a2e26730b8b96cf76 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:37:19 -0800 Subject: [PATCH 12/16] fix tests --- packages/rest/__tests__/src/routing.spec.mts | 28 +++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/rest/__tests__/src/routing.spec.mts b/packages/rest/__tests__/src/routing.spec.mts index 42be9a8..cfa1993 100644 --- a/packages/rest/__tests__/src/routing.spec.mts +++ b/packages/rest/__tests__/src/routing.spec.mts @@ -7,7 +7,9 @@ import { normalizeEventHeaders, NornirRestRequestValidationError, PostChain, - router + router, + MimeType, + HttpStatusCode } from "../../dist/runtime/index.mjs"; import {nornir, Nornir} from "@nornir/core"; import {describe} from "@jest/globals"; @@ -19,7 +21,7 @@ interface RouteGetInput extends HttpRequestEmpty { interface RoutePostInputJSON extends HttpRequest { headers: { - "content-type": "application/json"; + "content-type": MimeType.ApplicationJson; }; body: RoutePostBodyInput; query: { @@ -29,7 +31,7 @@ interface RoutePostInputJSON extends HttpRequest { interface RoutePostInputCSV extends HttpRequest { headers: { - "content-type": "text/csv"; + "content-type": MimeType.TextCsv; /** * This is a CSV header * @example "cool,cool2" @@ -78,24 +80,24 @@ class TestController { * @summary Cool Route */ @GetChain("/route") - public getRoute(chain: Nornir) { + public getRoute(chain: Nornir): Nornir { return chain .use(console.log) .use(() => ({ - statusCode: "200", + statusCode: HttpStatusCode.Ok, body: `cool`, headers: { - "content-type": "text/plain" + "content-type": MimeType.TextPlain }, - } as const)); + })); } @GetChain("/route2") - public getEmptyRoute(chain: Nornir) { + public getEmptyRoute(chain: Nornir): Nornir}> { return chain .use(() => { return { - statusCode: "200" as const, + statusCode: HttpStatusCode.Ok, body: undefined, headers: {}, } @@ -103,14 +105,14 @@ class TestController { } @PostChain("/route") - public postRoute(chain: Nornir) { + public postRoute(chain: Nornir): Nornir { return chain .use(input => input.headers["content-type"]) .use(contentType => ({ - statusCode: "200", + statusCode: HttpStatusCode.Ok, body: `Content-Type: ${contentType}`, headers: { - "content-type": "text/plain" + "content-type": MimeType.TextPlain }, } as const)); } @@ -192,7 +194,7 @@ describe("REST tests", () => { method: "POST", path: "/basepath/route", headers: { - "content-type": "application/json", + "content-type": MimeType.ApplicationJson, }, body: { cool: "cool" From 57c216d94bfc6352a4d0e5f37920710381b2ae47 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Sat, 16 Dec 2023 18:05:02 -0800 Subject: [PATCH 13/16] lots of bug fixes --- packages/rest/src/runtime/decorators.mts | 26 +- packages/rest/src/runtime/http-event.mts | 71 +---- packages/rest/src/runtime/index.mts | 2 +- packages/rest/src/runtime/serialize.mts | 12 +- .../rest/src/transform/controller-meta.ts | 17 +- packages/rest/src/transform/error.ts | 16 +- .../rest/src/transform/json-schema-utils.ts | 84 ++++-- packages/rest/src/transform/project.ts | 65 +++- packages/rest/src/transform/transform.ts | 39 +-- .../controller-method-transformer.ts | 1 + .../processors/chain-route-processor.ts | 32 +- packages/test/openapi.json | 282 +++--------------- packages/test/src/controller.ts | 6 +- packages/test/src/rest.ts | 3 + 14 files changed, 270 insertions(+), 386 deletions(-) diff --git a/packages/rest/src/runtime/decorators.mts b/packages/rest/src/runtime/decorators.mts index 4126b5f..153faaf 100644 --- a/packages/rest/src/runtime/decorators.mts +++ b/packages/rest/src/runtime/decorators.mts @@ -1,5 +1,5 @@ import {Nornir} from "@nornir/core"; -import {HttpRequest, HttpResponse} from "./http-event.mjs"; +import {HttpRequest, HttpResponse, HttpStatusCode, MimeType} from "./http-event.mjs"; import {InstanceOf} from "ts-morph"; const UNTRANSFORMED_ERROR = new Error("nornir/rest decorators have not been transformed. Have you setup ts-patch/ttypescript and added the originator to your tsconfig.json?"); @@ -15,11 +15,31 @@ export function Controller( - _target: (chain: Nornir) => Nornir, +const routeChainDecorator = ( + _target: (chain: Nornir>) => Nornir, ValidateResponseType>, _propertyKey: ClassMethodDecoratorContext, ): never => {throw UNTRANSFORMED_ERROR}; +export type ValidateRequestType = RequestResponseWithBodyHasContentType extends true ? T : "Request type with a body must have a content-type header"; +export type ValidateResponseType = RequestResponseWithBodyHasContentType extends true ? + OutputHasSpecifiedStatusCode extends true + ? T : "Response type must have a status code specified" : "Response type with a body must have a content-type header"; + +type OutputHasSpecifiedStatusCode = IfEquals; + +type RequestResponseWithBodyHasContentType = + // No body spec is valid + HasBody extends false ? true : + // Empty body is valid + T extends { body?: undefined | null } ? true : + T["headers"]["content-type"] extends string ? + IfEquals + : false; + +type HasBody = T extends { body: any } ? true : false + +type Test = { statusCode: HttpStatusCode.Ok, headers: NonNullable, body: string} + /** * Use to mark a method as a GET route * diff --git a/packages/rest/src/runtime/http-event.mts b/packages/rest/src/runtime/http-event.mts index 2c7cf5b..16f17d8 100644 --- a/packages/rest/src/runtime/http-event.mts +++ b/packages/rest/src/runtime/http-event.mts @@ -62,6 +62,9 @@ export interface HttpResponseEmpty extends HttpResponse { readonly body?: undefined } +/** + * @ignore + */ export enum HttpStatusCode { Continue = "100", SwitchingProtocols = "101", @@ -123,69 +126,15 @@ export enum HttpStatusCode { LoopDetected = "508", NotExtended = "510", } -// -// export type HttpStatusCode = -// | "100" -// | "101" -// | "102" -// | "200" -// | "201" -// | "202" -// | "203" -// | "204" -// | "205" -// | "206" -// | "207" -// | "208" -// | "226" -// | "300" -// | "301" -// | "302" -// | "303" -// | "304" -// | "305" -// | "307" -// | "308" -// | "400" -// | "401" -// | "402" -// | "403" -// | "404" -// | "405" -// | "406" -// | "407" -// | "408" -// | "409" -// | "410" -// | "411" -// | "412" -// | "413" -// | "414" -// | "415" -// | "416" -// | "417" -// | "418" -// | "421" -// | "422" -// | "423" -// | "424" -// | "426" -// | "428" -// | "429" -// | "431" -// | "451" -// | "500" -// | "501" -// | "502" -// | "503" -// | "504" -// | "505" -// | "506" -// | "507" -// | "508" -// | "510"; +/** + * @ignore + */ export enum MimeType { + /** + * @ignore + */ + None = "", ApplicationJson = "application/json", ApplicationOctetStream = "application/octet-stream", ApplicationPdf = "application/pdf", diff --git a/packages/rest/src/runtime/index.mts b/packages/rest/src/runtime/index.mts index fb94fa6..04d5afa 100644 --- a/packages/rest/src/runtime/index.mts +++ b/packages/rest/src/runtime/index.mts @@ -9,7 +9,7 @@ import {httpErrorHandler} from "./error.mjs"; export { GetChain, Controller, PostChain, DeleteChain, HeadChain, OptionsChain, PatchChain, PutChain, - Provider + Provider, ValidateRequestType, ValidateResponseType } from './decorators.mjs'; export { HttpResponse, HttpRequest, HttpEvent, HttpMethod, HttpRequestEmpty, HttpResponseEmpty, diff --git a/packages/rest/src/runtime/serialize.mts b/packages/rest/src/runtime/serialize.mts index d123731..d5dce68 100644 --- a/packages/rest/src/runtime/serialize.mts +++ b/packages/rest/src/runtime/serialize.mts @@ -2,14 +2,14 @@ import {HttpResponse, MimeType, SerializedHttpResponse} from "./http-event.mjs"; import {getContentType} from "./utils.mjs"; -export type HttpBodySerializer = (body: T | undefined) => Buffer +export type HttpBodySerializer = (body: unknown | undefined) => Buffer -export type HttpBodySerializerMap = Partial>> +export type HttpBodySerializerMap = Partial> -const DEFAULT_BODY_SERIALIZERS: HttpBodySerializerMap & {default: HttpBodySerializer} = { - "application/json": (body?: object) => Buffer.from(JSON.stringify(body) || "", "utf8"), - "text/plain": (body?: string) => Buffer.from(body?.toString() || "", "utf8"), - "default": (body?: never) => Buffer.from(JSON.stringify(body) || "", "utf8") +const DEFAULT_BODY_SERIALIZERS: HttpBodySerializerMap & {default: HttpBodySerializer} = { + "application/json": (body) => Buffer.from(JSON.stringify(body) || "", "utf8"), + "text/plain": (body) => Buffer.from(body?.toString() || "", "utf8"), + "default": (body) => Buffer.from(JSON.stringify(body) || "", "utf8") } export function httpResponseSerializer(bodySerializerMap?: HttpBodySerializerMap) { diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index fa140f2..d5a907a 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -8,6 +8,7 @@ import { getSchemaOrAllOf, getUnifiedPropertySchemas, joinSchemas, + moveExamplesToExample, moveRefsToAllOf, resolveDiscriminantProperty, rewriteRefsForOpenApi, @@ -224,14 +225,20 @@ export class ControllerMeta { input: routeInfo.input, }; - OpenApiSpecHolder.addSpecForFile(this.source, this.generateRouteSpec(modifiedRouteInfo)); - + try { + OpenApiSpecHolder.addSpecForFile(this.source, this.generateRouteSpec(modifiedRouteInfo)); + } catch (e) { + if (e instanceof TransformationError) { + throw e; + } + console.error(e); + throw new TransformationError("Could not generate OpenAPI spec for route", modifiedRouteInfo); + } methods.set(index.method, modifiedRouteInfo); } private generateRouteSpec(route: RouteInfo): OpenAPIV3_1.Document { const inputSchema = moveRefsToAllOf(route.inputSchema); - const routeIndex = this.getRouteIndex(route); const dereferencedInputSchema = dereferenceSchema(inputSchema); const outputSchema = moveRefsToAllOf(route.outputSchema); const dereferencedOutputSchema = dereferenceSchema(outputSchema); @@ -261,8 +268,8 @@ export class ControllerMeta { }, components: { schemas: { - ...rewriteRefsForOpenApi(inputSchema).definitions, - ...rewriteRefsForOpenApi(outputSchema).definitions, + ...rewriteRefsForOpenApi(moveExamplesToExample(inputSchema)).definitions, + ...rewriteRefsForOpenApi(moveExamplesToExample(outputSchema)).definitions, }, parameters: {}, }, diff --git a/packages/rest/src/transform/error.ts b/packages/rest/src/transform/error.ts index f47814c..0245d29 100644 --- a/packages/rest/src/transform/error.ts +++ b/packages/rest/src/transform/error.ts @@ -1,18 +1,18 @@ import ts from "typescript"; import { RouteIndex } from "./controller-meta"; export class TransformationError extends Error { - constructor(message: string, public readonly routeIndex: RouteIndex, public readonly node?: ts.Node) { + constructor(message: string, routeIndex: RouteIndex, node?: ts.Node) { super(message); - this.message += this.getMessageAddendum(); + this.message += this.getMessageAddendum(routeIndex, node); } - public getMessageAddendum() { - const routeMessage = ` - ${this.routeIndex.method} ${this.routeIndex.path}`; + public getMessageAddendum(routeIndex: RouteIndex, node?: ts.Node) { + const routeMessage = ` - ${routeIndex.method} ${routeIndex.path}`; let message = routeMessage; - if (this.node) { - const file: ts.SourceFile = this.node.getSourceFile(); + if (node) { + const file: ts.SourceFile = node.getSourceFile(); const { line, character } = file.getLineAndCharacterOfPosition( - this.node.pos, + node.pos, ); message += `\n${file.fileName}:${line + 1}:${character + 1}`; } @@ -24,6 +24,6 @@ export class StrictTransformationError extends TransformationError { public readonly warningMessage: string; constructor(errorMessage: string, warningMessage: string, routeIndex: RouteIndex, node?: ts.Node) { super(errorMessage, routeIndex, node); - this.warningMessage = warningMessage + this.getMessageAddendum(); + this.warningMessage = warningMessage + this.getMessageAddendum(routeIndex, node); } } diff --git a/packages/rest/src/transform/json-schema-utils.ts b/packages/rest/src/transform/json-schema-utils.ts index bdbaabc..b190025 100644 --- a/packages/rest/src/transform/json-schema-utils.ts +++ b/packages/rest/src/transform/json-schema-utils.ts @@ -13,7 +13,7 @@ export function dereferenceSchema(schema: JSONSchema7) { refParser.schema = clonedSchema; dereference(refParser, { dereference: { - circular: true, + circular: "ignore", onDereference(path: string, value: JSONSchema7) { (value as { "x-resolved-ref": string })["x-resolved-ref"] = path; }, @@ -88,30 +88,37 @@ export function getUnifiedPropertySchemas( }> = {}; let parentSchemas = 0; - traverse(schema, { - allKeys: false, - cb: { - pre(schema, jsonPtr, rootSchema, parentJsonPtr) { - const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); - if (convertedPath === parentPath && parentJsonPtr != "") { - parentSchemas++; - } + try { + traverse(schema, { + allKeys: false, + cb: { + pre(schema, jsonPtr, rootSchema, parentJsonPtr) { + const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); + if (convertedPath === parentPath && parentJsonPtr != "") { + parentSchemas++; + } + }, + post: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { + const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); + + if (convertedPath != null && isDirectChildPath(convertedPath, parentPath)) { + const schemaSet = schemas[keyIndex || ""] || { + schemaSet: [], + required: true, + }; + schemaSet.required = !schemaSet.required ? false : parentSchema?.required?.includes(keyIndex) ?? false; + schemaSet.schemaSet.push(schema); + schemas[keyIndex || ""] = schemaSet; + } + }, }, - post: (schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) => { - const convertedPath = convertJsonSchemaPathIfPropertyPath(jsonPtr); - - if (convertedPath != null && isDirectChildPath(convertedPath, parentPath)) { - const schemaSet = schemas[keyIndex || ""] || { - schemaSet: [], - required: true, - }; - schemaSet.required = !schemaSet.required ? false : parentSchema?.required?.includes(keyIndex) ?? false; - schemaSet.schemaSet.push(schema); - schemas[keyIndex || ""] = schemaSet; - } - }, - }, - }); + }); + } catch (e) { + if (e instanceof RangeError) { + throw new Error("Infinite loop detected in json schema"); + } + throw e; + } return Object.fromEntries( Object.entries(schemas).map(([key, value]) => { @@ -165,6 +172,22 @@ export function moveRefsToAllOf(schema: JSONSchema7) { return clonedSchema; } +export function moveExamplesToExample(schema: JSONSchema7) { + const clonedSchema = cloneDeep(schema); + traverse(clonedSchema, { + cb: { + pre: (schema) => { + if (schema.example == null && schema.examples != null && schema.examples.length > 0) { + schema.example = schema.examples[0]; + delete schema.examples; + } + }, + }, + }); + + return clonedSchema; +} + export function rewriteRefsForOpenApi(schema: JSONSchema7) { const clonedSchema = cloneDeep(schema); traverse(clonedSchema, { @@ -239,18 +262,19 @@ export function resolveDiscriminantProperty(schema: JSONSchema7, propertyPath: s const discriminatorValues: string[] = []; for (const schema of discriminantProperty.schemaSet) { + const resolvedAllOfSchema = getSchemaOrAllOf(schema); if ( - !(schema.type === "string" || schema.type === "number") - || (schema.const == null && schema.enum == null) + !(resolvedAllOfSchema.type === "string" || resolvedAllOfSchema.type === "number") + || (resolvedAllOfSchema.const == null && resolvedAllOfSchema.enum == null) ) { return; } - if (schema.const != null) { - discriminatorValues.push(schema.const as string); + if (resolvedAllOfSchema.const != null) { + discriminatorValues.push(resolvedAllOfSchema.const as string); } - if (schema.enum != null) { - discriminatorValues.push(...(schema.enum as string[])); + if (resolvedAllOfSchema.enum != null) { + discriminatorValues.push(...(resolvedAllOfSchema.enum as string[])); } } diff --git a/packages/rest/src/transform/project.ts b/packages/rest/src/transform/project.ts index 3bd0f16..ce47cb9 100644 --- a/packages/rest/src/transform/project.ts +++ b/packages/rest/src/transform/project.ts @@ -1,5 +1,17 @@ import type { Options as AJVBaseOptions } from "ajv"; -import { Config, NodeParser, SchemaGenerator, TypeFormatter } from "ts-json-schema-generator"; +import { + BaseType, + Config, + createParser, + NodeParser, + ReferenceType, + SchemaGenerator, + StringType, + SubNodeParser, + TypeFormatter, + UndefinedType, + UnknownType, +} from "ts-json-schema-generator"; import ts from "typescript"; export interface Project { @@ -48,3 +60,54 @@ export const SCHEMA_DEFAULTS: Config = { }; export type Options = AJVOptions & SchemaConfig; + +export class TemplateExpressionNodeParser implements SubNodeParser { + supportsNode(node: ts.Node): boolean { + return ts.isTemplateExpression(node); + } + + createType(): BaseType { + return new StringType(); + } +} + +export class UndefinedIdentifierParser implements SubNodeParser { + supportsNode(node: ts.Node): boolean { + return ts.isIdentifier(node) && node.text === "undefined"; + } + + createType(): BaseType { + return new UndefinedType(); + } +} + +export class NornirIgnoreParser implements SubNodeParser { + supportsNode(node: ts.Node): boolean { + // check if the ignore tag is present + return ts.getJSDocTags(node).some(tag => tag.tagName.getText() === "ignore"); + } + + createType(): BaseType { + return new UnknownType(); + } +} + +export class NornirParserThrow implements SubNodeParser { + supportsNode(node: ts.Node) { + return ts.getJSDocTags(node).some(tag => tag.tagName.getText() === "nodeParseThrow"); + } + + createType(node: ts.Node): BaseType { + const throwTagText = ts.getJSDocTags(node).find(tag => tag.tagName.getText() === "nodeParseThrow")?.comment; + throw new Error(`ParserFailure: ${throwTagText}`); + } +} + +export function getSchemaNodeParser(program: ts.Program, config: Config): NodeParser { + return createParser(program as unknown as Parameters[0], config, prs => { + prs.addNodeParser(new TemplateExpressionNodeParser()); + prs.addNodeParser(new UndefinedIdentifierParser()); + prs.addNodeParser(new NornirIgnoreParser()); + prs.addNodeParser(new NornirParserThrow()); + }); +} diff --git a/packages/rest/src/transform/transform.ts b/packages/rest/src/transform/transform.ts index d5b289b..4f93203 100644 --- a/packages/rest/src/transform/transform.ts +++ b/packages/rest/src/transform/transform.ts @@ -10,31 +10,19 @@ import { UndefinedType, } from "ts-json-schema-generator"; import ts from "typescript"; -import { AJV_DEFAULTS, AJVOptions, Options, Project, SCHEMA_DEFAULTS, SchemaConfig } from "./project.js"; +import { + AJV_DEFAULTS, + AJVOptions, + getSchemaNodeParser, + Options, + Project, + SCHEMA_DEFAULTS, + SchemaConfig, +} from "./project.js"; import { FileTransformer } from "./transformers/file-transformer.js"; let project: Project; -export class TemplateExpressionNodeParser implements SubNodeParser { - supportsNode(node: ts.Node): boolean { - return ts.isTemplateExpression(node); - } - - createType(): BaseType { - return new StringType(); - } -} - -export class UndefinedIdentifierParser implements SubNodeParser { - supportsNode(node: ts.Node): boolean { - return ts.isIdentifier(node) && node.text === "undefined"; - } - - createType(): BaseType { - return new UndefinedType(); - } -} - export class UndefinedFormatter implements SubTypeFormatter { public supportsType(type: BaseType): boolean { return type instanceof UndefinedType; @@ -48,7 +36,6 @@ export class UndefinedFormatter implements SubTypeFormatter { return {}; } } - export function transform(program: ts.Program, options?: Options): ts.TransformerFactory { const { loopEnum, @@ -83,13 +70,7 @@ export function transform(program: ts.Program, options?: Options): ts.Transforme allErrors: allErrors ?? AJV_DEFAULTS.allErrors, }; - const nodeParser = createParser(program as unknown as Parameters[0], { - ...schemaConfig, - }, (prs) => { - // prs.addNodeParser(new NornirIgnoreParser()); - prs.addNodeParser(new TemplateExpressionNodeParser()); - prs.addNodeParser(new UndefinedIdentifierParser()); - }); + const nodeParser = getSchemaNodeParser(program, schemaConfig); const typeFormatter = createFormatter({ ...schemaConfig, }, frm => { diff --git a/packages/rest/src/transform/transformers/controller-method-transformer.ts b/packages/rest/src/transform/transformers/controller-method-transformer.ts index 16f993b..83a2965 100644 --- a/packages/rest/src/transform/transformers/controller-method-transformer.ts +++ b/packages/rest/src/transform/transformers/controller-method-transformer.ts @@ -32,6 +32,7 @@ export abstract class ControllerMethodTransformer { try { return METHOD_DECORATOR_PROCESSORS[method](methodDecorator, project, source, node, controller); } catch (e) { + throw e; console.error(e); if (e instanceof TransformationError) { throw e; diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index d30beb5..6880cbe 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -48,15 +48,16 @@ export abstract class ChainRouteProcessor { const routeIndex = controller.getRouteIndex({ method, path }); - // const wrappedNode = createWrappedNode(node, { typeChecker: project.checker }) as MethodDeclaration; - const { typeNode: inputTypeNode } = ChainRouteProcessor.resolveInputType(project, node, routeIndex); const outputType = ChainRouteProcessor.resolveOutputType(project, node, routeIndex); - const outputSchema = project.schemaGenerator.createSchemaFromNodes([outputType.node]); - - const inputSchema = project.schemaGenerator.createSchemaFromNodes([inputTypeNode]); + const { inputSchema, outputSchema } = ChainRouteProcessor.generateInputOutputSchema( + project, + routeIndex, + inputTypeNode, + outputType.node, + ); const inputValidator = schemaToValidator(moveRefsToAllOf(inputSchema), project.options.validation); @@ -105,6 +106,25 @@ export abstract class ChainRouteProcessor { return recreatedNode; } + private static generateInputOutputSchema( + project: Project, + routeIndex: RouteIndex, + inputTypeNode: ts.TypeNode, + outputTypeNode: ts.TypeNode, + ) { + try { + const inputSchema = project.schemaGenerator.createSchemaFromNodes([inputTypeNode]); + const outputSchema = project.schemaGenerator.createSchemaFromNodes([outputTypeNode]); + return { + inputSchema, + outputSchema, + }; + } catch (e) { + console.error(e); + throw new TransformationError(`Could not generate schema for route`, routeIndex); + } + } + private static parseJSDoc(_project: Project, method: ts.MethodDeclaration): RouteTags { const docs = ts.getJSDocCommentsAndTags(ts.getOriginalNode(method)); const topLevel = docs[0]; @@ -218,7 +238,7 @@ export abstract class ChainRouteProcessor { project: Project, methodDeclaration: ts.MethodDeclaration, routeIndex: RouteIndex, - ): { type: ts.Type; node: ts.Node } { + ): { type: ts.Type; node: ts.TypeNode } { const wrapped = tsp.createWrappedNode(methodDeclaration, { typeChecker: project.checker }); const returnedTypeNode = wrapped.getReturnTypeNode()?.compilerNode; if (returnedTypeNode == null) { diff --git a/packages/test/openapi.json b/packages/test/openapi.json index d501cb6..d8cb91b 100644 --- a/packages/test/openapi.json +++ b/packages/test/openapi.json @@ -1,15 +1,12 @@ { - "openapi": "3.1.0", + "openapi": "3.0.3", "info": { - "title": "Test API", - "version": "1.0.0", - "description": "A test api" + "title": "Nornir API", + "version": "1.0.0" }, "paths": { - "/basepath/2/route": { + "/docs": { "get": { - "summary": "Get route", - "description": "The second simple GET route.", "responses": { "200": { "description": "", @@ -21,12 +18,12 @@ "deprecated": false, "schema": { "type": "string", - "const": "text/plain" + "const": "text/html" } } }, "content": { - "text/plain": { + "text/html": { "schema": { "type": "string" } @@ -34,59 +31,13 @@ } } }, - "parameters": [ - { - "name": "content-type", - "in": "header", - "required": false, - "deprecated": false, - "schema": { - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/MimeType" - } - ] - }, - { - "not": {} - } - ] - } - } - ] - }, - "put": { - "summary": "Put route", - "description": "The second simple PUT route.", + "parameters": [] + } + }, + "/openapi.json": { + "get": { "responses": { - "201": { - "description": "", - "headers": { - "content-type": { - "name": "content-type", - "in": "header", - "required": false, - "deprecated": false, - "schema": { - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/MimeType" - } - ] - }, - { - "not": {} - } - ] - } - } - } - }, - "422": { + "200": { "description": "", "headers": { "content-type": { @@ -104,62 +55,13 @@ "application/json": { "schema": { "type": "object", - "properties": { - "potato": { - "type": "boolean" - } - }, - "required": [ - "potato" - ] + "additionalProperties": {} } } } } }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "cool": { - "type": "string" - } - }, - "required": [ - "cool" - ] - } - }, - "text/csv": { - "schema": { - "type": "string" - } - } - } - }, - "parameters": [ - { - "name": "content-type", - "in": "header", - "required": true, - "deprecated": false, - "schema": { - "anyOf": [ - { - "type": "string", - "const": "application/json" - }, - { - "type": "string", - "const": "text/csv" - } - ] - } - } - ] + "parameters": [] } }, "/root/basepath/route/{cool}": { @@ -195,7 +97,8 @@ "required": [ "bleep", "bloop" - ] + ], + "additionalProperties": false } } } @@ -229,7 +132,8 @@ "required": [ "bleep", "bloop" - ] + ], + "additionalProperties": false } } } @@ -249,27 +153,7 @@ } }, "content": { - "application/json": { - "example": { - "message": "Bad Request" - }, - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "examples": [ - { - "message": "Bad Request" - } - ] - } - } + "application/json": {} } } }, @@ -287,26 +171,6 @@ } ] } - }, - { - "name": "content-type", - "in": "header", - "required": false, - "deprecated": false, - "schema": { - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/MimeType" - } - ] - }, - { - "not": {} - } - ] - } } ] }, @@ -356,6 +220,7 @@ "required": [ "cool" ], + "additionalProperties": false, "description": "A cool json input", "examples": [ { @@ -380,6 +245,7 @@ "required": [ "cool" ], + "additionalProperties": false, "description": "A cool json input", "examples": [ { @@ -489,63 +355,16 @@ }, "components": { "schemas": { - "HttpHeadersWithContentType": { - "type": "object", - "properties": { - "content-type": { - "allOf": [ - { - "$ref": "#/components/schemas/MimeType" - } - ] - } - }, - "required": [ - "content-type" - ] - }, - "MimeType": { - "type": "string", - "enum": [ - "*/*", - "application/json", - "application/octet-stream", - "application/pdf", - "application/x-www-form-urlencoded", - "application/zip", - "application/gzip", - "application/bzip", - "application/bzip2", - "application/ld+json", - "font/woff", - "font/woff2", - "font/ttf", - "font/otf", - "audio/mpeg", - "audio/x-wav", - "image/gif", - "image/jpeg", - "image/png", - "multipart/form-data", - "text/css", - "text/csv", - "text/html", - "text/plain", - "text/xml", - "video/mpeg", - "video/mp4", - "video/quicktime", - "video/x-msvideo", - "video/x-flv", - "video/webm" - ] - }, "HttpHeadersWithoutContentType": { "type": "object", + "additionalProperties": { + "type": [ + "number", + "string" + ] + }, "properties": { - "content-type": { - "not": {} - } + "content-type": {} } }, "TestStringType": { @@ -585,7 +404,8 @@ }, "required": [ "content-type" - ] + ], + "additionalProperties": false }, "body": { "type": "object", @@ -600,7 +420,8 @@ "required": [ "bleep", "bloop" - ] + ], + "additionalProperties": false } }, "required": [ @@ -608,6 +429,7 @@ "headers", "statusCode" ], + "additionalProperties": false, "description": "This is a comment" }, "RouteGetOutputError": { @@ -627,30 +449,16 @@ }, "required": [ "content-type" - ] - }, - "body": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" ], - "examples": [ - { - "message": "Bad Request" - } - ] - } + "additionalProperties": false + }, + "body": {} }, "required": [ - "body", "headers", "statusCode" ], + "additionalProperties": false, "description": "This is a comment on RouteGetOutputError" }, "RoutePostInputJSONAlias": { @@ -668,7 +476,8 @@ }, "required": [ "content-type" - ] + ], + "additionalProperties": false }, { "type": "object", @@ -680,7 +489,8 @@ }, "required": [ "content-type" - ] + ], + "additionalProperties": false } ] }, @@ -694,7 +504,8 @@ }, "required": [ "test" - ] + ], + "additionalProperties": false }, "body": { "type": "object", @@ -708,6 +519,7 @@ "required": [ "cool" ], + "additionalProperties": false, "description": "A cool json input", "examples": [ { @@ -737,7 +549,8 @@ }, "required": [ "reallyCool" - ] + ], + "additionalProperties": false } }, "required": [ @@ -745,7 +558,8 @@ "headers", "pathParams", "query" - ] + ], + "additionalProperties": false } }, "parameters": {} diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index a7315f1..51f3c11 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -8,10 +8,12 @@ import { HttpStatusCode, MimeType, PostChain, + ValidateRequestType, + ValidateResponseType, } from "@nornir/rest"; import { assertValid } from "@nrfcloud/ts-json-schema-transformer"; -interface RouteGetInput extends HttpRequestEmpty { +interface RouteGetInput extends HttpRequest { pathParams: { /** * @pattern ^[a-z]+$ @@ -193,9 +195,9 @@ export class TestController { return chain .use(_contentType => ({ statusCode: HttpStatusCode.Ok, - body: undefined, // body: `Content-Type: ${contentType}`, headers: {}, + body: "", })); } } diff --git a/packages/test/src/rest.ts b/packages/test/src/rest.ts index a976c39..0a8f63f 100644 --- a/packages/test/src/rest.ts +++ b/packages/test/src/rest.ts @@ -19,6 +19,7 @@ import type { } from "aws-lambda"; import "./controller.js"; import "./controller2.js"; +import "./docs-controller.js"; import { getMockObject } from "@nrfcloud/ts-json-schema-transformer"; export class TestError implements NodeJS.ErrnoException { @@ -42,6 +43,8 @@ const frameworkChain = nornir() ])) .use(httpResponseSerializer({ ["application/bzip"]: () => Buffer.from(""), + ["text/csv"]: (input) => Buffer.from(input as string), + ["text/html"]: (input) => Buffer.from(input as string), })); export const handler: APIGatewayProxyHandlerV2 = nornir() From f4d6eebe0109cf28b56aff99b66e3440b9282ae9 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Tue, 19 Dec 2023 13:20:54 -0800 Subject: [PATCH 14/16] fix body gen --- packages/rest/src/runtime/decorators.mts | 1 + packages/rest/src/transform/controller-meta.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/runtime/decorators.mts b/packages/rest/src/runtime/decorators.mts index 153faaf..480d84c 100644 --- a/packages/rest/src/runtime/decorators.mts +++ b/packages/rest/src/runtime/decorators.mts @@ -36,6 +36,7 @@ type RequestResponseWithBodyHasContentType IfEquals : false; +// eslint-disable-next-line @typescript-eslint/no-explicit-any type HasBody = T extends { body: any } ? true : false type Test = { statusCode: HttpStatusCode.Ok, headers: NonNullable, body: string} diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index d5a907a..cb1fc87 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -362,7 +362,7 @@ export class ControllerMeta { private generateRequestBody(routeIndex: RouteIndex, inputSchema: JSONSchema7): OpenAPIV3_1.RequestBodyObject | void { const bodySchema = getUnifiedPropertySchemas(inputSchema, "/")["body"]; const contentTypeDiscriminatedSchemas = resolveDiscriminantProperty(inputSchema, "/headers/content-type"); - if (bodySchema == null || (bodySchema.schemaSet.length === 1)) { + if (bodySchema == null || (bodySchema.schemaSet.length === 0)) { return; } From 30acf8ada72fdd07855a6a881fc1617d9b56a455 Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Tue, 19 Dec 2023 16:32:05 -0800 Subject: [PATCH 15/16] fix for breaking other transformers --- .../rest/src/transform/transformers/node-transformer.ts | 2 +- .../transformers/processors/chain-route-processor.ts | 8 ++++---- .../transformers/processors/controller-processor.ts | 2 +- packages/test/src/controller.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/rest/src/transform/transformers/node-transformer.ts b/packages/rest/src/transform/transformers/node-transformer.ts index ef42beb..5ed9930 100644 --- a/packages/rest/src/transform/transformers/node-transformer.ts +++ b/packages/rest/src/transform/transformers/node-transformer.ts @@ -10,7 +10,7 @@ export abstract class NodeTransformer { context: ts.TransformationContext, ): ts.Node { if (ts.isClassDeclaration(node)) { - return ClassTransformer.transform(project, source, ts.getOriginalNode(node) as ts.ClassDeclaration, context); + return ClassTransformer.transform(project, source, node as ts.ClassDeclaration, context); } return node; diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index 6880cbe..662e8d2 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -101,7 +101,7 @@ export abstract class ChainRouteProcessor { ); ts.setTextRange(recreatedNode, node); - ts.setOriginalNode(recreatedNode, node); + // ts.setOriginalNode(recreatedNode, node); return recreatedNode; } @@ -126,7 +126,7 @@ export abstract class ChainRouteProcessor { } private static parseJSDoc(_project: Project, method: ts.MethodDeclaration): RouteTags { - const docs = ts.getJSDocCommentsAndTags(ts.getOriginalNode(method)); + const docs = ts.getJSDocCommentsAndTags(method); const topLevel = docs[0]; if (!topLevel) { return {}; @@ -239,8 +239,8 @@ export abstract class ChainRouteProcessor { methodDeclaration: ts.MethodDeclaration, routeIndex: RouteIndex, ): { type: ts.Type; node: ts.TypeNode } { - const wrapped = tsp.createWrappedNode(methodDeclaration, { typeChecker: project.checker }); - const returnedTypeNode = wrapped.getReturnTypeNode()?.compilerNode; + const returnedTypeNode = methodDeclaration.type; + if (returnedTypeNode == null) { throw new TransformationError( "Endpoint is missing an explicit return type. Explicit return types are required for all endpoints to promote contract stability", diff --git a/packages/rest/src/transform/transformers/processors/controller-processor.ts b/packages/rest/src/transform/transformers/processors/controller-processor.ts index 7fc13ad..cfcc2a0 100644 --- a/packages/rest/src/transform/transformers/processors/controller-processor.ts +++ b/packages/rest/src/transform/transformers/processors/controller-processor.ts @@ -39,7 +39,7 @@ export abstract class ControllerProcessor { ); ts.setTextRange(recreatedNode, node); - ts.setOriginalNode(recreatedNode, node); + // ts.setOriginalNode(recreatedNode, node); return recreatedNode; } diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index 51f3c11..e9126fd 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -161,7 +161,7 @@ export class TestController { /** * Cool get route */ - @GetChain("/route/{cool}") + @GetChain("/route/:cool") public getRoute(chain: Nornir): Nornir { return chain .use(input => { @@ -188,7 +188,7 @@ export class TestController { * @deprecated * @operationId coolRoute */ - @PostChain("/route/{cool}") + @PostChain("/route/:cool") public postRoute( chain: Nornir, ): Nornir }> { From 6f5cd97dbd402d0ba6f16d8d0e31183e1c50868b Mon Sep 17 00:00:00 2001 From: John Conley <8932043+jfrconley@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:41:58 -0800 Subject: [PATCH 16/16] minor fixes --- .../rest/src/transform/controller-meta.ts | 64 ++++++++++--------- .../processors/chain-route-processor.ts | 6 +- packages/test/src/controller.ts | 2 + 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/rest/src/transform/controller-meta.ts b/packages/rest/src/transform/controller-meta.ts index cb1fc87..ae7a192 100644 --- a/packages/rest/src/transform/controller-meta.ts +++ b/packages/rest/src/transform/controller-meta.ts @@ -64,6 +64,7 @@ export class ControllerMeta { public static clearCache() { this.cache.clear(); + this.routes.clear(); } public static create( @@ -190,18 +191,18 @@ export class ControllerMeta { return ts.factory.createExpressionStatement(callExpression); } - public getRouteIndex(info: RouteIndex) { + public getRouteIndex(method: string, path: string) { return { - method: info.method, - path: this.basePath + info.path.toLowerCase(), + method, + path: normalizeHttpPath(this.basePath + path.toLowerCase()), }; } - public registerRoute(node: ts.Node, routeInfo: RouteInfo) { + public registerRoute(method: string, path: string, info: Omit) { if (this.project.transformOnly) { return; } - const index = this.getRouteIndex(routeInfo); + const index = this.getRouteIndex(method, path); const methods = ControllerMeta.routes.get(index.path) || new Map(); ControllerMeta.routes.set(index.path, methods); const route = methods.get(index.method); @@ -210,19 +211,16 @@ export class ControllerMeta { } const modifiedRouteInfo = { - method: routeInfo.method, - path: this.basePath + routeInfo.path.toLowerCase(), - description: routeInfo.description, - // requestInfo: this.buildRequestInfo(index, routeInfo.input), - // responseInfo: this.buildResponseInfo(index, routeInfo.output), - outputSchema: routeInfo.outputSchema, - inputSchema: routeInfo.inputSchema, - filePath: routeInfo.filePath, - summary: routeInfo.summary, - deprecated: routeInfo.deprecated, - operationId: routeInfo.operationId, - tags: routeInfo.tags, - input: routeInfo.input, + index, + description: info.description, + outputSchema: info.outputSchema, + inputSchema: info.inputSchema, + filePath: info.filePath, + summary: info.summary, + deprecated: info.deprecated, + operationId: info.operationId, + tags: info.tags, + input: info.input, }; try { @@ -232,7 +230,7 @@ export class ControllerMeta { throw e; } console.error(e); - throw new TransformationError("Could not generate OpenAPI spec for route", modifiedRouteInfo); + throw new TransformationError("Could not generate OpenAPI spec for route", index); } methods.set(index.method, modifiedRouteInfo); } @@ -249,15 +247,15 @@ export class ControllerMeta { version: "1.0.0", }, paths: { - [route.path]: { - [route.method.toLowerCase()]: { + [route.index.path]: { + [route.index.method.toLowerCase()]: { deprecated: route.deprecated, tags: route.tags, operationId: route.operationId, summary: route.summary, description: route.description, - responses: this.generateOutputType(route, dereferencedOutputSchema), - requestBody: this.generateRequestBody(route, dereferencedInputSchema), + responses: this.generateOutputType(route.index, dereferencedOutputSchema), + requestBody: this.generateRequestBody(route.index, dereferencedInputSchema), parameters: [ ...this.generateParametersForSchemaPath(dereferencedInputSchema, "/pathParams", "path"), ...this.generateParametersForSchemaPath(dereferencedInputSchema, "/query", "query"), @@ -314,12 +312,12 @@ export class ControllerMeta { }); } - private generateOutputType(routeInfo: RouteInfo, outputSchema: JSONSchema7): OpenAPIV3_1.ResponsesObject { + private generateOutputType(routeIndex: RouteIndex, outputSchema: JSONSchema7): OpenAPIV3_1.ResponsesObject { const responses: OpenAPIV3_1.ResponsesObject = {}; const statusCodeDiscriminatedSchemas = resolveDiscriminantProperty(outputSchema, "/statusCode"); if (statusCodeDiscriminatedSchemas == null) { - throw new TransformationError("Could not resolve status codes for some responses", routeInfo); + throw new TransformationError("Could not resolve status codes for some responses", routeIndex); } for (const [statusCode, schema] of Object.entries(statusCodeDiscriminatedSchemas)) { @@ -327,7 +325,7 @@ export class ControllerMeta { const contentTypeDiscriminatedSchemas = resolveDiscriminantProperty(schema, "/headers/content-type"); const bodySchema = getUnifiedPropertySchemas(schema, "/")["body"]; if (contentTypeDiscriminatedSchemas == null && bodySchema != null) { - throw new TransformationError(`Could not resolve content types for "${statusCode}" responses`, routeInfo); + throw new TransformationError(`Could not resolve content types for "${statusCode}" responses`, routeIndex); } const content = contentTypeDiscriminatedSchemas == null ? undefined : Object.fromEntries( @@ -400,13 +398,14 @@ export class ControllerMeta { } } -function deparameterizePath(path: string) { - return path.replaceAll(/:[^/]+/g, ":param"); +function normalizeHttpPath(path: string) { + const doubleSlashRemoved = path.replace(/\/\//g, "/"); + const endingSlashRemoved = doubleSlashRemoved.endsWith("/") ? doubleSlashRemoved.slice(0, -1) : doubleSlashRemoved; + return endingSlashRemoved.trim(); } export interface RouteInfo { - method: string; - path: string; + index: RouteIndex; description?: string; summary?: string; input: ts.TypeNode; @@ -418,4 +417,7 @@ export interface RouteInfo { operationId?: string; } -export type RouteIndex = Pick; +export interface RouteIndex { + method: string; + path: string; +} diff --git a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts index 662e8d2..1392a6b 100644 --- a/packages/rest/src/transform/transformers/processors/chain-route-processor.ts +++ b/packages/rest/src/transform/transformers/processors/chain-route-processor.ts @@ -46,7 +46,7 @@ export abstract class ChainRouteProcessor { const path = ChainRouteProcessor.getPath(project, methodDecorator); const method = ChainRouteProcessor.getMethod(project, methodDecorator); - const routeIndex = controller.getRouteIndex({ method, path }); + const routeIndex = controller.getRouteIndex(method, path); const { typeNode: inputTypeNode } = ChainRouteProcessor.resolveInputType(project, node, routeIndex); @@ -63,9 +63,7 @@ export abstract class ChainRouteProcessor { const parsedDocComments = ChainRouteProcessor.parseJSDoc(project, node); - controller.registerRoute(node, { - method, - path, + controller.registerRoute(method, path, { input: inputTypeNode, inputSchema, outputSchema: outputSchema, diff --git a/packages/test/src/controller.ts b/packages/test/src/controller.ts index e9126fd..852547a 100644 --- a/packages/test/src/controller.ts +++ b/packages/test/src/controller.ts @@ -125,6 +125,8 @@ interface RoutePostBodyInput { * @minLength 5 */ cool: string; + + omitted: boolean; } /**