import type { HttpHandler } from "msw"; import { HttpMethods } from "msw"; import { setupServer } from "msw/node"; import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; import type { TContentTypes } from "./contentTypes"; import { contentTypes } from "./contentTypes"; import type { THttpHandlerWithExtendedInfo } from "./httpHandler"; export interface ISwaggerOptions { handlers: THttpHandlerWithExtendedInfo[]; meta: OpenAPIV3_1.InfoObject; basePath?: string; servers?: OpenAPIV3_1.ServerObject[]; security?: OpenAPIV3_1.SecurityRequirementObject[]; queries: { name: string; value: string | number; }[]; } const defaultMeta: ISwaggerOptions["meta"] = { title: "API", description: "", version: "1.0.0", summary: "API Документация", termsOfService: "", contact: { name: "Support", url: "", email: "" }, license: { name: "MIT", url: "" }, }; const getResultInfo = async (url: string, init: RequestInit, defaultQueries: ISwaggerOptions["queries"] = []) => { let body: OpenAPIV3_1.RequestBodyObject; if (!init.body) { delete init.body; } return await fetch(url, init) .then(async (resp) => { const status = resp.status; const headers = resp?.headers ?? new Headers(); const contentType = headers.get("content-type") || contentTypes.json; const parameters: OpenAPIV3_1.ParameterObject[] = []; const getMock = async () => { switch (contentType) { case contentTypes.json: return resp.json(); case contentTypes.binary: return resp.arrayBuffer(); case contentTypes.formData: return resp.formData(); default: return resp.text(); } }; const searchParams = new URL(url).searchParams; const mock = await getMock(); defaultQueries.forEach(({ name }) => { searchParams.delete(name); }); // search params searchParams.forEach((value, key) => { const item: OpenAPIV3_1.ParameterObject = { in: "query", name: key, schema: { type: "string", }, example: value, }; parameters.push(item); }); // path params const pathParameters = url.match(/\/:([^\/]*)/g); if (pathParameters) { pathParameters.forEach((param) => { const paramName = param.replace("/:", ""); parameters.push({ in: "path", name: paramName, schema: { type: "string", }, example: `{${paramName}}`, }); }); } // headers headers.forEach((value, key) => { parameters.push({ in: "header", name: key, schema: { type: "string", }, example: value, }); }); // body if (!!init.body && (init.method === HttpMethods.POST || init.method === HttpMethods.PUT || init.method === HttpMethods.PATCH)) { try { const parsedBody = contentType === contentTypes.json && typeof init.body === "string" ? JSON.parse(init.body) : init.body; body = { required: true, content: { [contentType]: { schema: getInferSchema(parsedBody), example: parsedBody, }, }, }; } catch (e) { console.error("[getResultInfo] Failed to parse request body:", e); } } const base = url.split("?")[0]; return { status, mock, base, contentType, parameters, body, }; }) .catch((err) => { console.error(`[getSwaggerData] Failed to fetch url "${url}":`, err); return null; }); }; /** * Infer an OpenAPI schema for a given value. * @returns {OpenAPIV3_1.SchemaObject} */ const getInferSchema = (value: unknown): OpenAPIV3_1.SchemaObject => { if (value === null) { return { type: "null" as const }; } // Handle arrays if (Array.isArray(value)) { if (value.length === 0) { // Empty array return { type: "array", items: {}, // OpenAPI allows an empty schema object if the array is empty }; } // Map each item's schema const itemSchemas = value.map((item) => getInferSchema(item)); // Deduplicate schemas for homogeneous arrays const uniqueSchemas = Array.from( new Set(itemSchemas.map((schema) => JSON.stringify(schema))) ).map((schemaStr) => JSON.parse(schemaStr)); if (uniqueSchemas.length === 1) { // Homogeneous array: single schema for items return { type: "array", items: uniqueSchemas[0], // Unique schema for all items }; } else { // Heterogeneous array: multiple schemas using `oneOf` return { type: "array", items: { oneOf: uniqueSchemas, }, }; } } // Handle primitive types and objects switch (typeof value) { case "string": return { type: "string" }; case "number": return { type: Number.isInteger(value) ? "integer" : "number" }; case "boolean": return { type: "boolean" }; case "object": if (value !== null) { const properties: Record = {}; Object.entries(value).forEach(([ key, val ]) => { properties[key] = getInferSchema(val); }); return { type: "object", properties, additionalProperties: false, // Disallow additional untyped fields }; } return { type: "null" }; default: // Fallback for unsupported types return {}; } }; /** * Main function to return OpenAPI Schema based on content type and data. * @returns {OpenAPIV3_1.SchemaObject} */ const getSchema = (type: TContentTypes, data: unknown): OpenAPIV3_1.SchemaObject => { switch (type) { case contentTypes.json: return getInferSchema(data); case contentTypes.html: case contentTypes.plain: return { type: "string", }; case contentTypes.binary: return { type: "string", format: "binary", }; default: return { type: "null", }; } }; const getResponsesFromHandler = async (handler: THttpHandlerWithExtendedInfo, queries: ISwaggerOptions["queries"]): Promise<{ content: OpenAPIV3_1.ResponsesObject & OpenAPIV3.ResponsesObject; params: OpenAPIV3_1.ParameterObject[]; body?: OpenAPIV3_1.RequestBodyObject & OpenAPIV3.RequestBodyObject; }> => { const { type = contentTypes.json, path, method, mock, body, } = handler.info; const url = path?.toString() ?? ""; const init = { method: method?.toString() ?? "GET" as const, body, }; let requestBody; let params: OpenAPIV3_1.ParameterObject[] = []; const content: OpenAPIV3_1.ResponsesObject & OpenAPIV3.ResponsesObject = {}; await Promise.allSettled([ // main getResultInfo(url, init, queries), // with custom queries only for functions as mocks typeof mock === "function" ? queries.map(async ({ name, value }) => { let urlWithParams = url; try { const url = new URL(urlWithParams); url.searchParams.set(name, value.toString()); urlWithParams = url.href; } catch (e) { console.error(`[getSwaggerData] Can't append query params to url "${url}":`, e); } return getResultInfo(urlWithParams, init, queries); }) : [], ].flat()) .then((results) => results .filter((res) => res.status === "fulfilled") .map((res) => res.value)) .then((results) => { results.forEach((result) => { if (result) { const { status, mock, contentType, parameters, body, } = result; params = parameters; requestBody = body; content[status] = { description: "", content: { [type]: { example: mock, schema: getSchema(contentType, mock), } as OpenAPIV3.MediaTypeObject, }, }; } }); return Promise.resolve(); }); const result: { content: OpenAPIV3_1.ResponsesObject & OpenAPIV3.ResponsesObject; params: OpenAPIV3_1.ParameterObject[]; body?: OpenAPIV3_1.RequestBodyObject & OpenAPIV3.RequestBodyObject; } = { content, params, }; if (requestBody) { result.body = requestBody; } return Promise.resolve(result); }; /** * Генерирует OpenAPI 3.1.0 спецификацию из MSW handlers * @returns {Promise} * @example * import { getApiMock, getSwaggerData } from "@delement/ui/utils/msw"; * * const schema = await getSwaggerData({ * handlers: [getApiMock({ endpoint: "/ping", mock: { ok: true } })], * meta: { title: "API", version: "1.0.0" }, * queries: [], * }); */ export async function getSwaggerData({ handlers, meta = defaultMeta, basePath = "", servers = [], security = [], queries = [], }: ISwaggerOptions): Promise { const paths: OpenAPIV3_1.PathsObject = {}; const server = setupServer(...handlers as HttpHandler[]); server.listen(); try { const listHandlers = server.listHandlers(); if (!listHandlers.length) { throw new Error("[getSwaggerData] Can't parse mocks"); } console.debug(`[getSwaggerData] Found ${listHandlers.length} mocks:`, (Array.from(listHandlers) as unknown as THttpHandlerWithExtendedInfo[]) .filter((HttpHandler) => (HttpHandler).info !== undefined) .map((HttpHandler) => HttpHandler.info?.header)); await Promise.all(handlers.map(async (handler) => { const info = handler.info as { path?: string; method?: string; } & THttpHandlerWithExtendedInfo["info"] | undefined; if (!info?.path || !info?.method) { console.error("[getSwaggerData] Skipping handler: missing path or method information", info); return; } // Clean the path by removing any URL origin (scheme, host, port) let cleanPath = info.path; // If the path looks like an absolute URL (or protocol-relative), parse it. if (cleanPath.startsWith("http://") || cleanPath.startsWith("https://") || cleanPath.startsWith("//")) { try { const url = new URL(cleanPath.startsWith("//") ? `https:${cleanPath}` : cleanPath); cleanPath = url.pathname; } catch { // Keep original if parsing fails } } // Strip any query string that might have slipped through. cleanPath = cleanPath.split("?")[0]; // Ensure the final path starts with a single slash. const fullPath = `${basePath}/${cleanPath}` .replace(/\/+/g, "/") // collapse multiple slashes .replace(/\/$/, "") // remove trailing slash (OpenAPI prefers no trailing slash) || "/"; // fallback to root const method = info.method.toLowerCase() as Lowercase; const { content, params, body } = await getResponsesFromHandler(handler, queries); if (!paths[fullPath]) { paths[fullPath] = {}; } paths[fullPath][method] = { description: info?.description ?? "", tags: info?.tags ?? [], summary: `${method.toUpperCase()} ${fullPath}`, parameters: params, responses: content, }; if (body) { paths[fullPath][method].requestBody = body; } return Promise.resolve(); })); return { openapi: "3.1.0", info: { ...defaultMeta, ...(meta || {}), }, servers, paths, components: { schemas: { SuccessIndicator: { type: "boolean", }, Modals: { type: "array", items: { type: "object", properties: { header: { type: "string", }, content: { type: "string", }, }, }, }, Errors: { type: "array", items: { type: "string", }, }, SuccessResponse: { type: "object", properties: { isSuccess: { $ref: "#/components/schemas/SuccessIndicator", }, modals: { $ref: "#/components/schemas/Modals", }, data: { type: "object", }, }, }, ErrorResponse: { type: "object", properties: { isSuccess: { $ref: "#/components/schemas/SuccessIndicator", }, errors: { $ref: "#/components/schemas/Errors", }, }, }, }, }, security, }; } finally { server.close(); } }