UNPKG

@danstackme/apity

Version:

Type-safe API client generator for React applications with file-based routing and runtime validation

573 lines (565 loc) 19 kB
#!/usr/bin/env node import { Command } from 'commander'; import { readFile, mkdir, writeFile } from 'fs/promises'; import swagger2openapi from 'swagger2openapi'; function parseYaml(yamlString) { try { const lines = yamlString.split("\n"); const result = {}; let currentObject = result; const stack = [result]; let currentIndent = 0; let currentArray = null; let arrayIndent = -1; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim() === "" || line.trim().startsWith("#")) continue; const indent = line.search(/\S/); if (indent < 0) continue; if (line.trim().startsWith("-")) { const arrayItemMatch = line.trim().match(/^-\s*(.*)$/); if (!arrayItemMatch) continue; const value2 = arrayItemMatch[1].trim(); if (arrayIndent === -1 || indent !== arrayIndent) { let j = i - 1; while (j >= 0) { const prevLine = lines[j].trim(); if (prevLine === "" || prevLine.startsWith("#")) { j--; continue; } const keyMatch = prevLine.match(/^([\w-]+):(?:\s*)?$/); if (keyMatch) { const arrayKey = keyMatch[1]; currentObject[arrayKey] = []; currentArray = currentObject[arrayKey]; arrayIndent = indent; break; } j--; } } if (currentArray) { if (value2 === "") { const newObj = {}; currentArray.push(newObj); stack.push(currentObject); currentObject = newObj; currentIndent = indent; currentArray = null; } else { let parsedValue = value2; if (parsedValue === "true") parsedValue = true; else if (parsedValue === "false") parsedValue = false; else if (!isNaN(Number(parsedValue)) && parsedValue !== "") parsedValue = Number(parsedValue); else if (parsedValue.startsWith("'") && parsedValue.endsWith("'")) parsedValue = parsedValue.slice(1, -1); else if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) parsedValue = parsedValue.slice(1, -1); currentArray.push(parsedValue); } } continue; } else { if (indent <= arrayIndent) { arrayIndent = -1; currentArray = null; } } const match = line.trim().match(/^([\w-]+):(?:\s(.+))?$/); if (!match) continue; const [_, key, value] = match; if (value === void 0 || value.trim() === "") { currentObject[key] = {}; if (indent > currentIndent) { stack.push(currentObject); currentObject = currentObject[key]; currentIndent = indent; } else if (indent < currentIndent) { while (stack.length > 1 && indent <= currentIndent) { stack.pop(); currentObject = stack[stack.length - 1]; currentIndent -= 2; } currentObject[key] = {}; stack.push(currentObject); currentObject = currentObject[key]; } else { currentObject = stack[stack.length - 1]; currentObject[key] = {}; currentObject = currentObject[key]; } } else { let parsedValue = value.trim(); if (parsedValue === "true") parsedValue = true; else if (parsedValue === "false") parsedValue = false; else if (!isNaN(Number(parsedValue)) && parsedValue !== "") parsedValue = Number(parsedValue); else if (parsedValue.startsWith("'") && parsedValue.endsWith("'")) parsedValue = parsedValue.slice(1, -1); else if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) parsedValue = parsedValue.slice(1, -1); currentObject[key] = parsedValue; } } return result; } catch (error) { console.error("Error parsing YAML:", error); throw error; } } async function convertToOpenAPI3(doc) { if (doc.openapi && doc.openapi.startsWith("3.")) { return doc; } const result = await swagger2openapi.convert(doc, {}); return result.openapi; } function isReferenceObject(obj) { return obj !== null && obj !== void 0 && "$ref" in obj; } function getRefName(ref) { if (!ref) return ""; const parts = ref.split("/"); return parts[parts.length - 1]; } function resolveRef(ref, spec) { if (!ref.startsWith("#/")) return void 0; const parts = ref.substring(2).split("/"); let current = spec; for (const part of parts) { if (!current[part]) return void 0; current = current[part]; } return current; } function processSchemaDefinitions(spec) { const schemas = /* @__PURE__ */ new Map(); if (spec.components?.schemas) { for (const [name, schema] of Object.entries(spec.components.schemas)) { if (!isReferenceObject(schema)) { schemas.set(name, schema); } } } return schemas; } async function generateRoutes(spec, options) { const routes = /* @__PURE__ */ new Map(); const schemas = processSchemaDefinitions(spec); for (const [path, pathItem] of Object.entries(spec.paths || {})) { if (!pathItem) continue; const routePath = path.replace(/{([^}]+)}/g, "[$1]"); const methods = {}; for (const [method, operation] of Object.entries(pathItem)) { if (method === "parameters" || !operation || typeof operation === "string" || Array.isArray(operation)) continue; const upperMethod = method.toUpperCase(); const response = operation.responses?.[200]; const requestBody = operation.requestBody; methods[upperMethod] = { method: upperMethod, response: processResponseSchema(response, spec), body: processRequestBodySchema(requestBody, spec), query: {}, params: {} }; const queryParams = operation.parameters?.filter((p) => { if (!p || typeof p === "string") return false; return "in" in p && p.in === "query"; }) || []; if (queryParams.length > 0) { methods[upperMethod].query = { type: "object", properties: Object.fromEntries( queryParams.map((p) => { if (typeof p === "string" || isReferenceObject(p)) return ["", {}]; return ["name" in p ? p.name : "", "schema" in p ? p.schema : {}]; }) ) }; } const pathParams = operation.parameters?.filter((p) => { if (!p || typeof p === "string") return false; return "in" in p && p.in === "path"; }) || []; if (pathParams.length > 0) { methods[upperMethod].params = { type: "object", properties: Object.fromEntries( pathParams.map((p) => [ "name" in p ? p.name : "", "schema" in p ? p.schema : {} ]) ) }; } } routes.set(routePath, methods); } await generateSingleFile( routes, schemas, options.outDir || "src", spec.servers?.[0]?.url || "" ); } function processResponseSchema(response, spec) { if (!response) return {}; if (isReferenceObject(response)) { const resolvedSchema = resolveRef(response.$ref, spec); return resolvedSchema || {}; } const contentSchema = response.content?.["application/json"]?.schema; if (!contentSchema) return {}; if (isReferenceObject(contentSchema)) { const resolvedSchema = resolveRef(contentSchema.$ref, spec); return resolvedSchema || {}; } if (contentSchema.allOf) { return processAllOf(contentSchema, spec); } if (contentSchema.oneOf) { return contentSchema; } return contentSchema; } function processRequestBodySchema(requestBody, spec) { if (!requestBody) return {}; if (isReferenceObject(requestBody)) { const resolvedSchema = resolveRef(requestBody.$ref, spec); return resolvedSchema || {}; } const contentSchema = requestBody.content?.["application/json"]?.schema; if (!contentSchema) return {}; if (isReferenceObject(contentSchema)) { const resolvedSchema = resolveRef(contentSchema.$ref, spec); return resolvedSchema || {}; } if (contentSchema.allOf) { return processAllOf(contentSchema, spec); } if (contentSchema.oneOf) { return contentSchema; } return contentSchema; } function processAllOf(schema, spec) { if (!schema.allOf || !Array.isArray(schema.allOf) || schema.allOf.length === 0) { return schema; } const { allOf, ...baseSchema } = schema; const mergedSchema = { ...baseSchema }; for (const subSchema of allOf) { let resolvedSubSchema; if (isReferenceObject(subSchema)) { const resolved = resolveRef(subSchema.$ref, spec); if (!resolved) continue; resolvedSubSchema = resolved; } else { resolvedSubSchema = subSchema; } if (resolvedSubSchema.allOf) { resolvedSubSchema = processAllOf(resolvedSubSchema, spec); } if (resolvedSubSchema.properties) { mergedSchema.properties = { ...mergedSchema.properties, ...resolvedSubSchema.properties }; } if (resolvedSubSchema.required && resolvedSubSchema.required.length > 0) { mergedSchema.required = [ ...mergedSchema.required || [], ...resolvedSubSchema.required ]; } if (resolvedSubSchema.type && !mergedSchema.type) { mergedSchema.type = resolvedSubSchema.type; } if (resolvedSubSchema.format && !mergedSchema.format) { mergedSchema.format = resolvedSubSchema.format; } if (resolvedSubSchema.description && !mergedSchema.description) { mergedSchema.description = resolvedSubSchema.description; } } return mergedSchema; } function convertToZodSchema(schema, _isRequired = false, schemas = /* @__PURE__ */ new Map()) { if (!schema) return "z.void()"; if (isReferenceObject(schema)) { const refName = getRefName(schema.$ref); if (schemas.has(refName)) { return `z.lazy(() => ${refName}Schema)`; } return "z.unknown()"; } if (schema.allOf) { const allOfSchemas = schema.allOf.map( (subSchema) => convertToZodSchema(subSchema, _isRequired, schemas) ); if (allOfSchemas.length === 1) { return allOfSchemas[0]; } let result = `z.intersection(${allOfSchemas[0]}, ${allOfSchemas[1]})`; for (let i = 2; i < allOfSchemas.length; i++) { result = `z.intersection(${result}, ${allOfSchemas[i]})`; } return result; } if (schema.oneOf && schema.oneOf.length > 0) { const oneOfSchemas = schema.oneOf.map( (subSchema) => convertToZodSchema(subSchema, _isRequired, schemas) ); if (oneOfSchemas.length === 1) { return oneOfSchemas[0]; } return oneOfSchemas.reduce((acc, current, index) => { if (index === 0) return current; return `${acc}.or(${current})`; }, ""); } let numberType; let itemType; let properties; let zodSchema; if (Array.isArray(schema.type)) { if (schema.type.includes("null")) { const nonNullTypes = schema.type.filter((t) => t !== "null"); if (nonNullTypes.length === 1) { const nonNullSchema = { ...schema, type: nonNullTypes[0] }; return `${convertToZodSchema(nonNullSchema, _isRequired, schemas)}.nullable()`; } } schema.type = "string"; } switch (schema.type) { case "string": if (schema.enum) { zodSchema = `z.enum([${schema.enum.map((e) => `'${e}'`).join(", ")}])`; } else if (schema.format === "date-time") { zodSchema = "z.string().datetime()"; } else if (schema.format === "email") { zodSchema = "z.string().email()"; } else { zodSchema = "z.string()"; if (schema.minLength !== void 0) { zodSchema += `.min(${schema.minLength})`; } if (schema.maxLength !== void 0) { zodSchema += `.max(${schema.maxLength})`; } } break; case "number": case "integer": numberType = "z.number()"; if (schema.minimum !== void 0) { numberType += `.min(${schema.minimum})`; } if (schema.maximum !== void 0) { numberType += `.max(${schema.maximum})`; } zodSchema = numberType; break; case "boolean": zodSchema = "z.boolean()"; break; case "array": if (schema.items) { itemType = convertToZodSchema(schema.items, true, schemas); zodSchema = `z.array(${itemType})`; } else { zodSchema = "z.array(z.unknown())"; } break; case "object": if (!schema.properties) return "z.object({})"; properties = Object.entries(schema.properties).map(([key, prop]) => { const propIsRequired = schema.required?.includes(key); const propType = convertToZodSchema(prop, propIsRequired, schemas); return `${key}: ${propIsRequired ? propType : `${propType}.optional()`}`; }); zodSchema = `z.object({ ${properties.join(",\n ")} })`; break; default: zodSchema = "z.unknown()"; } if (schema.nullable) { zodSchema += ".nullable()"; } if (schema.description) { const escapedDescription = schema.description.replace(/[`'\\]/g, "\\$&"); zodSchema += `.describe(\`${escapedDescription}\`)`; } return zodSchema; } async function generateSingleFile(routes, schemas, outDir, baseUrl) { await mkdir(outDir, { recursive: true }); const getRoutes = /* @__PURE__ */ new Map(); const otherRoutes = /* @__PURE__ */ new Map(); for (const [path, methods] of routes) { for (const [method, schema] of Object.entries(methods)) { if (method === "GET") { if (!getRoutes.has(path)) { getRoutes.set(path, /* @__PURE__ */ new Map()); } getRoutes.get(path)?.set(method, schema); } else { if (!otherRoutes.has(path)) { otherRoutes.set(path, /* @__PURE__ */ new Map()); } otherRoutes.get(path)?.set(method, schema); } } } let content = `import { createApi, createApiEndpoint } from '@danstackme/apity'; `; content += `import { z } from 'zod'; `; if (schemas.size > 0) { content += `// Schema definitions `; for (const [name, schema] of schemas) { content += `export const ${name}Schema = ${convertToZodSchema(schema, true, schemas)}; `; } } const fetchEndpointNames = /* @__PURE__ */ new Map(); for (const [path, methods] of getRoutes) { const endpointNames = []; for (const [method, schema] of methods) { const pathParams = Object.keys(schema.params.properties || {}); const endpointName = `${method}_${path.replace(/\//g, "").replace(/\[.*?\]/g, "")}${pathParams.length > 0 ? `_${pathParams.join("_")}` : ""}`; endpointNames.push(endpointName); content += `const ${endpointName} = createApiEndpoint({ `; content += ` method: '${method}', `; if (Object.keys(schema.response).length > 0) { if (isReferenceObject(schema.response)) { const refName = getRefName(schema.response.$ref); content += ` response: ${refName}Schema, `; } else { content += ` response: ${convertToZodSchema(schema.response, true, schemas)}, `; } } else { content += ` response: z.void(), `; } if (Object.keys(schema.query).length > 0) { content += ` query: ${convertToZodSchema(schema.query, false, schemas)}, `; } content += `}); `; } if (endpointNames.length > 0) { fetchEndpointNames.set(path, endpointNames); } } const mutateEndpointNames = /* @__PURE__ */ new Map(); for (const [path, methods] of otherRoutes) { const endpointNames = []; for (const [method, schema] of methods) { const pathParams = Object.keys(schema.params.properties || {}); const endpointName = `${method}_${path.replace(/\//g, "").replace(/\[.*?\]/g, "")}${pathParams.length > 0 ? `_${pathParams.join("_")}` : ""}`; endpointNames.push(endpointName); content += `const ${endpointName} = createApiEndpoint({ `; content += ` method: '${method}', `; if (Object.keys(schema.response).length > 0) { if (isReferenceObject(schema.response)) { const refName = getRefName(schema.response.$ref); content += ` response: ${refName}Schema, `; } else { content += ` response: ${convertToZodSchema(schema.response, true, schemas)}, `; } } else { content += ` response: z.void(), `; } if (Object.keys(schema.body).length > 0) { if (isReferenceObject(schema.body)) { const refName = getRefName(schema.body.$ref); content += ` body: ${refName}Schema, `; } else { content += ` body: ${convertToZodSchema(schema.body, false, schemas)}, `; } } if (Object.keys(schema.query).length > 0) { content += ` query: ${convertToZodSchema(schema.query, false, schemas)}, `; } content += `}); `; } if (endpointNames.length > 0) { mutateEndpointNames.set(path, endpointNames); } } content += `export const fetchEndpoints = { `; for (const [path, endpointNames] of fetchEndpointNames) { content += ` '${path}': [${endpointNames.join(", ")}], `; } content += `} as const; `; content += `export const mutateEndpoints = { `; for (const [path, endpointNames] of mutateEndpointNames) { content += ` '${path}': [${endpointNames.join(", ")}], `; } content += `} as const; `; content += `export const api = createApi({ `; content += ` baseUrl: '${baseUrl}', `; content += ` fetchEndpoints, `; content += ` mutateEndpoints, `; content += `}); `; await writeFile(`${outDir}/endpoints.ts`, content); } async function main() { const program = new Command(); program.name("import-openapi").description("Import OpenAPI/Swagger specification and generate API routes").argument("<file>", "OpenAPI/Swagger specification file (JSON or YAML)").option("-d, --outDir <directory>", "Output directory", void 0); program.parse(); const options = program.opts(); const [file] = program.args; try { const content = await readFile(file, "utf-8"); const doc = file.endsWith(".yaml") || file.endsWith(".yml") ? parseYaml(content) : JSON.parse(content); const openapi = await convertToOpenAPI3(doc); await generateRoutes(openapi, { outDir: options.outDir }); console.log("Successfully generated API routes!"); } catch (error) { console.error("Error:", error); process.exit(1); } } if (process.env.NODE_ENV !== "test") { main(); } export { convertToOpenAPI3, generateRoutes, generateSingleFile, getRefName, isReferenceObject, processSchemaDefinitions, resolveRef }; //# sourceMappingURL=import-openapi.js.map //# sourceMappingURL=import-openapi.js.map