diff --git a/.changeset/cold-seahorses-greet.md b/.changeset/cold-seahorses-greet.md new file mode 100644 index 0000000000..71bc5db2cc --- /dev/null +++ b/.changeset/cold-seahorses-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Updates and moves OpenAPI spec to `src/schema/openapi.yaml` and uses `ApiRouter` type from `@backstage/backend-openapi-utils` to handle automatic types from the OpenAPI spec file. diff --git a/.changeset/fresh-lions-compete.md b/.changeset/fresh-lions-compete.md new file mode 100644 index 0000000000..87f15f538d --- /dev/null +++ b/.changeset/fresh-lions-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': minor +--- + +Adding two new commands to support OpenAPI spec writing, `schema:openapi:generate` to generate the Typescript file that `@backstage/backend-openapi-utils` needs for typing and `schema:openapi:verify` to verify that this file exists and matches your `src/schema/openapi.yaml` file. diff --git a/.changeset/tricky-beans-knock.md b/.changeset/tricky-beans-knock.md new file mode 100644 index 0000000000..1bdd95a365 --- /dev/null +++ b/.changeset/tricky-beans-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-openapi-utils': patch +--- + +New plugin! Primary focus is to support types on `Router`s in backend packages. Developers can use the `ApiRouter` from this package in their packages to support a typed experience based on their OpenAPI specs. The `ApiRouter` supports request bodies, response bodies, query parameters and path parameters, as well as full path-based context of the above. This means no more guessing on an endpoint like `req.post('/not-my-route', (req, res)=>{res.send(req.body.badparam)})`. Typescript would catch `/not-my-route`, `req.body.badparam`, `res.send(req.body.badparam)`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd67d2742a..fb03709cce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,9 @@ jobs: - name: verify api reference run: node scripts/verify-api-reference.js + - name: verify openapi yaml file matches generated ts file + run: yarn backstage-repo-tools schema:openapi:verify + - name: verify doc links run: node scripts/verify-links.js diff --git a/packages/backend-openapi-utils/.eslintrc.js b/packages/backend-openapi-utils/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/backend-openapi-utils/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/backend-openapi-utils/README.md b/packages/backend-openapi-utils/README.md new file mode 100644 index 0000000000..51455ccccb --- /dev/null +++ b/packages/backend-openapi-utils/README.md @@ -0,0 +1,45 @@ +# @backstage/openapi-utils + +## Summary + +This package is meant to provide a typed Express router for an OpenAPI spec. Based on the [`oatx`](https://github.com/varanauskas/oatx) library and adapted to override Express values. + +## Getting Started + +### Configuration + +1. Run `yarn --cwd backstage-cli package schema:openapi:generate` to translate your `src/schema/openapi.yaml` to a new Typescript file in `src/schema/openapi.ts`. In the case of projects that require linting + a license header, you will need to do this manually. + +2. In your plugin's `src/service/createRouter.ts`, + +```ts +import {ApiRouter} from `@backstage/backend-openapi-utils`; +import spec from '../schema/openapi' +... + +export function createRouter(){ + const router = Router() as ApiRouter; + ... +} +``` + +### Limitations + +1. `as const` makes all fields `readonly` + To ensure a good DX of using a simple imported JSON spec, we want to remove any type issues between `readonly` arrays and mutable arrays. Typescript does not allow them to be compared, so converting all imports from the `openapi3-ts` library to `readonly` is important. This is achieved through the `ImmutableObject` type in `types/immutable.ts`. + +```ts +... +// We want an interface like this, +Router() as ApiRouter + +// Not an interface like this, +Router() as ApiRouter> +... +``` + +## Future Work + +### Runtime validation + +Using a package like [`express-openapi-validator`](https://www.npmjs.com/package/express-openapi-validator), would allow us to remove [validation of request bodies with `AJV`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/service/util.ts#L58). However, `AJV` currently doesn't have support for OpenAPI 3.1 and `express-openapi-validator` enforces full URL matching for paths, meaning it cannot be mounted at the router level. diff --git a/packages/backend-openapi-utils/api-report.md b/packages/backend-openapi-utils/api-report.md new file mode 100644 index 0000000000..180a7846ed --- /dev/null +++ b/packages/backend-openapi-utils/api-report.md @@ -0,0 +1,644 @@ +## API Report File for "@backstage/backend-openapi-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type { ContentObject } from 'openapi3-ts'; +import type core from 'express-serve-static-core'; +import { FromSchema } from 'json-schema-to-ts'; +import { JSONSchema7 } from 'json-schema-to-ts'; +import type { OpenAPIObject } from 'openapi3-ts'; +import type { ParameterObject } from 'openapi3-ts'; +import type { ReferenceObject } from 'openapi3-ts'; +import type { RequestBodyObject } from 'openapi3-ts'; +import type { ResponseObject } from 'openapi3-ts'; +import { Router } from 'express'; +import type { SchemaObject } from 'openapi3-ts'; + +// @public +export interface ApiRouter extends Router { + // (undocumented) + all: DocRequestMatcher; + // (undocumented) + delete: DocRequestMatcher; + // (undocumented) + get: DocRequestMatcher; + // (undocumented) + head: DocRequestMatcher; + // (undocumented) + options: DocRequestMatcher; + // (undocumented) + patch: DocRequestMatcher; + // (undocumented) + post: DocRequestMatcher; + // (undocumented) + put: DocRequestMatcher; +} + +// @public (undocumented) +type ComponentRef< + Doc extends RequiredDoc, + Type extends ComponentTypes, + Ref extends ImmutableReferenceObject, +> = Ref extends { + $ref: `#/components/${Type}/${infer Name}`; +} + ? Name extends keyof Doc['components'][Type] + ? Doc['components'][Type][Name] extends ImmutableReferenceObject + ? ComponentRef + : Doc['components'][Type][Name] + : never + : never; + +// @public (undocumented) +type ComponentTypes = Extract< + keyof Doc['components'], + string +>; + +// @public (undocumented) +type ConvertAll = []> = T extends [ + infer First extends JSONSchema7, + ...infer Rest, +] + ? ConvertAll]> + : R; + +// @public (undocumented) +interface CookieObject extends ParameterObject { + // (undocumented) + in: 'cookie'; + // (undocumented) + style?: 'form'; +} + +// @public (undocumented) +type CookieSchema< + Doc extends RequiredDoc, + Path extends PathTemplate>, + Method extends DocPathMethod, +> = ParametersSchema, Method, ImmutableCookieObject>; + +// @public (undocumented) +type DiscriminateUnion = Extract< + T, + Record +>; + +// @public (undocumented) +type DocOperation< + Doc extends RequiredDoc, + Path extends keyof Doc['paths'], + Method extends keyof Doc['paths'][Path], +> = Doc['paths'][Path][Method]; + +// @public (undocumented) +type DocParameter< + Doc extends RequiredDoc, + Path extends Extract, + Method extends keyof Doc['paths'][Path], + Parameter extends keyof Doc['paths'][Path][Method]['parameters'], +> = DocOperation< + Doc, + Path, + Method +>['parameters'][Parameter] extends ImmutableReferenceObject + ? 'parameters' extends ComponentTypes + ? ComponentRef< + Doc, + 'parameters', + DocOperation['parameters'][Parameter] + > + : never + : DocOperation['parameters'][Parameter]; + +// @public (undocumented) +type DocParameters< + Doc extends RequiredDoc, + Path extends Extract, + Method extends keyof Doc['paths'][Path], +> = DocOperation['parameters'] extends ReadonlyArray + ? { + [Index in keyof DocOperation< + Doc, + Path, + Method + >['parameters']]: DocParameter; + } + : never; + +// @public +type DocPath< + Doc extends PathDoc, + Path extends PathTemplate>, +> = ValueOf<{ + [Template in Extract< + keyof Doc['paths'], + string + >]: Path extends PathTemplate