diff --git a/.gitignore b/.gitignore index 71392f8..301c003 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ jspm_packages /.yarn_home /dist /deno_dist -.idea \ No newline at end of file +.idea +.vscode \ No newline at end of file diff --git a/src/openapi/createOpenApiGenerator.ts b/src/openapi/createOpenApiGenerator.ts index 8fd448e..1bad8d6 100644 --- a/src/openapi/createOpenApiGenerator.ts +++ b/src/openapi/createOpenApiGenerator.ts @@ -4,135 +4,147 @@ import { z } from "zod"; import type { ZodFirstPartyTypeKind, ZodRawShape } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -type TypedTag = { - name: T; -}; - type CreateOpenApiGenerator = < - SharedRoutes extends Record, - TagName extends string, + SharedRoutesByTag extends { [T: string]: Record }, >( - sharedRoutes: SharedRoutes, - openApiRootDoc: Omit & { - tags?: TypedTag[]; - }, + sharedRoutesByTag: SharedRoutesByTag, + openApiRootDoc: Omit, ) => ( extraDataByRoute: Partial< { - [R in keyof SharedRoutes]: Omit & { - tags?: TagName[]; - extraDocs?: { - body?: OpenAPI.BaseSchemaObject & { - properties?: Partial< - Record< - keyof z.infer, - OpenAPI.BaseSchemaObject - > - >; + [Tag in keyof SharedRoutesByTag]: Partial< + { + [R in keyof SharedRoutesByTag[Tag]]: Omit< + OpenAPI.PathItemObject, + OpenAPI.HttpMethods + > & { + extraDocs?: { + body?: OpenAPI.BaseSchemaObject & { + properties?: Partial< + Record< + keyof z.infer, + OpenAPI.BaseSchemaObject + > + >; + }; + queryParams?: Partial< + Record< + keyof z.infer, + Partial + > + >; + headerParams?: Partial< + Record< + keyof z.infer, + Partial + > + >; + + responseBody?: OpenAPI.BaseSchemaObject & { + properties?: Partial< + Record< + keyof z.infer, + OpenAPI.BaseSchemaObject + > + >; + }; + + successStatusCode?: number; + responses?: OpenAPI.ResponsesObject; + }; }; - queryParams?: Partial< - Record< - keyof z.infer, - Partial - > - >; - headerParams?: Partial< - Record< - keyof z.infer, - Partial - > - >; - - responseBody?: OpenAPI.BaseSchemaObject & { - properties?: Partial< - Record< - keyof z.infer, - OpenAPI.BaseSchemaObject - > - >; - }; - - successStatusCode?: number; - responses?: OpenAPI.ResponsesObject; - }; - }; + } + >; } >, ) => OpenAPI.Document; export const createOpenApiGenerator: CreateOpenApiGenerator = - (sharedRoutes, openApiRootDoc) => (extraDataByRoute) => ({ + (sharedRoutesByTag, openApiRootDoc) => (extraDataByRoute) => ({ ...openApiRootDoc, - paths: keys(sharedRoutes).reduce((acc, routeName) => { - const route = sharedRoutes[routeName]; - const { extraDocs, ...extraDataForRoute } = extraDataByRoute[routeName] ?? {}; - const responseSchema = zodToOpenApi(route.responseBodySchema); - const responseSchemaType: - | OpenAPI.NonArraySchemaObjectType - | OpenAPI.ArraySchemaObjectType - | undefined = (responseSchema as any).type; - - const { formattedUrl, pathParams } = extractFromUrl(route.url); - - const parameters = [ - ...(pathParams.length > 0 ? pathParams : []), - ...(!isShapeObjectEmpty(route.queryParamsSchema) - ? zodObjectToParameters( - route.queryParamsSchema, - "query", - extraDocs?.queryParams, - ) - : []), - ...(!isShapeObjectEmpty(route.headersSchema) - ? zodObjectToParameters(route.headersSchema, "header", extraDocs?.headerParams) - : []), - ]; + paths: keys(sharedRoutesByTag).reduce((rootAcc, tag) => { + const sharedRoutes = sharedRoutesByTag[tag]; return { - ...acc, - [formattedUrl]: { - ...acc[formattedUrl], - [route.method]: { - ...extraDataForRoute, - ...(parameters.length > 0 && { - parameters, - }), - - ...(!isShapeObjectEmpty(route.requestBodySchema) && { - requestBody: { - required: true, - content: { - "application/json": { - schema: { - ...extraDocs?.body, - ...zodToOpenApi(route.requestBodySchema), - }, - }, - }, - }, - }), - - responses: { - [extraDocs?.successStatusCode ?? 200]: { - description: - responseSchemaType !== undefined - ? "Success" - : "Success, with void response", - ...(responseSchemaType !== undefined && { - content: { - "application/json": { - schema: responseSchema, + ...rootAcc, + ...keys(sharedRoutes).reduce((acc, routeName) => { + const route = sharedRoutes[routeName]; + const { extraDocs, ...extraDataForRoute } = + extraDataByRoute[tag]?.[routeName] ?? {}; + const responseSchema = zodToOpenApi(route.responseBodySchema); + const responseSchemaType: + | OpenAPI.NonArraySchemaObjectType + | OpenAPI.ArraySchemaObjectType + | undefined = (responseSchema as any).type; + + const { formattedUrl, pathParams } = extractFromUrl(route.url); + + const parameters = [ + ...(pathParams.length > 0 ? pathParams : []), + ...(!isShapeObjectEmpty(route.queryParamsSchema) + ? zodObjectToParameters( + route.queryParamsSchema, + "query", + extraDocs?.queryParams, + ) + : []), + ...(!isShapeObjectEmpty(route.headersSchema) + ? zodObjectToParameters( + route.headersSchema, + "header", + extraDocs?.headerParams, + ) + : []), + ]; + + return { + ...acc, + [formattedUrl]: { + ...acc[formattedUrl], + [route.method]: { + ...extraDataForRoute, + tags: [tag], + ...(parameters.length > 0 && { + parameters, + }), + + ...(!isShapeObjectEmpty(route.requestBodySchema) && { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + ...extraDocs?.body, + ...zodToOpenApi(route.requestBodySchema), + }, + }, }, }, }), - ...extraDocs?.responseBody, + + responses: { + [extraDocs?.successStatusCode ?? 200]: { + description: + responseSchemaType !== undefined + ? "Success" + : "Success, with void response", + ...(responseSchemaType !== undefined && { + content: { + "application/json": { + schema: responseSchema, + }, + }, + }), + ...extraDocs?.responseBody, + }, + ...extraDocs?.responses, + }, }, - ...extraDocs?.responses, }, - }, - }, + }; + }, {} as any), }; - }, {} as any), + }, {}), }); type ParamKind = "path" | "query" | "header"; diff --git a/test/createOpenApiGenerator.test.ts b/test/createOpenApiGenerator.test.ts new file mode 100644 index 0000000..eb89147 --- /dev/null +++ b/test/createOpenApiGenerator.test.ts @@ -0,0 +1,183 @@ +import { OpenAPIV3 } from "openapi-types"; +import { defineRoute, defineRoutes } from "../src"; +import { z } from "zod"; +import { createOpenApiGenerator } from "../src/openapi"; +import { it, expect } from "vitest"; + +const bookSchema = z.object({ title: z.string(), author: z.string() }); +const withAuthorizationSchema = z.object({ authorization: z.string() }); + +const routes = defineRoutes({ + getAllBooks: defineRoute({ + url: "/books", + method: "get", + queryParamsSchema: z.object({ + max: z.number().optional(), + truc: z.string(), + }), + responseBodySchema: z.array(bookSchema), + }), + getByTitle: defineRoute({ + url: "/books/:title", + method: "get", + responseBodySchema: bookSchema, + }), + addBook: defineRoute({ + url: "/books", + method: "post", + requestBodySchema: bookSchema, + headersSchema: withAuthorizationSchema, + }), +}); + +const rootInfo = { + info: { + title: "My book API", + description: "My test openApi description", + version: "1", + }, + servers: [{ url: "/api" }], + openapi: "3.0.0", +}; + +const generateOpenApi = createOpenApiGenerator({ Books: routes }, rootInfo); + +const openApiJSON = generateOpenApi({ + Books: { + addBook: { + summary: "To add a book", + description: "To add a book", + extraDocs: { + body: { + title: "Book", + description: "Represents a book", + properties: { + title: { example: "Harry Potter" }, + author: { example: "JK Rowlings" }, + }, + }, + }, + }, + getAllBooks: { + summary: "To get all books", + description: "To get all books", + extraDocs: { + queryParams: { + max: { example: 15 }, + truc: { example: "machin..." }, + }, + }, + }, + }, +}); + +const bookJsonSchema = { + additionalProperties: false, + type: "object" as const, + properties: { + title: { type: "string" as const }, + author: { type: "string" as const }, + }, + required: ["title", "author"], +}; + +const expected: OpenAPIV3.Document = { + ...rootInfo, + paths: { + "/books/{title}": { + get: { + tags: ["Books"], + parameters: [ + { + name: "title", + required: true, + schema: { type: "string" }, + in: "path", + }, + ], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: bookJsonSchema, + }, + }, + }, + }, + }, + }, + "/books": { + get: { + summary: "To get all books", + description: "To get all books", + tags: ["Books"], + parameters: [ + { + example: 15, + name: "max", + required: false, + schema: { type: "number" }, + in: "query", + }, + { + example: "machin...", + in: "query", + name: "truc", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "array", + items: bookJsonSchema, + }, + }, + }, + }, + }, + }, + post: { + summary: "To add a book", + description: "To add a book", + tags: ["Books"], + parameters: [ + { + in: "header", + name: "authorization", + required: true, + schema: { + type: "string", + }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + title: "Book", + description: "Represents a book", + ...bookJsonSchema, + }, + }, + }, + required: true, + }, + responses: { + "200": { + description: "Success, with void response", + }, + }, + }, + }, + }, +}; + +it("has the expected shape", () => { + expect(openApiJSON).toEqual(expected); +});