Wire full OpenAPI client and TypeScript app workflow

This commit is contained in:
2026-02-24 22:19:40 +01:00
parent e54b9d2ce8
commit d73ae16e76
10 changed files with 2692 additions and 223 deletions

View File

@@ -47,7 +47,7 @@ function schemaRefName(schema) {
return schema.$ref.substring(refPrefix.length);
}
function toTypeScriptType(schema, components, forProperty = false) {
function toTypeScriptType(schema, forProperty = false) {
if (!schema || typeof schema !== "object") {
return "unknown";
}
@@ -63,7 +63,7 @@ function toTypeScriptType(schema, components, forProperty = false) {
}
if (schema.type === "array") {
const itemType = toTypeScriptType(schema.items, components);
const itemType = toTypeScriptType(schema.items, true);
return `Array<${itemType}>`;
}
@@ -75,7 +75,7 @@ function toTypeScriptType(schema, components, forProperty = false) {
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = propertyEntries.map(([name, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, components, true);
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(name) ? "" : "?";
return `${name}${optional}: ${typeName};`;
});
@@ -88,20 +88,20 @@ function toTypeScriptType(schema, components, forProperty = false) {
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
return schema.oneOf.map((option) => toTypeScriptType(option, components, true)).join(" | ");
return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | ");
return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
return "unknown";
}
function schemaToInterface(name, schema, components) {
function schemaToInterface(name, schema) {
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, components, true);
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(propertyName) ? "" : "?";
return ` ${propertyName}${optional}: ${typeName};`;
});
@@ -113,26 +113,98 @@ function schemaToInterface(name, schema, components) {
return `export interface ${name} {\n${fields.join("\n")}\n}`;
}
function collectOperations(document, components) {
const operations = [];
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : [];
function collectParameters(pathItem, operation) {
const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
const deduped = [];
const keys = new Set();
for (const parameter of rawParameters) {
if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") {
continue;
}
const key = `${parameter.in}:${parameter.name}`;
if (keys.has(key)) {
continue;
}
keys.add(key);
const inferredType = mapSimpleType(parameter.schema) ?? "string";
deduped.push({
name: parameter.name,
location: parameter.in,
required: parameter.required === true,
type: inferredType
});
}
return deduped;
}
function resolveRequestBodyType(operation) {
const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
if (!bodySchema) {
return null;
}
return {
type: toTypeScriptType(bodySchema, true),
required: operation.requestBody.required === true
};
}
function resolveResponse(operation) {
const responses = operation.responses ?? {};
const successCodes = Object.keys(responses)
.filter((code) => code.startsWith("2"))
.sort();
if (successCodes.length === 0) {
return { type: "void", expectsJson: false };
}
for (const code of successCodes) {
const jsonSchema = responses[code]?.content?.["application/json"]?.schema;
if (jsonSchema) {
return { type: toTypeScriptType(jsonSchema), expectsJson: true };
}
if (code === "204") {
return { type: "void", expectsJson: false };
}
}
return { type: "void", expectsJson: false };
}
function collectOperations(document) {
const operations = [];
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem ?? {})) {
if (operation === null || typeof operation !== "object") {
if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") {
continue;
}
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
if (operation.operationId.length === 0) {
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
}
const parameters = collectParameters(pathItem, operation);
const pathParameters = parameters.filter((parameter) => parameter.location === "path");
const queryParameters = parameters.filter((parameter) => parameter.location === "query");
const requestBody = resolveRequestBodyType(operation);
const response = resolveResponse(operation);
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path: pathKey,
parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]),
responseType: resolveResponseType(operation, components)
pathParameters,
queryParameters,
requestBody,
response
});
}
}
@@ -144,55 +216,8 @@ function collectOperations(document, components) {
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
}
function collectPathParameters(parameters) {
const seen = new Set();
const result = [];
for (const parameter of parameters) {
if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") {
continue;
}
if (seen.has(parameter.name)) {
continue;
}
seen.add(parameter.name);
const simpleType = mapSimpleType(parameter.schema);
result.push({
name: parameter.name,
type: simpleType ?? "string"
});
}
return result;
}
function resolveResponseType(operation, components) {
const responses = operation.responses ?? {};
const successCodes = Object.keys(responses)
.filter((code) => code.startsWith("2"))
.sort();
if (successCodes.length === 0) {
return "void";
}
for (const code of successCodes) {
const content = responses[code]?.content?.["application/json"];
const schema = content?.schema;
if (schema) {
return toTypeScriptType(schema, components);
}
}
return "void";
}
function buildSchemaTypes(document) {
const components = document.components ?? {};
const schemas = components.schemas ?? {};
const schemas = document.components?.schemas ?? {};
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
const declarations = schemaEntries.map(([name, schema]) => {
@@ -200,21 +225,62 @@ function buildSchemaTypes(document) {
return `export type ${name} = unknown;`;
}
return schemaToInterface(name, schema, components);
return schemaToInterface(name, schema);
});
return declarations.join("\n\n");
}
function buildFunctionSource(operation) {
const pathParameters = operation.pathParameters;
const queryParameters = operation.queryParameters;
const body = operation.requestBody;
const parts = [];
if (pathParameters.length > 0) {
parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`));
}
if (body) {
const optional = body.required ? "" : "?";
parts.push(`body${optional}: ${body.type}`);
}
if (queryParameters.length > 0) {
const queryType = queryParameters
.map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`)
.join(" ");
parts.push(`query: { ${queryType} }`);
}
const signature = parts.join(", ");
const pathParameterAssignment = pathParameters.length === 0
? "{}"
: `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
const bodyArgument = body ? "body" : "undefined";
const queryArgument = queryParameters.length > 0 ? "query" : "undefined";
return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`;
}
function buildClientSource(operations, schemaTypes) {
const operationEntries = operations
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`)
.join(",\n");
const helper = `
type ApiOperation = {
method: string;
path: string;
expectsJson: boolean;
};
type RequestOptions = {
pathParams?: Record<string, string | number | boolean>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
export const apiOperations = {
@@ -223,17 +289,42 @@ ${operationEntries}
`.trim();
const sendFunction = `
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
let resolvedPath = operation.path;
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`;
}
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers: Record<string, string> = {
"Accept": "application/json"
};
let body: string | undefined;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers: {
"Accept": "application/json"
}
headers,
body
});
if (!response.ok) {
@@ -245,31 +336,22 @@ async function send<TResult>(operation: ApiOperation, pathParams: Record<string,
throw new Error(\`Request failed with status \${response.status}\`);
}
if (!operation.expectsJson) {
return undefined as TResult;
}
return response.json() as Promise<TResult>;
}
`.trim();
const exports = operations
.map((operation) => {
const params = operation.parameters;
const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", ");
const pathParams = params.length === 0
? "{}"
: `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
const responseType = operation.responseType ?? "unknown";
return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`;
})
.join("\n\n");
const exports = operations.map(buildFunctionSource).join("\n\n");
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
}
const openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText);
const components = document.components ?? {};
const operations = collectOperations(document, components);
const operations = collectOperations(document);
const schemaTypes = buildSchemaTypes(document);
const clientSource = buildClientSource(operations, schemaTypes);