Skip to content

Commit 4843a03

Browse files
authored
Merge pull request #5 from Tam2/fix/no-body-command-schema
fix: handle no-body / optional-body endpoints and empty 2xx responses
2 parents 7bf9d39 + 934fa8e commit 4843a03

9 files changed

Lines changed: 221489 additions & 45 deletions

File tree

src/generator.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,58 @@ function extractPathBlock(content: string, startIndex: number): string | null {
5454
return content.slice(startIndex, pos);
5555
}
5656

57+
/**
58+
* Extract the `{...}` block for a named member of an interface, e.g. an entry of `operations`.
59+
* Handles both bare identifier keys (`updatePet: {`) and quoted keys (`"activity/star-repo": {`) —
60+
* openapi-typescript quotes operationIds that contain non-identifier characters (`/`, `-`, `.`).
61+
*/
62+
function extractNamedBlock(content: string, name: string): string | null {
63+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64+
const re = new RegExp(`(?:^|[\\s{;,])["']?${escaped}["']?\\s*:\\s*\\{`, 'm');
65+
const m = re.exec(content);
66+
if (!m) return null;
67+
const braceIdx = content.indexOf('{', m.index);
68+
let depth = 1;
69+
let pos = braceIdx + 1;
70+
while (pos < content.length && depth > 0) {
71+
if (content[pos] === '{') depth++;
72+
else if (content[pos] === '}') depth--;
73+
pos++;
74+
}
75+
return content.slice(braceIdx, pos);
76+
}
77+
78+
/** A block has a request body if `requestBody` maps to an object; `requestBody?: never`/absent => no body. */
79+
function blockHasRequestBody(block: string): boolean {
80+
return /requestBody\s*\??\s*:\s*\{/.test(block);
81+
}
82+
83+
/**
84+
* Whether an operation actually declares a request body. Resolves a `<method>: operations["Name"]`
85+
* reference against the `operations` interface, or reads an inline `<method>: { ... }` block.
86+
* Defaults to `true` when the operation can't be resolved, so a real body is never dropped.
87+
*/
88+
function operationHasBody(pathBlock: string, method: string, operationsContent: string): boolean {
89+
const ref = new RegExp(`\\b${method}\\s*:\\s*operations\\[["']([^"']+)["']\\]`).exec(pathBlock);
90+
if (ref) {
91+
const opBlock = extractNamedBlock(operationsContent, ref[1]);
92+
return opBlock ? blockHasRequestBody(opBlock) : true;
93+
}
94+
const inline = new RegExp(`\\b${method}\\s*:\\s*\\{`).exec(pathBlock);
95+
if (inline) {
96+
const inlineBlock = extractPathBlock(pathBlock, inline.index);
97+
return inlineBlock ? blockHasRequestBody(inlineBlock) : true;
98+
}
99+
return true;
100+
}
101+
57102
export function extractPaths(apiTypesContent: string): PathInfo[] {
58103
const pathsContent = extractInterfaceBody(apiTypesContent, 'paths');
59104
if (!pathsContent) {
60105
throw new Error('Could not find paths interface in api.d.ts');
61106
}
62107

108+
const operationsContent = extractInterfaceBody(apiTypesContent, 'operations') ?? '';
63109
const pathKeyRegex = /"(\/[^"]*)":\s*\{/g;
64110
const paths: PathInfo[] = [];
65111

@@ -84,7 +130,10 @@ export function extractPaths(apiTypesContent: string): PathInfo[] {
84130
for (const { method, regex } of methodChecks) {
85131
if (regex.test(pathBlock)) {
86132
const hasParams = pathStr.includes('{');
87-
const hasBody = method !== 'get' && method !== 'delete';
133+
const hasBody =
134+
method !== 'get' &&
135+
method !== 'delete' &&
136+
operationHasBody(pathBlock, method, operationsContent);
88137
const hasQuery = method === 'get';
89138
methods.push({ method, hasParams, hasBody, hasQuery });
90139
}
@@ -177,7 +226,7 @@ export function generateFileContent(paths: PathInfo[], clientImport: string): st
177226
allHandlers.add(`handle${Method}Command`);
178227
allHandlers.add(`handle${Method}Form`);
179228
if (methodInfo.hasParams) allTypes.add('GetParameters');
180-
allTypes.add('GetRequestBody');
229+
if (methodInfo.hasBody) allTypes.add('GetRequestBody');
181230
}
182231
}
183232
}
@@ -226,22 +275,30 @@ function generateFunctionCode(
226275
const formName = pathToFunctionName(pathStr, method, 'Form');
227276
codes.push(`export const ${commandName} = command(\n\tz.custom<GetParameters<paths, '${pathStr}', 'delete'>>(),\n\tasync (params) => handleDeleteCommand('${pathStr}', params)\n);`);
228277
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<GetParameters<paths, '${pathStr}', 'delete'>>()),\n\tasync (params) => handleDeleteForm('${pathStr}', params)\n);`);
229-
} else if (info.hasParams) {
230-
const commandName = pathToFunctionName(pathStr, method, 'Command');
231-
const formName = pathToFunctionName(pathStr, method, 'Form');
232-
const Method = method.charAt(0).toUpperCase() + method.slice(1);
233-
const commandHandler = `handle${Method}Command`;
234-
const formHandler = `handle${Method}Form`;
235-
codes.push(`export const ${commandName} = command(\n\tz.object({\n\t\tpath: z.custom<GetParameters<paths, '${pathStr}', '${method}'>['path']>(),\n\t\tbody: z.custom<GetRequestBody<paths, '${pathStr}', '${method}'>>()\n\t}),\n\tasync (input) => ${commandHandler}('${pathStr}', input)\n);`);
236-
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<{ path: GetParameters<paths, '${pathStr}', '${method}'>['path']; body: GetRequestBody<paths, '${pathStr}', '${method}'> }>()),\n\tasync (input) => ${formHandler}('${pathStr}', input)\n);`);
237278
} else {
238279
const commandName = pathToFunctionName(pathStr, method, 'Command');
239280
const formName = pathToFunctionName(pathStr, method, 'Form');
240281
const Method = method.charAt(0).toUpperCase() + method.slice(1);
241282
const commandHandler = `handle${Method}Command`;
242283
const formHandler = `handle${Method}Form`;
243-
codes.push(`export const ${commandName} = command(\n\tz.custom<GetRequestBody<paths, '${pathStr}', '${method}'>>(),\n\tasync (body) => ${commandHandler}('${pathStr}', body)\n);`);
244-
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<GetRequestBody<paths, '${pathStr}', '${method}'>>()),\n\tasync (body) => ${formHandler}('${pathStr}', body)\n);`);
284+
const pathType = `GetParameters<paths, '${pathStr}', '${method}'>['path']`;
285+
const bodyType = `GetRequestBody<paths, '${pathStr}', '${method}'>`;
286+
287+
if (info.hasParams && info.hasBody) {
288+
codes.push(`export const ${commandName} = command(\n\tz.object({\n\t\tpath: z.custom<${pathType}>(),\n\t\tbody: z.custom<${bodyType}>()\n\t}),\n\tasync (input) => ${commandHandler}('${pathStr}', input)\n);`);
289+
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<{ path: ${pathType}; body: ${bodyType} }>()),\n\tasync (input) => ${formHandler}('${pathStr}', input)\n);`);
290+
} else if (info.hasParams && !info.hasBody) {
291+
// Path params but no request body: only accept `{ path }` so callers don't need `body: {}`.
292+
codes.push(`export const ${commandName} = command(\n\tz.object({\n\t\tpath: z.custom<${pathType}>()\n\t}),\n\tasync (input) => ${commandHandler}('${pathStr}', input)\n);`);
293+
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<{ path: ${pathType} }>()),\n\tasync (input) => ${formHandler}('${pathStr}', input)\n);`);
294+
} else if (!info.hasParams && info.hasBody) {
295+
codes.push(`export const ${commandName} = command(\n\tz.custom<${bodyType}>(),\n\tasync (body) => ${commandHandler}('${pathStr}', body)\n);`);
296+
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()).pipe(z.custom<${bodyType}>()),\n\tasync (body) => ${formHandler}('${pathStr}', body)\n);`);
297+
} else {
298+
// No path params and no request body: a no-argument action command (e.g. billing reactivate).
299+
codes.push(`export const ${commandName} = command(\n\tz.void(),\n\tasync () => ${commandHandler}('${pathStr}')\n);`);
300+
codes.push(`export const ${formName} = form(\n\tz.record(z.string(), z.any()),\n\tasync () => ${formHandler}('${pathStr}')\n);`);
301+
}
245302
}
246303

247304
return codes;

src/runtime/index.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,54 +13,66 @@ function handleResponse(response: any, error: any, data: any) {
1313
throw svelteError(503, 'Network error: no response from service');
1414
}
1515
if (error) {
16+
// Use the real HTTP status from the response (works for any API); fall back to 500 only if it's
17+
// somehow absent. The error body's `message`, when present, gives a better message than a generic.
1618
throw svelteError(
17-
(error as any).statusCode ?? 500,
18-
(error as any).message ?? 'An error occurred',
19+
response.status ?? 500,
20+
(error as { message?: string })?.message ?? 'An error occurred',
1921
);
2022
}
21-
if (!data) {
22-
throw svelteError(404, 'Not found');
23+
if (data === undefined || data === null) {
24+
// A successful (2xx) response may legitimately carry no body — 204 No Content, or a void /
25+
// action endpoint (e.g. "resend invitation"). Only treat missing data as an error when the
26+
// response itself was not ok; otherwise return it so the caller sees success, not a false 404.
27+
if (response.ok) return data;
28+
throw svelteError(response.status ?? 404, 'Not found');
2329
}
2430
return data;
2531
}
2632

33+
/**
34+
* Build the openapi-fetch request options from a command's single input argument.
35+
*
36+
* The generated command wrappers pass their input in one of these shapes, and this normalizes them:
37+
* - `{ path, body }` → params.path + body (path + body endpoint)
38+
* - `{ body }` → body (no-path endpoint, wrapped)
39+
* - `{ path }` (no `body`) → params.path, NO body (path-only endpoint, no request body)
40+
* - a bare body `{ ...fields }` → body (no-path endpoint whose wrapper passes
41+
* the body directly)
42+
* - `undefined` → neither (no-path, no-body action endpoint)
43+
*
44+
* The key subtlety: a bare body has no `body` key, so it must be forwarded whole; but a path-only
45+
* input (has `path`, no `body`) must NOT be forwarded as the body, or `{ path }` would be sent as
46+
* the request body to a no-body endpoint.
47+
*/
48+
function buildRequestOptions(input: any) {
49+
const hasPath = input && typeof input === 'object' && 'path' in input;
50+
const hasBody = input && typeof input === 'object' && 'body' in input;
51+
const body = hasBody ? input.body : hasPath ? undefined : input;
52+
return {
53+
...(hasPath && { params: { path: input.path } }),
54+
...(body !== undefined && { body }),
55+
};
56+
}
57+
2758
export function createRemoteHandlers(client: OpenapiClient) {
2859
async function handleGetQuery(path: string, params: any) {
2960
const { data, error, response } = await client.GET(path, { params });
3061
return handleResponse(response, error, data);
3162
}
3263

3364
async function handlePostCommand(path: string, input: any) {
34-
const hasPath = input && typeof input === 'object' && 'path' in input;
35-
const hasBody = input && typeof input === 'object' && 'body' in input;
36-
const { data, error, response } = await client.POST(path, {
37-
...(hasPath && { params: { path: input.path } }),
38-
body: hasBody ? input.body : input,
39-
});
65+
const { data, error, response } = await client.POST(path, buildRequestOptions(input));
4066
return handleResponse(response, error, data);
4167
}
4268

4369
async function handlePatchCommand(path: string, input: any) {
44-
const hasPath = input && typeof input === 'object' && 'path' in input;
45-
const hasBody = input && typeof input === 'object' && 'body' in input;
46-
// For a no-path endpoint the generated wrapper passes the body directly (no `body` key), so fall
47-
// back to the whole input as the body — matching handlePostCommand. Without this, a bare body was
48-
// silently dropped, sending an empty PATCH.
49-
const { data, error, response } = await client.PATCH(path, {
50-
...(hasPath && { params: { path: input.path } }),
51-
body: hasBody ? input.body : input,
52-
});
70+
const { data, error, response } = await client.PATCH(path, buildRequestOptions(input));
5371
return handleResponse(response, error, data);
5472
}
5573

5674
async function handlePutCommand(path: string, input: any) {
57-
const hasPath = input && typeof input === 'object' && 'path' in input;
58-
const hasBody = input && typeof input === 'object' && 'body' in input;
59-
// Same bare-body fallback as handlePostCommand/handlePatchCommand.
60-
const { data, error, response } = await client.PUT(path, {
61-
...(hasPath && { params: { path: input.path } }),
62-
body: hasBody ? input.body : input,
63-
});
75+
const { data, error, response } = await client.PUT(path, buildRequestOptions(input));
6476
return handleResponse(response, error, data);
6577
}
6678

src/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,20 @@ export type GetParameters<
1313
TMethod extends keyof Paths[TPath],
1414
> = Paths[TPath][TMethod] extends { parameters: infer P } ? P : never;
1515

16-
/** Extract JSON request body for a given path and method */
16+
/**
17+
* Extract the JSON request body for a given path and method.
18+
*
19+
* Uses `requestBody?:` (optional) in the constraint so it matches BOTH a required body
20+
* (`requestBody: {...}`) and an optional one (`requestBody?: {...}`). openapi-typescript emits the
21+
* optional form whenever the OpenAPI operation marks its body `required: false` — which is the
22+
* common case — so matching only the required form typed those endpoints as `never`.
23+
*/
1724
export type GetRequestBody<
1825
Paths,
1926
TPath extends keyof Paths,
2027
TMethod extends keyof Paths[TPath],
2128
> = Paths[TPath][TMethod] extends {
22-
requestBody: { content: { 'application/json': infer B } };
29+
requestBody?: { content: { 'application/json': infer B } };
2330
}
2431
? B
2532
: never;

0 commit comments

Comments
 (0)