UNPKG

@omer-x/next-openapi-route-handler

Version:

a Next.js plugin to generate OpenAPI documentation from route handlers

309 lines (293 loc) 9.66 kB
// src/types/error.ts var customErrorTypes = [ "PARSE_FORM_DATA", "PARSE_REQUEST_BODY", "PARSE_SEARCH_PARAMS", "PARSE_PATH_PARAMS", "UNNECESSARY_PATH_PARAMS" ]; // src/utils/openapi-components.ts function createSchemaRef(schemaName, isArray) { const refObject = { $ref: `#/components/schemas/${schemaName}` }; if (isArray) { return { type: "array", items: refObject }; } return refObject; } // src/core/createMediaType.ts function createMediaType(schema, example, examples) { const mediaTypeEntries = []; mediaTypeEntries.push(["schema", schema]); if (example) { mediaTypeEntries.push(["example", example]); } if (examples) { mediaTypeEntries.push(["examples", examples]); } return Object.fromEntries(mediaTypeEntries); } // src/core/zod-to-openapi.ts import { z } from "zod"; function convertToOpenAPI(schema, isArray) { return z.toJSONSchema(isArray ? schema.array() : schema); } // src/core/content.ts function resolveContent(source, isArray = false, isFormData = false, customExample, customExamples) { if (!source) return void 0; const schema = typeof source === "string" ? createSchemaRef(source, isArray) : convertToOpenAPI(source, isArray); return { [isFormData ? "multipart/form-data" : "application/json"]: createMediaType(schema, customExample, customExamples) }; } // src/utils/array-serialization.ts function serializeArray(value) { return value.join(","); } function deserializeArray(value) { if (!value.length) return []; return value.split(","); } // src/utils/boolean.ts function getTrueBoolean(input) { if (input === "true") return true; if (input === "false") return false; return null; } // src/utils/type-conversion.ts function convertStringToNumber(input, keys) { return keys.reduce((mutation, key) => { return { ...mutation, [key]: parseFloat(mutation[key]) }; }, input); } function convertStringToBoolean(input, keys) { return keys.reduce((mutation, key) => { return { ...mutation, [key]: getTrueBoolean(mutation[key]) }; }, input); } function convertStringToArray(input, keys) { return keys.reduce((mutation, key) => { return { ...mutation, [key]: deserializeArray(mutation[key]) }; }, input); } // src/core/zod-error-handler.ts function safeParse(schema, input) { const result = schema.safeParse(input); if (!result.success) { for (const issue of result.error.issues) { const [key] = issue.path; if (issue.code === "invalid_type" && typeof key === "string" && key in input && typeof input[key] === "string") { if (issue.expected === "number") { return safeParse(schema, convertStringToNumber(input, issue.path)); } if (issue.expected === "boolean") { return safeParse(schema, convertStringToBoolean(input, issue.path)); } if (issue.expected === "array") { return safeParse(schema, convertStringToArray(input, issue.path)); } } } throw result.error; } return result.data; } // src/core/body.ts function resolveRequestBody(source, isFormData = false, customExample, customExamples) { if (!source) return void 0; return { // description: "", // how to fill this? required: true, content: resolveContent(source, false, isFormData, customExample, customExamples) }; } async function parseRequestBody(request, method, schema, isFormData = false) { if (!schema || typeof schema === "string") return null; if (method === "GET") throw new Error("GET routes can't have request body"); if (isFormData) { const formData = await request.formData(); const body = Array.from(formData.keys()).reduce((collection, key) => ({ ...collection, [key]: formData.get(key) }), {}); try { return safeParse(schema, body); } catch (error) { if (process.env.NODE_ENV !== "production") { console.log(error.issues); } throw new Error("PARSE_FORM_DATA", { cause: error.issues }); } } try { return schema.parse(await request.json()); } catch (error) { if (error instanceof Error && error.message === "Unexpected end of JSON input") { const result = schema.safeParse({}); throw new Error("PARSE_REQUEST_BODY", { cause: result.error?.issues }); } if (process.env.NODE_ENV !== "production") { console.log(error.issues); } throw new Error("PARSE_REQUEST_BODY", { cause: error.issues }); } } // src/core/params.ts function resolveParams(kind, source) { if (!source) return []; const schema = convertToOpenAPI(source, false); if (!("properties" in schema)) throw new Error("Invalid object schema"); const entries = Object.entries(schema.properties ?? {}); return entries.map(([name, definition]) => ({ in: kind, name, required: schema.required?.includes(name) ?? false, description: definition.description, schema: definition })); } // src/core/path-params.ts function parsePathParams(source, schema) { if (!schema) return null; if (!source) throw new Error("UNNECESSARY_PATH_PARAMS"); try { return safeParse(schema, source); } catch (error) { if (process.env.NODE_ENV !== "production") { console.log(error.issues); } throw new Error("PARSE_PATH_PARAMS", { cause: error.issues }); } } // src/core/responses.ts function addBadRequest(queryParams, requestBody) { if (!queryParams && !requestBody) return void 0; return { description: "Bad Request" }; } function bundleResponses(collection) { return Object.entries(collection).reduce((bundle, [key, response]) => { if (response.content) { return { ...bundle, [key]: { description: response.description, content: resolveContent(response.content, response.isArray, false, response.example, response.examples) } }; } if (response.customContent) { return { ...bundle, [key]: { description: response.description, content: response.customContent } }; } return { ...bundle, [key]: { description: response.description } }; }, {}); } // src/core/search-params.ts function parseSearchParams(source, schema) { if (!schema) return null; const sourceKeys = Array.from(new Set(source.keys())); const params = sourceKeys.reduce((collection, key) => { const values = source.getAll(key); return { ...collection, [key]: serializeArray(values) }; }, {}); try { return safeParse(schema, params); } catch (error) { if (process.env.NODE_ENV !== "production") { console.log(error.issues); } throw new Error("PARSE_SEARCH_PARAMS", { cause: error.issues }); } } // src/core/definer.ts function defineRoute(input) { const handler = async (request, context) => { try { const { searchParams } = new URL(request.url); const nextSegmentParams = context ? await context.params : void 0; const pathParams = parsePathParams(nextSegmentParams, input.pathParams); const queryParams = parseSearchParams(searchParams, input.queryParams); const body = await parseRequestBody(request, input.method, input.requestBody, input.hasFormData); return await input.action({ pathParams, queryParams, body }, request); } catch (error) { if (input.handleErrors) { if (error instanceof Error) { const errorMessage = error.message; if (customErrorTypes.includes(errorMessage)) { return input.handleErrors(errorMessage, error.cause); } } return input.handleErrors("UNKNOWN_ERROR"); } if (error instanceof Error) { switch (error.message) { case "PARSE_FORM_DATA": case "PARSE_REQUEST_BODY": case "PARSE_SEARCH_PARAMS": return new Response(null, { status: 400 }); case "PARSE_PATH_PARAMS": return new Response(null, { status: 404 }); case "UNNECESSARY_PATH_PARAMS": { if (process.env.NODE_ENV !== "production") { console.log([ "[Next OpenAPI Route Handler]", "You tried to add pathParams to a route which doesn't have any dynamic params.", "Maybe you meant queryParams instead?" ].join(" ")); } } } } return new Response(null, { status: 500 }); } }; const parameters = [ ...resolveParams("path", input.pathParams), ...resolveParams("query", input.queryParams) ]; const responses = bundleResponses(input.responses); if (!input.responses["400"]) { const response400 = addBadRequest(input.queryParams, input.requestBody); if (response400) { responses["400"] = response400; } } if (!input.responses["500"]) { responses["500"] = { description: "Internal Server Error" }; } handler.apiData = { operationId: input.operationId, summary: input.summary, description: input.description, tags: input.tags, parameters: parameters.length ? parameters : void 0, requestBody: resolveRequestBody(input.requestBody, input.hasFormData, input.requestBodyExample, input.requestBodyExamples), responses, security: input.security }; if (input.middleware) { const alteredHandler = input.middleware(handler); alteredHandler.apiData = handler.apiData; return { [input.method]: alteredHandler }; } return { [input.method]: handler }; } var definer_default = defineRoute; // src/index.ts var index_default = definer_default; export { index_default as default, definer_default as defineRoute };