diff --git a/.github/workflows/version.yaml b/.github/workflows/version.yaml index c8be4a9..ff5bc61 100644 --- a/.github/workflows/version.yaml +++ b/.github/workflows/version.yaml @@ -66,6 +66,9 @@ jobs: npm install --save-exact --workspace docs-example openapi-typescript-server@${{ env.PACKAGE_VERSION }} openapi-typescript-server-express@${{ env.PACKAGE_VERSION }} npm install --save-exact --workspace docs-example openapi-typescript-server@${{ env.PACKAGE_VERSION }} openapi-typescript-server-express@${{ env.PACKAGE_VERSION }} + npm install --save-exact --workspace tags-example openapi-typescript-server@${{ env.PACKAGE_VERSION }} openapi-typescript-server-express@${{ env.PACKAGE_VERSION }} + npm install --save-exact --workspace tags-example openapi-typescript-server@${{ env.PACKAGE_VERSION }} openapi-typescript-server-express@${{ env.PACKAGE_VERSION }} + - name: Build the packages run: npm run build:packages diff --git a/examples/docs/gen/server.ts b/examples/docs/gen/server.ts index b5cab07..a7b0f69 100644 --- a/examples/docs/gen/server.ts +++ b/examples/docs/gen/server.ts @@ -72,3 +72,32 @@ export function registerRouteHandlers(server: Server): Route }, ] } + +export type Tag = null; + +export interface ServerForUntagged { + makePetSpeak: (args: MakePetSpeakArgs) => MakePetSpeakResult; + uhoh: (args: UhohArgs) => UhohResult; +} + +export function registerRouteHandlersByTag(tag: null, server: ServerForUntagged): Route[]; +export function registerRouteHandlersByTag(tag: Tag, server: Partial>): Route[] { + const routes: Route[] = []; + + switch (tag) { + case null: + routes.push({ + method: "post", + path: "/speak/{petId}", + handler: server.makePetSpeak as Route["handler"], + }); + routes.push({ + method: "get", + path: "/uhoh", + handler: server.uhoh as Route["handler"], + }); + break; + } + + return routes; +} diff --git a/examples/kitchensink/gen/server.ts b/examples/kitchensink/gen/server.ts index 5dac4bc..844627a 100644 --- a/examples/kitchensink/gen/server.ts +++ b/examples/kitchensink/gen/server.ts @@ -250,3 +250,62 @@ export function registerRouteHandlers(server: Server): Route }, ] } + +export type Tag = null; + +export interface ServerForUntagged { + listPets: (args: ListPetsArgs) => ListPetsResult; + listPetsBySize: (args: ListPetsBySizeArgs) => ListPetsBySizeResult; + getPetById: (args: GetPetByIdArgs) => GetPetByIdResult; + updatePetWithForm: (args: UpdatePetWithFormArgs) => UpdatePetWithFormResult; + mixedContentTypes: (args: MixedContentTypesArgs) => MixedContentTypesResult; + getPetImage: (args: GetPetImageArgs) => GetPetImageResult; + getPetWebpage: (args: GetPetWebpageArgs) => GetPetWebpageResult; +} + +export function registerRouteHandlersByTag(tag: null, server: ServerForUntagged): Route[]; +export function registerRouteHandlersByTag(tag: Tag, server: Partial>): Route[] { + const routes: Route[] = []; + + switch (tag) { + case null: + routes.push({ + method: "get", + path: "/pets", + handler: server.listPets as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pets/{size}", + handler: server.listPetsBySize as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/{petId}", + handler: server.getPetById as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet/{petId}", + handler: server.updatePetWithForm as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet/{petId}/mixed-content-types", + handler: server.mixedContentTypes as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/{petId}/image", + handler: server.getPetImage as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/{petId}/webpage", + handler: server.getPetWebpage as Route["handler"], + }); + break; + } + + return routes; +} diff --git a/examples/petstore/gen/server.ts b/examples/petstore/gen/server.ts index 8ebff13..740f301 100644 --- a/examples/petstore/gen/server.ts +++ b/examples/petstore/gen/server.ts @@ -743,3 +743,146 @@ export function registerRouteHandlers(server: Server): Route }, ] } + +export type Tag = "pet" | "store" | "user"; + +export interface ServerForPet { + updatePet: (args: UpdatePetArgs) => UpdatePetResult; + addPet: (args: AddPetArgs) => AddPetResult; + findPetsByStatus: (args: FindPetsByStatusArgs) => FindPetsByStatusResult; + findPetsByTags: (args: FindPetsByTagsArgs) => FindPetsByTagsResult; + getPetById: (args: GetPetByIdArgs) => GetPetByIdResult; + updatePetWithForm: (args: UpdatePetWithFormArgs) => UpdatePetWithFormResult; + deletePet: (args: DeletePetArgs) => DeletePetResult; + uploadFile: (args: UploadFileArgs) => UploadFileResult; +} + +export interface ServerForStore { + getInventory: (args: GetInventoryArgs) => GetInventoryResult; + placeOrder: (args: PlaceOrderArgs) => PlaceOrderResult; + getOrderById: (args: GetOrderByIdArgs) => GetOrderByIdResult; + deleteOrder: (args: DeleteOrderArgs) => DeleteOrderResult; +} + +export interface ServerForUser { + createUser: (args: CreateUserArgs) => CreateUserResult; + createUsersWithListInput: (args: CreateUsersWithListInputArgs) => CreateUsersWithListInputResult; + loginUser: (args: LoginUserArgs) => LoginUserResult; + logoutUser: (args: LogoutUserArgs) => LogoutUserResult; + getUserByName: (args: GetUserByNameArgs) => GetUserByNameResult; + updateUser: (args: UpdateUserArgs) => UpdateUserResult; + deleteUser: (args: DeleteUserArgs) => DeleteUserResult; +} + +export function registerRouteHandlersByTag(tag: "pet", server: ServerForPet): Route[]; +export function registerRouteHandlersByTag(tag: "store", server: ServerForStore): Route[]; +export function registerRouteHandlersByTag(tag: "user", server: ServerForUser): Route[]; +export function registerRouteHandlersByTag(tag: Tag, server: Partial>): Route[] { + const routes: Route[] = []; + + switch (tag) { + case "pet": + routes.push({ + method: "put", + path: "/pet", + handler: server.updatePet as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet", + handler: server.addPet as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/findByStatus", + handler: server.findPetsByStatus as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/findByTags", + handler: server.findPetsByTags as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/{petId}", + handler: server.getPetById as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet/{petId}", + handler: server.updatePetWithForm as Route["handler"], + }); + routes.push({ + method: "delete", + path: "/pet/{petId}", + handler: server.deletePet as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet/{petId}/uploadImage", + handler: server.uploadFile as Route["handler"], + }); + break; + case "store": + routes.push({ + method: "get", + path: "/store/inventory", + handler: server.getInventory as Route["handler"], + }); + routes.push({ + method: "post", + path: "/store/order", + handler: server.placeOrder as Route["handler"], + }); + routes.push({ + method: "get", + path: "/store/order/{orderId}", + handler: server.getOrderById as Route["handler"], + }); + routes.push({ + method: "delete", + path: "/store/order/{orderId}", + handler: server.deleteOrder as Route["handler"], + }); + break; + case "user": + routes.push({ + method: "post", + path: "/user", + handler: server.createUser as Route["handler"], + }); + routes.push({ + method: "post", + path: "/user/createWithList", + handler: server.createUsersWithListInput as Route["handler"], + }); + routes.push({ + method: "get", + path: "/user/login", + handler: server.loginUser as Route["handler"], + }); + routes.push({ + method: "get", + path: "/user/logout", + handler: server.logoutUser as Route["handler"], + }); + routes.push({ + method: "get", + path: "/user/{username}", + handler: server.getUserByName as Route["handler"], + }); + routes.push({ + method: "put", + path: "/user/{username}", + handler: server.updateUser as Route["handler"], + }); + routes.push({ + method: "delete", + path: "/user/{username}", + handler: server.deleteUser as Route["handler"], + }); + break; + } + + return routes; +} diff --git a/examples/tags/api.ts b/examples/tags/api.ts new file mode 100644 index 0000000..e2bba19 --- /dev/null +++ b/examples/tags/api.ts @@ -0,0 +1,125 @@ +import type * as ServerTypes from "./gen/server.ts"; +import type { Request, Response } from "express"; + +// Service implementation for "pets" tag +export const petsService: ServerTypes.ServerForPets = { + listPets: async (): ServerTypes.ListPetsResult => { + return { + content: { + 200: { + "application/json": { + pets: [ + { id: 1, name: "dog" }, + { id: 2, name: "cat" }, + ], + }, + }, + }, + }; + }, + + getPetById: async ({ parameters }): ServerTypes.GetPetByIdResult => { + if (parameters.path.petId === 42) { + return { + content: { + default: { + "application/json": { + message: "Cannot get that pet", + }, + }, + }, + status: 503, + }; + } + + if (parameters.path.petId === 500) { + throw new Error("Cannot get that pet"); + } + + return { + content: { + 200: { + "application/json": { + pet: { id: parameters.path.petId, name: "dog" }, + }, + }, + }, + }; + }, + + updatePetWithForm: async ({ + parameters, + requestBody, + }): ServerTypes.UpdatePetWithFormResult => { + const { petId } = parameters.path; + const { name } = parameters.query ?? {}; + const { status } = requestBody.content; + + return { + content: { + 200: { + "application/json": { + pet: { id: petId, name: name || "dog", status }, + }, + }, + }, + }; + }, +}; + +// Service implementation for "store" tag +export const storeService: ServerTypes.ServerForStore = { + getInventory: async (): ServerTypes.GetInventoryResult => { + return { + content: { + 200: { + "application/json": { + inventory: { + available: 10, + pending: 5, + sold: 3, + }, + }, + }, + }, + }; + }, + + placeOrder: async ({ requestBody }): ServerTypes.PlaceOrderResult => { + const { petId, quantity } = requestBody.content; + + return { + content: { + 200: { + "application/json": { + order: { + id: Math.floor(Math.random() * 1000), + petId, + quantity, + status: "placed", + }, + }, + }, + }, + }; + }, +}; + +// Service implementation for untagged operations +export const untaggedService: ServerTypes.ServerForUntagged = + { + listUsers: async (): ServerTypes.ListUsersResult => { + return { + content: { + 200: { + "application/json": { + users: [ + { id: 1, username: "john_doe", email: "john@example.com" }, + { id: 2, username: "jane_smith", email: "jane@example.com" }, + ], + }, + }, + }, + }; + }, + }; diff --git a/examples/tags/app.test.ts b/examples/tags/app.test.ts new file mode 100644 index 0000000..8616487 --- /dev/null +++ b/examples/tags/app.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import makeApp from "./app.ts"; +import request from "supertest"; + +const app = makeApp(); + +describe("getPetById", async () => { + it("returns 200", async () => { + const response = await request(app) + .get("/api/v3/pet/123") + .set("Accept", "application/json"); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + pet: { + id: 123, + name: "dog", + }, + }); + }); + + it("returns 503 via default response and custom status code", async () => { + const response = await request(app) + .get("/api/v3/pet/42") + .set("Accept", "application/json"); + + assert.equal(response.status, 503); + assert.deepEqual(response.body, { + message: "Cannot get that pet", + }); + }); +}); + +describe("updatePetWithForm", async () => { + it("returns 200", async () => { + const response = await request(app) + .post("/api/v3/pet/123?name=cat") + .set("Accept", "application/json") + .send({ status: "sold" }); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + pet: { + id: 123, + name: "cat", + status: "sold", + }, + }); + }); +}); + +describe("listUsers", async () => { + it("returns 200", async () => { + const response = await request(app) + .get("/api/v3/users") + .set("Accept", "application/json"); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + users: [ + { id: 1, username: "john_doe", email: "john@example.com" }, + { id: 2, username: "jane_smith", email: "jane@example.com" }, + ], + }); + }); +}); + +describe("listPets", async () => { + it("returns 200", async () => { + const response = await request(app) + .get("/api/v3/pets") + .set("Accept", "application/json"); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + pets: [ + { id: 1, name: "dog" }, + { id: 2, name: "cat" }, + ], + }); + }); +}); + +describe("getInventory", async () => { + it("returns 200", async () => { + const response = await request(app) + .get("/api/v3/store/inventory") + .set("Accept", "application/json"); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, { + inventory: { + available: 10, + pending: 5, + sold: 3, + }, + }); + }); +}); + +describe("placeOrder", async () => { + it("returns 200", async () => { + const response = await request(app) + .post("/api/v3/store/order") + .set("Accept", "application/json") + .send({ petId: 123, quantity: 2 }); + + assert.equal(response.status, 200); + assert(response.body.order); + assert.equal(response.body.order.petId, 123); + assert.equal(response.body.order.quantity, 2); + assert.equal(response.body.order.status, "placed"); + assert(typeof response.body.order.id === "number"); + }); +}); diff --git a/examples/tags/app.ts b/examples/tags/app.ts new file mode 100644 index 0000000..dfb6209 --- /dev/null +++ b/examples/tags/app.ts @@ -0,0 +1,68 @@ +import express from "express"; +import type { Request, Response, NextFunction } from "express"; +import { registerRouteHandlersByTag } from "./gen/server.ts"; +import registerRoutes from "openapi-typescript-server-express"; +import { petsService, storeService, untaggedService } from "./api.ts"; +import OpenApiValidator from "express-openapi-validator"; +import { NotImplementedError } from "openapi-typescript-server-runtime"; + +export default function makeApp() { + const app = express(); + + app.get("/", (_req, res) => { + res.send("Hello World!"); + }); + + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const apiRouter = express(); + + apiRouter.use( + OpenApiValidator.middleware({ + apiSpec: "./openapi.yaml", + validateResponses: false, + }), + ); + + // Register routes by tag using registerRouteHandlersByTag + const petsRoutes = registerRouteHandlersByTag("pets", petsService); + const storeRoutes = registerRouteHandlersByTag("store", storeService); + const untaggedRoutes = registerRouteHandlersByTag(null, untaggedService); + + registerRoutes([...petsRoutes, ...storeRoutes, ...untaggedRoutes], apiRouter); + + app.use("/api/v3", apiRouter); + + interface ValidationError extends Error { + status?: number; + errors?: Record[]; + } + + app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { + console.error("+++", err); + + const validationError = err as ValidationError; + if (validationError.status && validationError.errors) { + res.status(validationError.status || 500).json({ + message: validationError.message, + errors: validationError.errors, + }); + return; + } + + if (err instanceof NotImplementedError) { + res.status(501).json({ + message: "Not Implemented", + }); + return; + } + + res.status(500).json({ + message: "Internal Server Error", + }); + return; + }); + + return app; +} diff --git a/examples/tags/cmd/index.ts b/examples/tags/cmd/index.ts new file mode 100644 index 0000000..881517e --- /dev/null +++ b/examples/tags/cmd/index.ts @@ -0,0 +1,5 @@ +import makeApp from "../app.ts"; + +makeApp().listen(8080, () => { + console.log("Server running on port 8080"); +}); diff --git a/examples/tags/gen/schema.d.ts b/examples/tags/gen/schema.d.ts new file mode 100644 index 0000000..b7b2f01 --- /dev/null +++ b/examples/tags/gen/schema.d.ts @@ -0,0 +1,361 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/pets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns all pets from the system that the user has access to */ + get: operations["listPets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pet/{petId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPetById"]; + put?: never; + post: operations["updatePetWithForm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns all users from the system */ + get: operations["listUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/store/inventory": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInventory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/store/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["placeOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ErrorResponse: { + message?: string; + }; + Pet: { + /** Format: int64 */ + id: number; + name: string; + category?: components["schemas"]["Category"]; + photoUrls?: string[]; + tags?: components["schemas"]["Tag"][]; + /** + * @description pet status in the store + * @enum {string} + */ + status?: "available" | "pending" | "sold"; + }; + Category: { + /** Format: int64 */ + id?: number; + name?: string; + }; + Tag: { + /** Format: int64 */ + id?: number; + name?: string; + }; + UpdatePetInput: { + /** @enum {string} */ + status: "available" | "pending" | "sold"; + }; + Order: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + petId: number; + /** Format: int32 */ + quantity: number; + /** + * @description Order Status + * @enum {string} + */ + status?: "placed" | "approved" | "delivered"; + }; + User: { + /** Format: int64 */ + id: number; + username: string; + email?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listPets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of pets. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pets?: components["schemas"]["Pet"][]; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPetById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of pet to return */ + petId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pet?: components["schemas"]["Pet"]; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updatePetWithForm: { + parameters: { + query?: { + /** @description Name of pet that needs to be updated */ + name?: string; + }; + header?: never; + path: { + /** @description ID of pet that needs to be updated */ + petId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePetInput"]; + }; + }; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pet?: components["schemas"]["Pet"]; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of users. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + users?: components["schemas"]["User"][]; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getInventory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + inventory?: { + [key: string]: number; + }; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + placeOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: int64 */ + petId: number; + /** Format: int32 */ + quantity: number; + }; + }; + }; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + order?: components["schemas"]["Order"]; + }; + }; + }; + /** @description unexpected error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/examples/tags/gen/server.ts b/examples/tags/gen/server.ts new file mode 100644 index 0000000..df44185 --- /dev/null +++ b/examples/tags/gen/server.ts @@ -0,0 +1,285 @@ +/** + * This file was auto-generated by openapi-typescript-server@0.0.13. + * Do not make direct changes to the file. + */ + +import type { paths } from "./schema.d.ts"; +import type { Route } from "openapi-typescript-server-runtime"; +import { NotImplementedError } from "openapi-typescript-server-runtime"; + +export interface ListPetsArgs { + parameters: paths['/pets']['get']['parameters']; + contentType: string; + req: Req; + res: Res; +} + +interface ListPetsResult200 { + content: { 200: paths['/pets']['get']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface ListPetsResultDefault { + content: { default: paths['/pets']['get']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type ListPetsResult = Promise; + +export async function listPetsUnimplemented(): ListPetsResult { + throw new NotImplementedError() +} + +export interface GetPetByIdArgs { + parameters: paths['/pet/{petId}']['get']['parameters']; + contentType: string; + req: Req; + res: Res; +} + +interface GetPetByIdResult200 { + content: { 200: paths['/pet/{petId}']['get']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface GetPetByIdResultDefault { + content: { default: paths['/pet/{petId}']['get']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type GetPetByIdResult = Promise; + +export async function getPetByIdUnimplemented(): GetPetByIdResult { + throw new NotImplementedError() +} + +export interface UpdatePetWithFormArgs { + parameters: paths['/pet/{petId}']['post']['parameters']; + contentType: string; + req: Req; + res: Res; + requestBody: { + mediaType: "application/json"; + content: paths['/pet/{petId}']['post']['requestBody']['content']['application/json'] + } + ; +} + +interface UpdatePetWithFormResult200 { + content: { 200: paths['/pet/{petId}']['post']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface UpdatePetWithFormResultDefault { + content: { default: paths['/pet/{petId}']['post']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type UpdatePetWithFormResult = Promise; + +export async function updatePetWithFormUnimplemented(): UpdatePetWithFormResult { + throw new NotImplementedError() +} + +export interface ListUsersArgs { + parameters: paths['/users']['get']['parameters']; + contentType: string; + req: Req; + res: Res; +} + +interface ListUsersResult200 { + content: { 200: paths['/users']['get']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface ListUsersResultDefault { + content: { default: paths['/users']['get']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type ListUsersResult = Promise; + +export async function listUsersUnimplemented(): ListUsersResult { + throw new NotImplementedError() +} + +export interface GetInventoryArgs { + parameters: paths['/store/inventory']['get']['parameters']; + contentType: string; + req: Req; + res: Res; +} + +interface GetInventoryResult200 { + content: { 200: paths['/store/inventory']['get']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface GetInventoryResultDefault { + content: { default: paths['/store/inventory']['get']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type GetInventoryResult = Promise; + +export async function getInventoryUnimplemented(): GetInventoryResult { + throw new NotImplementedError() +} + +export interface PlaceOrderArgs { + parameters: paths['/store/order']['post']['parameters']; + contentType: string; + req: Req; + res: Res; + requestBody: { + mediaType: "application/json"; + content: paths['/store/order']['post']['requestBody']['content']['application/json'] + } + ; +} + +interface PlaceOrderResult200 { + content: { 200: paths['/store/order']['post']['responses']['200']['content'] }; + headers?: { [name: string]: any }; +} + +interface PlaceOrderResultDefault { + content: { default: paths['/store/order']['post']['responses']['default']['content'] }; + headers?: { [name: string]: any }; + status: number; +} + +export type PlaceOrderResult = Promise; + +export async function placeOrderUnimplemented(): PlaceOrderResult { + throw new NotImplementedError() +} + +export interface Server { + /** Returns all pets from the system that the user has access to */ + listPets: ( + args: ListPetsArgs + ) => ListPetsResult; + getPetById: ( + args: GetPetByIdArgs + ) => GetPetByIdResult; + updatePetWithForm: ( + args: UpdatePetWithFormArgs + ) => UpdatePetWithFormResult; + /** Returns all users from the system */ + listUsers: ( + args: ListUsersArgs + ) => ListUsersResult; + getInventory: ( + args: GetInventoryArgs + ) => GetInventoryResult; + placeOrder: ( + args: PlaceOrderArgs + ) => PlaceOrderResult; +} + +export function registerRouteHandlers(server: Server): Route[] { + return [ + { + method: "get", + path: "/pets", + handler: server.listPets as Route["handler"], + }, + { + method: "get", + path: "/pet/{petId}", + handler: server.getPetById as Route["handler"], + }, + { + method: "post", + path: "/pet/{petId}", + handler: server.updatePetWithForm as Route["handler"], + }, + { + method: "get", + path: "/users", + handler: server.listUsers as Route["handler"], + }, + { + method: "get", + path: "/store/inventory", + handler: server.getInventory as Route["handler"], + }, + { + method: "post", + path: "/store/order", + handler: server.placeOrder as Route["handler"], + }, + ] +} + +export type Tag = "pets" | "store" | null; + +export interface ServerForPets { + listPets: (args: ListPetsArgs) => ListPetsResult; + getPetById: (args: GetPetByIdArgs) => GetPetByIdResult; + updatePetWithForm: (args: UpdatePetWithFormArgs) => UpdatePetWithFormResult; +} + +export interface ServerForUntagged { + listUsers: (args: ListUsersArgs) => ListUsersResult; +} + +export interface ServerForStore { + getInventory: (args: GetInventoryArgs) => GetInventoryResult; + placeOrder: (args: PlaceOrderArgs) => PlaceOrderResult; +} + +export function registerRouteHandlersByTag(tag: "pets", server: ServerForPets): Route[]; +export function registerRouteHandlersByTag(tag: null, server: ServerForUntagged): Route[]; +export function registerRouteHandlersByTag(tag: "store", server: ServerForStore): Route[]; +export function registerRouteHandlersByTag(tag: Tag, server: Partial>): Route[] { + const routes: Route[] = []; + + switch (tag) { + case "pets": + routes.push({ + method: "get", + path: "/pets", + handler: server.listPets as Route["handler"], + }); + routes.push({ + method: "get", + path: "/pet/{petId}", + handler: server.getPetById as Route["handler"], + }); + routes.push({ + method: "post", + path: "/pet/{petId}", + handler: server.updatePetWithForm as Route["handler"], + }); + break; + case null: + routes.push({ + method: "get", + path: "/users", + handler: server.listUsers as Route["handler"], + }); + break; + case "store": + routes.push({ + method: "get", + path: "/store/inventory", + handler: server.getInventory as Route["handler"], + }); + routes.push({ + method: "post", + path: "/store/order", + handler: server.placeOrder as Route["handler"], + }); + break; + } + + return routes; +} diff --git a/examples/tags/openapi.yaml b/examples/tags/openapi.yaml new file mode 100644 index 0000000..362ab24 --- /dev/null +++ b/examples/tags/openapi.yaml @@ -0,0 +1,278 @@ +openapi: 3.0.2 +info: + title: Simple Petstore + version: 0.0.1 +servers: + - url: /api/v3 +paths: + /pets: + get: + tags: + - pets + operationId: listPets + summary: Returns all pets from the system that the user has access to + responses: + "200": + description: A list of pets. + content: + application/json: + schema: + type: object + properties: + pets: + type: array + items: + $ref: "#/components/schemas/Pet" + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /pet/{petId}: + get: + tags: + - pets + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + properties: + pet: + $ref: "#/components/schemas/Pet" + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + tags: + - pets + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePetInput" + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + properties: + pet: + $ref: "#/components/schemas/Pet" + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /users: + get: + operationId: listUsers + summary: Returns all users from the system + responses: + "200": + description: A list of users. + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /store/inventory: + get: + tags: + - store + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + properties: + inventory: + type: object + additionalProperties: + type: integer + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /store/order: + post: + tags: + - store + operationId: placeOrder + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + required: + - petId + - quantity + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + properties: + order: + $ref: "#/components/schemas/Order" + "default": + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" +components: + schemas: + ErrorResponse: + type: object + properties: + message: + type: string + Pet: + required: + - id + - name + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + category: + $ref: "#/components/schemas/Category" + photoUrls: + type: array + items: + type: string + tags: + type: array + items: + $ref: "#/components/schemas/Tag" + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + Category: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + UpdatePetInput: + type: object + properties: + status: + type: string + enum: + - available + - pending + - sold + required: + - status + Order: + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + required: + - id + - petId + - quantity + User: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + email: + type: string + required: + - id + - username diff --git a/examples/tags/package.json b/examples/tags/package.json new file mode 100644 index 0000000..cb52427 --- /dev/null +++ b/examples/tags/package.json @@ -0,0 +1,24 @@ +{ + "name": "tags-example", + "private": true, + "version": "0.0.13", + "type": "module", + "scripts": { + "test": "node --test", + "gen": "openapi-typescript ./openapi.yaml --output ./gen/schema.d.ts && openapi-typescript-server ./openapi.yaml --types ./schema.d.ts --output ./gen/server.ts", + "start": "node --watch cmd/index.ts" + }, + "dependencies": { + "@types/express": "^5.0.1", + "@types/express-xml-bodyparser": "^0.3.5", + "@types/supertest": "^6.0.3", + "express": "^4.21.2", + "express-openapi-validator": "^5.5.8", + "express-xml-bodyparser": "^0.4.1", + "openapi-typescript": "^7.9.1", + "openapi-typescript-server": "0.0.13", + "openapi-typescript-server-express": "0.0.13", + "supertest": "^7.1.4", + "xml-js": "^1.6.11" + } +} diff --git a/package-lock.json b/package-lock.json index 218fcd6..d1db16a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,23 @@ "openapi-typescript-server-express": "0.0.13" } }, + "examples/tags": { + "name": "tags-example", + "version": "0.0.13", + "dependencies": { + "@types/express": "^5.0.1", + "@types/express-xml-bodyparser": "^0.3.5", + "@types/supertest": "^6.0.3", + "express": "^4.21.2", + "express-openapi-validator": "^5.5.8", + "express-xml-bodyparser": "^0.4.1", + "openapi-typescript": "^7.9.1", + "openapi-typescript-server": "0.0.13", + "openapi-typescript-server-express": "0.0.13", + "supertest": "^7.1.4", + "xml-js": "^1.6.11" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.1.1.tgz", @@ -3510,6 +3527,10 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tags-example": { + "resolved": "examples/tags", + "link": true + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/packages/openapi-typescript-server-runtime/src/schema.ts b/packages/openapi-typescript-server-runtime/src/schema.ts index 6b7645e..25bde22 100644 --- a/packages/openapi-typescript-server-runtime/src/schema.ts +++ b/packages/openapi-typescript-server-runtime/src/schema.ts @@ -29,6 +29,7 @@ export const OpenAPISpec = z.object({ summary: z.string().optional(), description: z.string().optional(), operationId: z.string().optional(), + tags: z.array(z.string()).optional(), parameters: z .array( z.object({ diff --git a/packages/openapi-typescript-server/bin/cli/index.cjs b/packages/openapi-typescript-server/bin/cli/index.cjs index b48428a..6000960 100755 --- a/packages/openapi-typescript-server/bin/cli/index.cjs +++ b/packages/openapi-typescript-server/bin/cli/index.cjs @@ -319,6 +319,7 @@ function generate(spec, types, outpath, version) { moduleSpecifier: "openapi-typescript-server-runtime" }); const operationsById = {}; + const allTags = /* @__PURE__ */ new Set(); for (const path in spec.paths) { const pathSpec = spec.paths[path]; for (const method in pathSpec) { @@ -408,13 +409,16 @@ function generate(spec, types, outpath, version) { isExported: true, type: `Promise<${responseVariantInterfaceNames.join(" | ")}>` }); + const operationTags = operation.tags || []; + operationTags.forEach((tag) => allTags.add(tag)); operationsById[operationId] = { path, method, args: argsInterface.getName(), result: resultType.getName(), summary: operation.summary, - description: operation.description + description: operation.description, + tags: operationTags }; sourceFile.addFunction({ name: `${operationId}Unimplemented`, @@ -481,6 +485,107 @@ function generate(spec, types, outpath, version) { writer.writeLine("]"); } }); + const tagValues = Array.from(allTags).map((tag) => `"${tag}"`); + const hasUntaggedOperations = Object.values(operationsById).some( + (op) => !op.tags || op.tags.length === 0 + ); + if (hasUntaggedOperations) { + tagValues.push("null"); + } + if (tagValues.length > 0) { + sourceFile.addTypeAlias({ + name: "Tag", + isExported: true, + type: tagValues.join(" | ") + }); + } + const tagToOperations = {}; + Object.entries(operationsById).forEach(([operationId, { tags }]) => { + if (!tags || tags.length === 0) { + if (!tagToOperations["null"]) { + tagToOperations["null"] = []; + } + tagToOperations["null"].push(operationId); + } else { + tags.forEach((tag) => { + if (!tagToOperations[tag]) { + tagToOperations[tag] = []; + } + tagToOperations[tag].push(operationId); + }); + } + }); + Object.entries(tagToOperations).forEach(([tag, operations]) => { + const interfaceName = tag === "null" ? "ServerForUntagged" : `ServerFor${capitalize(tag)}`; + const tagInterface = sourceFile.addInterface({ + name: interfaceName, + isExported: true, + typeParameters: [ + { name: "Req", default: "unknown" }, + { name: "Res", default: "unknown" } + ] + }); + operations.forEach((operationId) => { + const op = operationsById[operationId]; + if (op) { + tagInterface.addProperty({ + name: operationId, + type: `(args: ${op.args}) => ${op.result}`, + hasQuestionToken: false + // Required - all operations for this tag must be implemented + }); + } + }); + }); + const registerByTagFunc = sourceFile.addFunction({ + name: "registerRouteHandlersByTag", + isExported: true, + parameters: [ + { name: "tag", type: "Tag" }, + { name: "server", type: "Partial>" } + ], + typeParameters: [{ name: "Req" }, { name: "Res" }], + returnType: "Route[]", + statements: (writer) => { + writer.writeLine("const routes: Route[] = [];"); + writer.writeLine(""); + if (Object.keys(tagToOperations).length > 0) { + writer.writeLine("switch (tag) {"); + Object.entries(tagToOperations).forEach(([tag, operations]) => { + const caseValue = tag === "null" ? "null" : `"${tag}"`; + writer.writeLine(`case ${caseValue}:`); + operations.forEach((operationId) => { + const op = operationsById[operationId]; + if (op) { + writer.writeLine("routes.push({"); + writer.writeLine(`method: "${op.method}",`); + writer.writeLine(`path: "${op.path}",`); + writer.writeLine( + `handler: server.${operationId} as Route["handler"],` + ); + writer.writeLine("});"); + } + }); + writer.writeLine("break;"); + }); + writer.writeLine("}"); + } + writer.writeLine(""); + writer.writeLine("return routes;"); + } + }); + Object.entries(tagToOperations).forEach(([tag, _operations]) => { + const tagValue = tag === "null" ? "null" : `"${tag}"`; + const interfaceName = tag === "null" ? "ServerForUntagged" : `ServerFor${capitalize(tag)}`; + registerByTagFunc.addOverload({ + parameters: [ + { name: "tag", type: tagValue }, + { name: "server", type: `${interfaceName}` } + ], + typeParameters: [{ name: "Req" }, { name: "Res" }], + returnType: "Route[]" + }); + }); sourceFile.insertText( 0, `/** diff --git a/packages/openapi-typescript-server/src/cli/generate.test.ts b/packages/openapi-typescript-server/src/cli/generate.test.ts index 6a46b03..06fb0a0 100644 --- a/packages/openapi-typescript-server/src/cli/generate.test.ts +++ b/packages/openapi-typescript-server/src/cli/generate.test.ts @@ -212,6 +212,297 @@ describe("wihout operationId", () => { }); }); +describe("tags", () => { + it("generates Tag type with all tags from spec", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + tags: ["pet"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + "/users": { + get: { + operationId: "listUsers", + tags: ["user"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const tagType = sourceFile.getTypeAlias("Tag"); + assert(tagType); + assert(tagType.isExported()); + const tagTypeText = tagType.getTypeNode()?.getText() || ""; + assert.match(tagTypeText, /"pet"/); + assert.match(tagTypeText, /"user"/); + }); + + it("generates Tag type with null for untagged operations", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const tagType = sourceFile.getTypeAlias("Tag"); + assert(tagType); + assert(tagType.isExported()); + const tagTypeText = tagType.getTypeNode()?.getText() || ""; + assert.equal(tagTypeText, "null"); + }); + + it("generates Tag type with both tags and null for mixed operations", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + tags: ["pet"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + "/status": { + get: { + operationId: "getStatus", + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const tagType = sourceFile.getTypeAlias("Tag"); + assert(tagType); + const tagTypeText = tagType.getTypeNode()?.getText() || ""; + assert.match(tagTypeText, /"pet"/); + assert.match(tagTypeText, /null/); + }); +}); + +describe("registerRouteHandlersByTag", () => { + it("generates function with correct signature", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + tags: ["pet"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const func = sourceFile.getFunction("registerRouteHandlersByTag"); + assert(func); + assert(func.isExported()); + assert.equal(func.getTypeParameters().length, 2); + assert.equal(func.getParameters().length, 2); + + const tagParam = func.getParameters()[0]; + assert(tagParam); + assert.equal(tagParam.getName(), "tag"); + assert.equal(tagParam.getType().getText(), '"pet"'); + + const serverParam = func.getParameters()[1]; + assert(serverParam); + assert.equal(serverParam.getName(), "server"); + const serverTypeText = serverParam.getTypeNode()?.getText() || ""; + assert.match(serverTypeText, /Partial>/); + + const returnTypeText = func.getReturnTypeNode()?.getText() || ""; + assert.match(returnTypeText, /Route\[\]/); + }); + + it("generates switch statement for each tag", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + tags: ["pet"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + "/users": { + get: { + operationId: "listUsers", + tags: ["user"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const func = sourceFile.getFunction("registerRouteHandlersByTag"); + const bodyText = func?.getBodyText() || ""; + + assert.match(bodyText, /switch \(tag\)/); + assert.match(bodyText, /case "pet":/); + assert.match(bodyText, /case "user":/); + assert.match(bodyText, /handler: server\.listPets/); + assert.match(bodyText, /handler: server\.listUsers/); + }); + + it("groups operations by tag correctly", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/pets": { + get: { + operationId: "listPets", + tags: ["pet"], + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + post: { + operationId: "createPet", + tags: ["pet"], + responses: { + 201: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const func = sourceFile.getFunction("registerRouteHandlersByTag"); + const bodyText = func?.getBodyText() || ""; + + // Both operations should be in the same case block + const petCaseMatch = bodyText.match(/case "pet":[\s\S]*?break;/); + assert(petCaseMatch); + const petCaseBlock = petCaseMatch[0]; + assert.match(petCaseBlock, /handler: server\.listPets/); + assert.match(petCaseBlock, /handler: server\.createPet/); + }); + + it("handles untagged operations with null case", () => { + const spec = { + openapi: "3.0.0", + info: {}, + paths: { + "/status": { + get: { + operationId: "getStatus", + responses: { + 200: { + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + const sourceFile = generate(spec, "./schema.d.ts", "outdir.ts"); + const func = sourceFile.getFunction("registerRouteHandlersByTag"); + const bodyText = func?.getBodyText() || ""; + + assert.match(bodyText, /case null:/); + assert.match(bodyText, /handler: server\.getStatus/); + }); +}); + describe("request body", () => { it("writes required request body", () => { const spec = { diff --git a/packages/openapi-typescript-server/src/cli/generate.ts b/packages/openapi-typescript-server/src/cli/generate.ts index 80b1c38..c5f66e6 100644 --- a/packages/openapi-typescript-server/src/cli/generate.ts +++ b/packages/openapi-typescript-server/src/cli/generate.ts @@ -39,9 +39,12 @@ export default function generate( result: string; summary?: string; description?: string; + tags?: string[]; } > = {}; + const allTags = new Set(); + for (const path in spec.paths) { const pathSpec = spec.paths[path]; for (const method in pathSpec) { @@ -144,6 +147,10 @@ export default function generate( type: `Promise<${responseVariantInterfaceNames.join(" | ")}>`, }); + // Track tags for this operation + const operationTags = operation.tags || []; + operationTags.forEach((tag) => allTags.add(tag)); + operationsById[operationId] = { path: path, method: method, @@ -151,6 +158,7 @@ export default function generate( result: resultType.getName(), summary: operation.summary, description: operation.description, + tags: operationTags, }; sourceFile.addFunction({ @@ -227,6 +235,134 @@ export default function generate( }, }); + // Generate Tag type union + const tagValues = Array.from(allTags).map((tag) => `"${tag}"`); + // Check if there are any untagged operations + const hasUntaggedOperations = Object.values(operationsById).some( + (op) => !op.tags || op.tags.length === 0, + ); + if (hasUntaggedOperations) { + tagValues.push("null"); + } + + if (tagValues.length > 0) { + sourceFile.addTypeAlias({ + name: "Tag", + isExported: true, + type: tagValues.join(" | "), + }); + } + + // Generate interfaces for partial servers by tag + const tagToOperations: Record = {}; + + // Group operations by tag + Object.entries(operationsById).forEach(([operationId, { tags }]) => { + if (!tags || tags.length === 0) { + // Untagged operations + if (!tagToOperations["null"]) { + tagToOperations["null"] = []; + } + tagToOperations["null"].push(operationId); + } else { + // Add operation to each tag it belongs to + tags.forEach((tag) => { + if (!tagToOperations[tag]) { + tagToOperations[tag] = []; + } + tagToOperations[tag].push(operationId); + }); + } + }); + + // Generate a ServerForTag interface for each tag + Object.entries(tagToOperations).forEach(([tag, operations]) => { + const interfaceName = + tag === "null" ? "ServerForUntagged" : `ServerFor${capitalize(tag)}`; + const tagInterface = sourceFile.addInterface({ + name: interfaceName, + isExported: true, + typeParameters: [ + { name: "Req", default: "unknown" }, + { name: "Res", default: "unknown" }, + ], + }); + + // Add only operations associated with this tag + operations.forEach((operationId) => { + const op = operationsById[operationId]; + if (op) { + tagInterface.addProperty({ + name: operationId, + type: `(args: ${op.args}) => ${op.result}`, + hasQuestionToken: false, // Required - all operations for this tag must be implemented + }); + } + }); + }); + + // Generate registerRouteHandlersByTag function with overloads + const registerByTagFunc = sourceFile.addFunction({ + name: "registerRouteHandlersByTag", + isExported: true, + parameters: [ + { name: "tag", type: "Tag" }, + { name: "server", type: "Partial>" }, + ], + typeParameters: [{ name: "Req" }, { name: "Res" }], + returnType: "Route[]", + statements: (writer) => { + writer.writeLine("const routes: Route[] = [];"); + writer.writeLine(""); + + // Generate switch statement for each tag + if (Object.keys(tagToOperations).length > 0) { + writer.writeLine("switch (tag) {"); + + Object.entries(tagToOperations).forEach(([tag, operations]) => { + const caseValue = tag === "null" ? "null" : `"${tag}"`; + writer.writeLine(`case ${caseValue}:`); + + operations.forEach((operationId) => { + const op = operationsById[operationId]; + if (op) { + writer.writeLine("routes.push({"); + writer.writeLine(`method: "${op.method}",`); + writer.writeLine(`path: "${op.path}",`); + writer.writeLine( + `handler: server.${operationId} as Route["handler"],`, + ); + writer.writeLine("});"); + } + }); + + writer.writeLine("break;"); + }); + + writer.writeLine("}"); + } + + writer.writeLine(""); + writer.writeLine("return routes;"); + }, + }); + + // Add overload signatures for each tag + Object.entries(tagToOperations).forEach(([tag, _operations]) => { + const tagValue = tag === "null" ? "null" : `"${tag}"`; + const interfaceName = + tag === "null" ? "ServerForUntagged" : `ServerFor${capitalize(tag)}`; + + registerByTagFunc.addOverload({ + parameters: [ + { name: "tag", type: tagValue }, + { name: "server", type: `${interfaceName}` }, + ], + typeParameters: [{ name: "Req" }, { name: "Res" }], + returnType: "Route[]", + }); + }); + sourceFile.insertText( 0, `/**