>, L>;
+
+// @public
+type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (
+ k: infer I,
+) => void
+ ? I
+ : never;
+
+// @public (undocumented)
+type UnknownIfNever = [P] extends [never] ? unknown : P;
+
+// @public
+type ValueOf = T[keyof T];
+```
diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json
new file mode 100644
index 0000000000..9fb04150a4
--- /dev/null
+++ b/packages/backend-openapi-utils/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@backstage/backend-openapi-utils",
+ "description": "OpenAPI typescript support.",
+ "version": "0.0.1",
+ "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": "node-library"
+ },
+ "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": {
+ "@types/express": "^4.17.6",
+ "@types/express-serve-static-core": "^4.17.5",
+ "express": "^4.17.1",
+ "express-promise-router": "^4.1.0",
+ "json-schema-to-ts": "^2.6.2",
+ "openapi3-ts": "^3.1.2"
+ }
+}
diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts
new file mode 100644
index 0000000000..f8fdcff16b
--- /dev/null
+++ b/packages/backend-openapi-utils/src/index.ts
@@ -0,0 +1,25 @@
+/*
+ * 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
+ */
+import * as internal from './types';
+
+export { internal };
+export type { ApiRouter } from './router';
diff --git a/packages/backend-openapi-utils/src/router.ts b/packages/backend-openapi-utils/src/router.ts
new file mode 100644
index 0000000000..253fc1596e
--- /dev/null
+++ b/packages/backend-openapi-utils/src/router.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { Router } from 'express';
+import { DocRequestMatcher, RequiredDoc } from './types';
+
+/**
+ * Typed Express router based on an OpenAPI 3.1 spec.
+ * @public
+ */
+export interface ApiRouter extends Router {
+ get: DocRequestMatcher;
+
+ post: DocRequestMatcher;
+
+ all: DocRequestMatcher;
+
+ put: DocRequestMatcher;
+
+ delete: DocRequestMatcher;
+
+ patch: DocRequestMatcher;
+
+ options: DocRequestMatcher;
+
+ head: DocRequestMatcher;
+}
diff --git a/packages/backend-openapi-utils/src/setupTests.ts b/packages/backend-openapi-utils/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/packages/backend-openapi-utils/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/packages/backend-openapi-utils/src/types/common.ts b/packages/backend-openapi-utils/src/types/common.ts
new file mode 100644
index 0000000000..5568ed795c
--- /dev/null
+++ b/packages/backend-openapi-utils/src/types/common.ts
@@ -0,0 +1,286 @@
+/*
+ * 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.
+ */
+
+/**
+ * Pulled from https://github.com/varanauskas/oatx.
+ */
+
+import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
+import {
+ ImmutableContentObject,
+ ImmutableOpenAPIObject,
+ ImmutableReferenceObject,
+} from './immutable';
+
+/**
+ * Basic OpenAPI spec with paths and components properties enforced.
+ * @public
+ */
+export type RequiredDoc = Pick;
+
+/**
+ * @public
+ */
+export type PathDoc = Pick;
+
+/**
+ * Get value types of `T`.
+ * @public
+ */
+export type ValueOf = T[keyof T];
+
+/**
+ * Validate a string against OpenAPI path template, {@link https://spec.openapis.org/oas/v3.1.0#path-templating-matching}.
+ *
+ * @example
+ * ```ts
+ * const path: PathTemplate<"/posts/{postId}/comments/{commentId}"> = "/posts/:postId/comments/:commentId";
+ * const pathWithoutParams: PathTemplate<"/posts/comments"> = "/posts/comments";
+ * ```
+ *
+ * @public
+ */
+export type PathTemplate =
+ Path extends `${infer Prefix}{${infer PathName}}${infer Suffix}`
+ ? `${Prefix}:${PathName}${PathTemplate}`
+ : Path;
+
+/**
+ * Extract path as specified in OpenAPI `Doc` based on request path
+ * @example
+ * ```ts
+ * const spec = {
+ * paths: {
+ * "/posts/{postId}/comments/{commentId}": {},
+ * "/posts/comments": {},
+ * }
+ * };
+ * const specPathWithParams: DocPath = "/posts/{postId}/comments/{commentId}";
+ * const specPathWithoutParams: DocPath = "/posts/comments";
+ * ```
+ *
+ * @public
+ */
+export type DocPath<
+ Doc extends PathDoc,
+ Path extends PathTemplate>,
+> = ValueOf<{
+ [Template in Extract<
+ keyof Doc['paths'],
+ string
+ >]: Path extends PathTemplate ? Template : never;
+}>;
+
+/**
+ * @public
+ */
+export type DocPathTemplate = PathTemplate<
+ Extract
+>;
+
+/**
+ * @public
+ */
+export type DocPathMethod<
+ Doc extends Pick,
+ Path extends DocPathTemplate,
+> = keyof Doc['paths'][DocPath];
+
+/**
+ * @public
+ */
+export type MethodAwareDocPath<
+ Doc extends PathDoc,
+ Path extends PathTemplate>,
+ Method extends keyof Doc['paths'][Path],
+> = ValueOf<{
+ [Template in Extract<
+ keyof Doc['paths'],
+ string
+ >]: Path extends PathTemplate
+ ? Method extends DocPathMethod
+ ? PathTemplate
+ : never
+ : never;
+}>;
+
+/**
+ * @public
+ */
+export type DocOperation<
+ Doc extends RequiredDoc,
+ Path extends keyof Doc['paths'],
+ Method extends keyof Doc['paths'][Path],
+> = Doc['paths'][Path][Method];
+
+/**
+ * @public
+ */
+export type ComponentTypes = Extract<
+ keyof Doc['components'],
+ string
+>;
+
+/**
+ * @public
+ */
+export 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
+ */
+export type SchemaRef = Schema extends {
+ $ref: `#/components/schemas/${infer Name}`;
+}
+ ? 'schemas' extends keyof Doc['components']
+ ? Name extends keyof Doc['components']['schemas']
+ ? SchemaRef
+ : never
+ : never
+ : { [Key in keyof Schema]: SchemaRef };
+
+/**
+ * @public
+ */
+export type ObjectWithContentSchema<
+ Doc extends RequiredDoc,
+ Object extends { content?: ImmutableContentObject },
+> = Object['content'] extends ImmutableContentObject
+ ? SchemaRef
+ : never;
+
+/**
+ * From {@link https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema}
+ *
+ * StackOverflow says not to do this, but union types aren't possible any other way.
+ *
+ * @public
+ */
+export type UnionToIntersection = (
+ U extends any ? (k: U) => void : never
+) extends (k: infer I) => void
+ ? I
+ : never;
+
+/**
+ * @public
+ */
+export type LastOf = UnionToIntersection<
+ T extends any ? () => T : never
+> extends () => infer R
+ ? R
+ : never;
+
+/**
+ * @public
+ */
+export type Push = [...T, V];
+
+/**
+ * @public
+ */
+export type TuplifyUnion<
+ T,
+ L = LastOf,
+ N = [T] extends [never] ? true : false,
+> = true extends N ? [] : Push>, L>;
+
+/**
+ * @public
+ */
+export type ConvertAll = []> = T extends [
+ infer First extends JSONSchema7,
+ ...infer Rest,
+]
+ ? ConvertAll]>
+ : R;
+
+/**
+ * @public
+ */
+export type UnknownIfNever = [P] extends [never] ? unknown : P;
+
+/**
+ * @public
+ */
+export type ToTypeSafe = UnknownIfNever>[number]>;
+
+/**
+ * @public
+ */
+export type DiscriminateUnion = Extract<
+ T,
+ Record
+>;
+
+/**
+ * @public
+ */
+export type MapDiscriminatedUnion<
+ T extends Record,
+ K extends keyof T,
+> = {
+ [V in T[K]]: DiscriminateUnion;
+};
+
+/**
+ * @public
+ */
+export type PickOptionalKeys = {
+ [K in keyof T]: true extends T[K]['required'] ? never : K;
+}[keyof T];
+
+/**
+ * @public
+ */
+export type PickRequiredKeys = {
+ [K in keyof T]: true extends T[K]['required'] ? K : never;
+}[keyof T];
+
+/**
+ * @public
+ */
+export type OptionalMap = {
+ [P in Exclude, undefined>]?: NonNullable;
+};
+
+/**
+ * @public
+ */
+export type RequiredMap = {
+ [P in Exclude, undefined>]: NonNullable;
+};
+
+/**
+ * @public
+ */
+export type FullMap = RequiredMap &
+ OptionalMap;
+
+/**
+ * @public
+ */
+export type Filter = T extends U ? T : never;
diff --git a/packages/backend-openapi-utils/src/types/express.ts b/packages/backend-openapi-utils/src/types/express.ts
new file mode 100644
index 0000000000..b0020dab37
--- /dev/null
+++ b/packages/backend-openapi-utils/src/types/express.ts
@@ -0,0 +1,79 @@
+/*
+ * 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 type core from 'express-serve-static-core';
+import { DocPathTemplate, MethodAwareDocPath, RequiredDoc } from './common';
+import { PathSchema, QuerySchema } from './params';
+import { RequestBodyToJsonSchema } from './requests';
+import { ResponseBodyToJsonSchema } from './responses';
+
+/**
+ * Typed express request handler.
+ * @public
+ */
+export type DocRequestHandler<
+ Doc extends RequiredDoc,
+ Path extends DocPathTemplate,
+ Method extends keyof Doc['paths'][Path],
+> = core.RequestHandler<
+ PathSchema,
+ ResponseBodyToJsonSchema,
+ RequestBodyToJsonSchema,
+ QuerySchema,
+ Record
+>;
+
+/**
+ * Typed express error handler / request handler union type.
+ * @public
+ */
+export type DocRequestHandlerParams<
+ Doc extends RequiredDoc,
+ Path extends DocPathTemplate,
+ Method extends keyof Doc['paths'][Path],
+> = core.RequestHandlerParams<
+ PathSchema,
+ ResponseBodyToJsonSchema,
+ RequestBodyToJsonSchema,
+ QuerySchema,
+ Record
+>;
+
+/**
+ * Superset of the express router path matcher that enforces typed request and response bodies.
+ * @public
+ */
+export interface DocRequestMatcher<
+ Doc extends RequiredDoc,
+ T,
+ Method extends
+ | 'all'
+ | 'get'
+ | 'post'
+ | 'put'
+ | 'delete'
+ | 'patch'
+ | 'options'
+ | 'head',
+> {
+ , Method>>(
+ path: Path,
+ ...handlers: Array>
+ ): T;
+ , Method>>(
+ path: Path,
+ ...handlers: Array>
+ ): T;
+}
diff --git a/packages/backend-openapi-utils/src/types/immutable.ts b/packages/backend-openapi-utils/src/types/immutable.ts
new file mode 100644
index 0000000000..3b253133d8
--- /dev/null
+++ b/packages/backend-openapi-utils/src/types/immutable.ts
@@ -0,0 +1,140 @@
+/*
+ * 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 type {
+ ContentObject,
+ OpenAPIObject,
+ ReferenceObject,
+ RequestBodyObject,
+ ParameterObject,
+ SchemaObject,
+ ResponseObject,
+} from 'openapi3-ts';
+
+/**
+ * This file is meant to hold Immutable overwrites of the values provided by the `openapi3-ts`
+ * package due to issues with `as const` supporting only readonly values.
+ */
+
+/**
+ * From {@link https://github.com/microsoft/TypeScript/issues/13923#issuecomment-653675557}, allows
+ * us to convert from `as const` to the various OpenAPI types documented in `openapi3-ts`.
+ *
+ * @public
+ */
+export type Immutable = T extends
+ | Function
+ | boolean
+ | number
+ | string
+ | null
+ | undefined
+ ? T
+ : T extends Map
+ ? ReadonlyMap