diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6b733a0a9a..6685332002 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -54,6 +54,7 @@ import { initialize } from 'express-openapi'; import yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; +import OpenAPIBackend from 'openapi-backend'; class ParsingError extends Error { toString() { @@ -104,6 +105,23 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } + // create api with your definition file or object + const api = new OpenAPIBackend({ definition: './petstore.yml' }); + + // register your framework specific request handlers here + api.register({ + getPets: (c, req, res) => { + return res.status(200).json({ result: 'ok' }); + }, + getPetById: (c, req, res) => res.status(200).json({ result: 'ok' }), + validationFail: (c, req, res) => + res.status(400).json({ err: c.validation.errors }), + notFound: (c, req, res) => res.status(404).json({ err: 'not found' }), + }); + + // initalize the backend + api.init(); + const validateDependency = ( dependency: any, next: (req: express.Request, res: express.Response) => any, diff --git a/plugins/openapi-router-common/.eslintrc.js b/plugins/openapi-router-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/openapi-router-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/openapi-router-common/README.md b/plugins/openapi-router-common/README.md new file mode 100644 index 0000000000..cda5d7fc7f --- /dev/null +++ b/plugins/openapi-router-common/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-openapi-router-common + +Welcome to the common package for the openapi-router plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/openapi-router-common/package.json b/plugins/openapi-router-common/package.json new file mode 100644 index 0000000000..a852edfcec --- /dev/null +++ b/plugins/openapi-router-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@backstage/plugin-openapi-router", + "description": "OpenAPI router with type completions.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ], + "dependencies": { + "express": "^4.18.2", + "json-schema-to-ts": "^2.6.2", + "openapi-types": "^12.1.0" + } +} diff --git a/plugins/openapi-router-common/src/index.ts b/plugins/openapi-router-common/src/index.ts new file mode 100644 index 0000000000..7edca76f93 --- /dev/null +++ b/plugins/openapi-router-common/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common functionalities for the openapi-router plugin. + * + * @packageDocumentation + */ + +export * from './router'; diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts new file mode 100644 index 0000000000..c7940cdbd0 --- /dev/null +++ b/plugins/openapi-router-common/src/router.ts @@ -0,0 +1,357 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { OpenAPIV3_1 } from 'openapi-types'; +import { FieldValues } from './types/fields'; +import { + AppendNonBlankKey, + FieldPath, + FieldPathValue, + Path, +} from './types/path'; +import { IRouter, Router, IRouterMatcher } from 'express'; +import core, { + ParamsDictionary, + RequestHandler, +} from 'express-serve-static-core'; +import { FromSchema } from 'json-schema-to-ts'; + +type RouterFn = < + TFieldName extends FieldPath = FieldPath, +>( + name: TFieldName, +) => null; + +const doc = { + openapi: '3.1.0', + info: { + version: '1.0.0', + title: 'Swagger Petstore', + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + servers: [ + { + url: 'http://petstore.swagger.io/v1', + }, + ], + paths: { + '/pets': { + get: { + summary: 'List all pets', + operationId: 'listPets', + tags: ['pets'], + parameters: [ + { + name: 'limit', + in: 'query', + description: 'How many items to return at one time (max 100)', + required: false, + schema: { + type: 'integer', + format: 'int32', + }, + }, + ], + responses: { + '200': { + description: 'A paged array of pets', + headers: { + 'x-next': { + description: 'A link to the next page of responses', + schema: { + type: 'string', + }, + }, + }, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pets', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + post: { + summary: 'Create a pet', + operationId: 'createPets', + tags: ['pets'], + responses: { + '201': { + description: 'Null response', + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + '/pets/{petId}': { + get: { + summary: 'Info for a specific pet', + operationId: 'showPetById', + tags: ['pets'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'The id of the pet to retrieve', + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Expected response to a valid request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + format: 'int64', + }, + name: { + type: 'string', + }, + tag: { + type: 'string', + }, + }, + }, + Pets: { + type: 'array', + items: { + $ref: '#/components/schemas/Pet', + }, + }, + Error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'integer', + format: 'int32', + }, + message: { + type: 'string', + }, + }, + }, + }, + }, +} as const; + +const isUndefined = (a: any): a is undefined => a === undefined; +const isNull = (a: any): a is null => a === null; + +const isNullOrUndefined = (a: any): a is undefined | null => + isUndefined(a) || isNull(a); + +const isDateObject = (value: unknown): value is Date => value instanceof Date; + +const isObjectType = (value: unknown) => typeof value === 'object'; + +const isObject = (value: unknown): value is T => + !isNullOrUndefined(value) && + !Array.isArray(value) && + isObjectType(value) && + !isDateObject(value); + +const compact = (value: TValue[]) => + Array.isArray(value) ? value.filter(Boolean) : []; + +export const resolve = ( + obj: T, + path: string, + defaultValue?: unknown, +): any => { + if (!path || !isObject(obj)) { + return defaultValue; + } + + const result = compact(path.split(/[,[\].]+?/)).reduce( + (result, key) => + isNullOrUndefined(result) ? result : result[key as keyof {}], + obj, + ); + if (result === undefined || result === obj) { + return obj[path as keyof T] === undefined + ? defaultValue + : obj[path as keyof T]; + } + return result; +}; + +/** + * We want this to input path and have + * @param name + * @returns + */ +const x: RouterFn = name => { + console.log(resolve(doc, name)); + return null; +}; + +x('/pets/{petId}'); + +type RemoveTail< + S extends string, + Tail extends string, +> = S extends `${infer P}${Tail}` ? P : S; +type GetRouteParameter = RemoveTail< + RemoveTail, `-${string}`>, + `.${string}` +>; +export type RouteParameters = string extends Route + ? ParamsDictionary + : Route extends `${string}(${string}` + ? ParamsDictionary + : Route extends `${string}:${infer Rest}` + ? (GetRouteParameter extends never + ? ParamsDictionary + : GetRouteParameter extends `${infer ParamName}?` + ? { [P in ParamName]?: string } + : { [P in GetRouteParameter]: string }) & + (Rest extends `${GetRouteParameter}${infer Next}` + ? RouteParameters + : unknown) + : {}; +interface ParsedQs { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +} +interface ApiRouterMatcher< + TFieldValues extends FieldValues, + T, + Method extends + | 'all' + | 'get' + | 'post' + | 'put' + | 'delete' + | 'patch' + | 'options' + | 'head', +> { + < + TFieldName extends Path = Path, + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + Locals extends Record = Record, + >( + // (it's used as the default type parameter for P) + path: TFieldName, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; +} + +type path = ApiRouterMatcher; +const test: path = a => { + console.log(a); +}; +test('/pets/{petId}'); + +export interface IApiRouter extends IRouter { + all: ApiRouterMatcher; + get: ApiRouterMatcher; +} + +export default class ApiRouter< + ApiSpec, + PathSpec extends OpenAPIV3_1.Document, + T, +> implements IApiRouter +{ + private _router = Router(); + + constructor(private spec: OpenAPIV3_1.Document) {} + + static fromSpec(spec: OpenAPIV3_1.Document) { + return new ApiRouter(spec); + } + + get< + TFieldValues extends FieldValues = PathSpec, + TFieldName extends FieldPath = FieldPath, + >( + path: TFieldName, + ...handlers: core.RequestHandler< + core.ParamsDictionary, + any, + FieldPathValue, 'get'>, + ParsedQs, + Record + >[] + ) { + console.log(path); + return this._router.get(path, ...handlers); + } +} + +const router = ApiRouter.fromSpec(doc); + +router.get('/pets', (req, res) => { + req.body.tags; +}); diff --git a/plugins/openapi-router-common/src/setupTests.ts b/plugins/openapi-router-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/openapi-router-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/openapi-router-common/src/types/fields.ts b/plugins/openapi-router-common/src/types/fields.ts new file mode 100644 index 0000000000..e97eeace9d --- /dev/null +++ b/plugins/openapi-router-common/src/types/fields.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { IsFlatObject, Noop } from './utils'; + +export type InternalFieldName = string; + +export type FieldName = + IsFlatObject extends true + ? Extract + : string; + +export type CustomElement = { + name: FieldName; + type?: string; + value?: any; + disabled?: boolean; + checked?: boolean; + options?: HTMLOptionsCollection; + files?: FileList | null; + focus?: Noop; +}; + +export type FieldValue = + TFieldValues[InternalFieldName]; + +export type FieldValues = Record; + +export type NativeFieldValue = + | string + | number + | boolean + | null + | undefined + | unknown[]; + +export type FieldElement = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | CustomElement; + +export type Ref = FieldElement; + +export type Field = { + _f: { + ref: Ref; + name: InternalFieldName; + refs?: HTMLInputElement[]; + mount?: boolean; + }; +}; + +export type FieldRefs = Partial>; diff --git a/plugins/openapi-router-common/src/types/path.ts b/plugins/openapi-router-common/src/types/path.ts new file mode 100644 index 0000000000..c1ffbd6378 --- /dev/null +++ b/plugins/openapi-router-common/src/types/path.ts @@ -0,0 +1,666 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldValues } from './fields'; +import { + BrowserNativeObject, + IsAny, + IsNever, + IsEqual, + Primitive, +} from './utils'; + +/** + * Type alias to `string` which describes a lodash-like path through an object. + * E.g. `'foo.bar.0.baz'` + */ +export type PathString = string; + +/** + * Type which can be traversed through with a {@link PathString}. + * I.e. objects, arrays, and tuples + */ +export type Traversable = object; + +/** + * Type to query whether an array type T is a tuple type. + * @typeParam T - type which may be an array or tuple + * @example + * ``` + * IsTuple<[number]> = true + * IsTuple = false + * ``` + */ +export type IsTuple> = number extends T['length'] + ? false + : true; + +/** + * Type which can be used to index an array or tuple type. + */ +export type ArrayKey = number; + +/** + * Type which can be used to index an object. + */ +export type Key = string; + +/** + * Type to assert that a type is a {@link Key}. + * @typeParam T - type which may be a {@link Key} + */ +export type AsKey = Extract; + +/** + * Type to convert a type to a {@link Key}. + * @typeParam T - type which may be converted to a {@link Key} + */ +export type ToKey = T extends ArrayKey ? `${T}` : AsKey; + +/** + * Type which describes a path through an object + * as a list of individual {@link Key}s. + */ +export type PathTuple = Key[]; + +/** + * Type to assert that a type is a {@link PathTuple}. + * @typeParam T - type which may be a {@link PathTuple} + */ +export type AsPathTuple = Extract; + +/** + * Type to intersect a union type. + * See https://fettblog.eu/typescript-union-to-intersection/ + * @typeParam U - union + * @example + * ``` + * UnionToIntersection<{ foo: string } | { bar: number }> + * = { foo: string; bar: number } + * ``` + */ +export type UnionToIntersection = ( + U extends any ? (_: U) => any : never +) extends (_: infer I) => any + ? I + : never; + +/** + * Type which appends a {@link Key} to the {@link PathTuple} only if it is not + * blank, i.e. not the empty string. + * @typeParam PT - path + * @typeParam K - key + * @example + * ``` + * AppendNonBlankKey<['foo'], 'bar'> = ['foo', 'bar'] + * AppendNonBlankKey<['foo'], ''> = ['foo'] + * ``` + */ +export type AppendNonBlankKey< + PT extends PathTuple, + K extends Key, +> = K extends '' ? PT : [...PT, K]; + +/** + * Type to implement {@link SplitPathString} tail recursively. + * @typeParam PS - remaining {@link PathString} which should be split into its + * individual {@link Key}s + * @typeParam PT - accumulator of the {@link Key}s which have been split from + * the original {@link PathString} already + */ +type SplitPathStringImpl< + PS extends PathString, + PT extends PathTuple, +> = PS extends `${infer K}.${infer R}` + ? SplitPathStringImpl> + : AppendNonBlankKey; + +/** + * Type to split a {@link PathString} into a {@link PathTuple}. + * The individual {@link Key}s may be empty strings. + * @typeParam PS - {@link PathString} which should be split into its + * individual {@link Key}s + * @example + * ``` + * SplitPathString<'foo'> = ['foo'] + * SplitPathString<'foo.bar.0.baz'> = ['foo', 'bar', '0', 'baz'] + * SplitPathString<'.'> = [] + * ``` + */ +export type SplitPathString = SplitPathStringImpl< + PS, + [] +>; + +/** + * Type to implement {@link JoinPathTuple} tail-recursively. + * @typeParam PT - remaining {@link Key}s which needs to be joined + * @typeParam PS - accumulator of the already joined {@link Key}s + */ +type JoinPathTupleImpl< + PT extends PathTuple, + PS extends PathString, +> = PT extends [infer K, ...infer R] + ? JoinPathTupleImpl, `${PS}.${AsKey}`> + : PS; + +/** + * Type to join a {@link PathTuple} to a {@link PathString}. + * @typeParam PT - {@link PathTuple} which should be joined. + * @example + * ``` + * JoinPathTuple<['foo']> = 'foo' + * JoinPathTuple<['foo', 'bar', '0', 'baz']> = 'foo.bar.0.baz' + * JoinPathTuple<[]> = never + * ``` + */ +export type JoinPathTuple = PT extends [ + infer K, + ...infer R, +] + ? JoinPathTupleImpl, AsKey> + : never; + +/** + * Type which converts all keys of an object to {@link Key}s. + * @typeParam T - object type + * @example + * ``` + * MapKeys<{0: string}> = {'0': string} + * ``` + */ +type MapKeys = { [K in keyof T as ToKey]: T[K] }; + +/** + * Type to access a type by a key. + * - Returns undefined if it can't be indexed by that key. + * - Returns null if the type is null. + * - Returns undefined if the type is not traversable. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccess<{foo: string}, 'foo'> = string + * TryAccess<{foo: string}, 'bar'> = undefined + * TryAccess = null + * TryAccess = undefined + * ``` + */ +type TryAccess = K extends keyof T + ? T[K] + : T extends null + ? null + : undefined; + +/** + * Type to access an array type by a key. + * Returns undefined if the key is non-numeric. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccessArray = string + * TryAccessArray = undefined + * ``` + */ +type TryAccessArray< + T extends ReadonlyArray, + K extends Key, +> = K extends `${ArrayKey}` ? T[number] : TryAccess; + +/** + * Type to evaluate the type which the given key points to. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * @example + * ``` + * EvaluateKey<{foo: string}, 'foo'> = string + * EvaluateKey<[number, string], '1'> = string + * EvaluateKey = string + * ``` + */ +export type EvaluateKey = T extends ReadonlyArray + ? IsTuple extends true + ? TryAccess + : TryAccessArray + : TryAccess, K>; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam PT - path into the deeply nested type + * @example + * ``` + * EvaluatePath<{foo: {bar: string}}, ['foo', 'bar']> = string + * EvaluatePath<[number, string], ['1']> = string + * EvaluatePath = number + * EvaluatePath = undefined + * ``` + */ +export type EvaluatePath = PT extends [ + infer K, + ...infer R, +] + ? EvaluatePath>, AsPathTuple> + : T; + +/** + * Type which given a tuple type returns its own keys, i.e. only its indices. + * @typeParam T - tuple type + * @example + * ``` + * TupleKeys<[number, string]> = '0' | '1' + * ``` + */ +export type TupleKeys> = Exclude< + keyof T, + keyof any[] +>; + +/** + * Type which extracts all numeric keys from an object. + * @typeParam T - type + * @example + * ``` + * NumericObjectKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * ``` + */ +type NumericObjectKeys = ToKey< + Extract +>; + +/** + * Type which extracts all numeric keys from an object, tuple, or array. + * If a union is passed, it evaluates to the overlapping numeric keys. + * @typeParam T - type + * @example + * ``` + * NumericKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * NumericKeys = `${number}` + * NumericKeys<[string, number]> = '0' | '1' + * NumericKeys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type NumericKeys = UnionToIntersection< + T extends ReadonlyArray + ? IsTuple extends true + ? [TupleKeys] + : [ToKey] + : [NumericObjectKeys] +>[never]; + +/** + * Type which extracts all keys from an object. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - object type + * @example + * ``` + * ObjectKeys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * ObjectKeys<{foo: string, bar: number}, string> = 'foo' + * ``` + */ +export type ObjectKeys = Exclude< + ToKey, + `${string}.${string}` | '' +>; + +/** + * Type to check whether a type's property matches the constraint type + * and return its key. Converts the key to a {@link Key}. + * @typeParam T - type whose property should be checked + * @typeParam K - key of the property + * @typeParam U - constraint type + * @example + * ``` + * CheckKeyConstraint<{foo: string}, 'foo', string> = 'foo' + * CheckKeyConstraint<{foo: string}, 'foo', number> = never + * CheckKeyConstraint = `${number}` + * ``` + */ +export type CheckKeyConstraint = K extends any + ? EvaluateKey extends U + ? K + : never + : never; + +/** + * Type which evaluates to true when the type is an array or tuple or is a union + * which contains an array or tuple. + * @typeParam T - type + * @example + * ``` + * ContainsIndexable<{foo: string}> = false + * ContainsIndexable<{foo: string} | number[]> = true + * ``` + */ +export type ContainsIndexable = IsNever< + Extract> +> extends true + ? false + : true; + +/** + * Type to implement {@link Keys} for non-nullable values. + * @typeParam T - non-nullable type whose property should be checked + */ +type KeysImpl = [T] extends [Traversable] + ? ContainsIndexable extends true + ? NumericKeys + : ObjectKeys + : never; + +/** + * Type to find all properties of a type that match the constraint type + * and return their keys. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - type whose property should be checked + * @typeParam U - constraint type + * @example + * ``` + * Keys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * Keys<{foo?: string, bar?: string}> = 'foo' | 'bar' + * Keys<{foo: string, bar: number}, string> = 'foo' + * Keys<[string, number], string> = '0' + * Keys = `${number}` + * Keys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type Keys = IsAny extends true + ? Key + : IsNever extends true + ? Key + : IsNever> extends true + ? never + : CheckKeyConstraint>, U>; + +/** + * Type to check whether a {@link Key} is present in a type. + * If a union of {@link Key}s is passed, all {@link Key}s have to be present + * in the type. + * @typeParam T - type which is introspected + * @typeParam K - key + * @example + * ``` + * HasKey<{foo: string}, 'foo'> = true + * HasKey<{foo: string}, 'bar'> = false + * HasKey<{foo: string}, 'foo' | 'bar'> = false + * ``` + */ +export type HasKey = IsNever>>; + +/** + * Type to implement {@link ValidPathPrefix} tail recursively. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @typeParam VPT - accumulates the prefix of {@link Key}s which have been + * confirmed to exist already + */ +type ValidPathPrefixImpl< + T, + PT extends PathTuple, + VPT extends PathTuple, +> = PT extends [infer K, ...infer R] + ? HasKey> extends true + ? ValidPathPrefixImpl< + EvaluateKey>, + AsPathTuple, + AsPathTuple<[...VPT, K]> + > + : VPT + : VPT; + +/** + * Type to find the longest path prefix which is still valid, + * i.e. exists within the given type. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'bar']> = ['foo', 'bar'] + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'ba']> = ['foo'] + * ``` + */ +export type ValidPathPrefix = ValidPathPrefixImpl< + T, + PT, + [] +>; + +/** + * Type to check whether a path through a type exists. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * HasPath<{foo: {bar: string}}, ['foo', 'bar']> = true + * HasPath<{foo: {bar: string}}, ['foo', 'ba']> = false + * ``` + */ +export type HasPath = ValidPathPrefix extends PT + ? true + : false; + +/** + * Helper function to break apart T1 and check if any are equal to T2 + * + * See {@link IsEqual} + */ +type AnyIsEqual = T1 extends T2 + ? IsEqual extends true + ? true + : never + : never; + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'all'; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link Path} + */ +type PathImpl = V extends + | Primitive + | BrowserNativeObject + ? `${K}` + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? `${K}` + : true extends AnyIsEqual + ? `` + : `${K}` | `${K}.${PathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link Path} + */ +type PathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: PathImpl; + }[TupleKeys] + : PathImpl + : { + [K in keyof T]-?: PathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type + * @typeParam T - type which should be introspected + * @example + * ``` + * Path<{foo: {bar: string}}> = 'foo' | 'foo.bar' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type Path = T extends any ? PathInternal : never; + +/** + * See {@link Path} + */ +export type FieldPath = Path; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link ArrayPath} + */ +type ArrayPathImpl = V extends + | Primitive + | BrowserNativeObject + ? IsAny extends true + ? string + : never + : V extends ReadonlyArray + ? U extends Primitive | BrowserNativeObject + ? IsAny extends true + ? string + : never + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? never + : `${K}` | `${K}.${ArrayPathInternal}` + : true extends AnyIsEqual + ? never + : `${K}.${ArrayPathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link ArrayPath} + */ +type ArrayPathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: ArrayPathImpl; + }[TupleKeys] + : ArrayPathImpl + : { + [K in keyof T]-?: ArrayPathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type which point to an array + * type. + * @typeParam T - type which should be introspected. + * @example + * ``` + * Path<{foo: {bar: string[], baz: number[]}}> = 'foo.bar' | 'foo.baz' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type ArrayPath = T extends any ? ArrayPathInternal : never; + +/** + * See {@link ArrayPath} + */ +export type FieldArrayPath = + ArrayPath; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam P - path into the deeply nested type + * @example + * ``` + * PathValue<{foo: {bar: string}}, 'foo.bar'> = string + * PathValue<[number, string], '1'> = string + * ``` + */ +export type PathValue | ArrayPath> = T extends any + ? P extends `${infer K}.${infer R}` + ? K extends keyof T + ? R extends Path + ? PathValue + : never + : K extends `${ArrayKey}` + ? T extends ReadonlyArray + ? PathValue> + : never + : never + : P extends keyof T + ? T[P] + : P extends `${ArrayKey}` + ? T extends ReadonlyArray + ? V + : never + : never + : never; + +/** + * See {@link PathValue} + */ +export type FieldPathValue< + TFieldValues extends FieldValues, + TFieldPath extends FieldPath, +> = PathValue; + +/** + * See {@link PathValue} + */ +export type FieldArrayPathValue< + TFieldValues extends FieldValues, + TFieldArrayPath extends FieldArrayPath, +> = PathValue; + +/** + * Type to evaluate the type which the given paths point to. + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TPath - paths into the deeply nested field values + * @example + * ``` + * FieldPathValues<{foo: {bar: string}}, ['foo', 'foo.bar']> + * = [{bar: string}, string] + * ``` + */ +export type FieldPathValues< + TFieldValues extends FieldValues, + TPath extends FieldPath[] | readonly FieldPath[], +> = {} & { + [K in keyof TPath]: FieldPathValue< + TFieldValues, + TPath[K] & FieldPath + >; +}; + +/** + * Type which eagerly collects all paths through a fieldType that matches a give type + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TValue - the value you want to match into each type + * @example + * ```typescript + * FieldPathByValue<{foo: {bar: number}, baz: number, bar: string}, number> + * = 'foo.bar' | 'baz' + * ``` + */ +export type FieldPathByValue = { + [FieldKey in FieldPath]: FieldPathValue< + TFieldValues, + FieldKey + > extends TValue + ? FieldKey + : never; +}[FieldPath]; diff --git a/plugins/openapi-router-common/src/types/utils.ts b/plugins/openapi-router-common/src/types/utils.ts new file mode 100644 index 0000000000..25282e1da2 --- /dev/null +++ b/plugins/openapi-router-common/src/types/utils.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +declare const $NestedValue: unique symbol; + +/** + * @deprecated to be removed in the next major version + */ +export type NestedValue = { + [$NestedValue]: never; +} & TValue; + +/* +Projects that React Hook Form installed don't include the DOM library need these interfaces to compile. +React Native applications is no DOM available. The JavaScript runtime is ES6/ES2015 only. +These definitions allow such projects to compile with only --lib ES6. + +Warning: all of these interfaces are empty. +If you want type definitions for various properties, you need to add `--lib DOM` (via command line or tsconfig.json). +*/ + +export type Noop = () => void; + +interface File extends Blob { + readonly lastModified: number; + readonly name: string; +} + +interface FileList { + readonly length: number; + item(index: number): File | null; + [index: number]: File; +} + +export type Primitive = + | null + | undefined + | string + | number + | boolean + | symbol + | bigint; + +export type BrowserNativeObject = Date | FileList | File; + +export type EmptyObject = { [K in string | number]: never }; + +export type NonUndefined = T extends undefined ? never : T; + +export type LiteralUnion = + | T + | (U & { _?: never }); + +export type DeepPartial = T extends BrowserNativeObject | NestedValue + ? T + : { [K in keyof T]?: DeepPartial }; + +export type DeepPartialSkipArrayKey = T extends + | BrowserNativeObject + | NestedValue + ? T + : T extends ReadonlyArray + ? { [K in keyof T]: DeepPartialSkipArrayKey } + : { [K in keyof T]?: DeepPartialSkipArrayKey }; + +/** + * Checks whether the type is any + * See {@link https://stackoverflow.com/a/49928360/3406963} + * @typeParam T - type which may be any + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsAny = 0 extends 1 & T ? true : false; + +/** + * Checks whether the type is never + * @typeParam T - type which may be never + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsNever = [T] extends [never] ? true : false; + +/** + * Checks whether T1 can be exactly (mutually) assigned to T2 + * @typeParam T1 - type to check + * @typeParam T2 - type to check against + * ``` + * IsEqual = true + * IsEqual<'foo', 'foo'> = true + * IsEqual = false + * IsEqual = false + * IsEqual = false + * IsEqual<'foo', string> = false + * IsEqual<'foo' | 'bar', 'foo'> = boolean // 'foo' is assignable, but 'bar' is not (true | false) -> boolean + * ``` + */ +export type IsEqual = T1 extends T2 + ? (() => G extends T1 ? 1 : 2) extends () => G extends T2 ? 1 : 2 + ? true + : false + : false; + +export type DeepMap = IsAny extends true + ? any + : T extends BrowserNativeObject | NestedValue + ? TValue + : T extends object + ? { [K in keyof T]: DeepMap, TValue> } + : TValue; + +export type IsFlatObject = Extract< + Exclude, + any[] | object +> extends never + ? true + : false; + +export type Merge = { + [K in keyof A | keyof B]?: K extends keyof A & keyof B + ? [A[K], B[K]] extends [object, object] + ? Merge + : A[K] | B[K] + : K extends keyof A + ? A[K] + : K extends keyof B + ? B[K] + : never; +}; diff --git a/yarn.lock b/yarn.lock index 859a44fda4..eb39765348 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3342,7 +3342,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.20.13 resolution: "@babel/runtime@npm:7.20.13" dependencies: @@ -7446,6 +7446,17 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common" + dependencies: + "@backstage/cli": "workspace:^" + express: ^4.18.2 + json-schema-to-ts: ^2.6.2 + openapi-types: ^12.1.0 + languageName: unknown + linkType: soft + "@backstage/plugin-org-react@workspace:plugins/org-react": version: 0.0.0-use.local resolution: "@backstage/plugin-org-react@workspace:plugins/org-react" @@ -23777,7 +23788,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1": +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -28324,6 +28335,18 @@ __metadata: languageName: node linkType: hard +"json-schema-to-ts@npm:^2.6.2": + version: 2.6.2 + resolution: "json-schema-to-ts@npm:2.6.2" + dependencies: + "@babel/runtime": ^7.18.3 + "@types/json-schema": ^7.0.9 + ts-algebra: ^1.1.1 + ts-toolbelt: ^9.6.0 + checksum: e408f8d3d32dda1a001f194cf5514fea7ef0a4dc4ed4f5448b9ea02df3a071119a0517aaffb639de725f4a12102406dfdf7743455e0e30317e231bb551362039 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -38580,6 +38603,15 @@ __metadata: languageName: node linkType: hard +"ts-algebra@npm:^1.1.1": + version: 1.1.1 + resolution: "ts-algebra@npm:1.1.1" + dependencies: + ts-toolbelt: ^9.6.0 + checksum: 29fe27215863520377a94a14ccf1e78e921be6a5c514a0a8250dab09e325b006a9571ef1f366875d58e495b59ba880620718a1dd38b6af598cc6ea6de82d4f66 + languageName: node + linkType: hard + "ts-easing@npm:^0.2.0": version: 0.2.0 resolution: "ts-easing@npm:0.2.0" @@ -38672,6 +38704,13 @@ __metadata: languageName: node linkType: hard +"ts-toolbelt@npm:^9.6.0": + version: 9.6.0 + resolution: "ts-toolbelt@npm:9.6.0" + checksum: 9f35fd95d895a5d32ea9fd2e532a695b0bae6cbff6832b77292efa188a0ed1ed6e54f63f74a8920390f3d909a7a3adb20a144686372a8e78b420246a9bd3d58a + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.1 resolution: "tsconfig-paths@npm:3.14.1"