Skip to content

Commit 0850888

Browse files
feat: helpers for Type Inference from API Endpoints (#4)
* feat(OpenAPI): enhance type exports and module declarations in generateDTS function * fix: remove unnecessary blank line before return statement in generateDTS function * docs: update section titles and add path-based type helpers with examples * fix: reorder import statements in openapi-type-helpers.md for clarity * feat(OpenAPI): add advanced type helpers for OpenAPI schema operations * chore: improve type export formatting and indentation * chore: move ParseInt type definition * chore: add @productdevbook to license --------- Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
1 parent 40d7f57 commit 0850888

4 files changed

Lines changed: 258 additions & 26 deletions

File tree

LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
MIT License
22

33
Copyright (c) 2024-PRESENT Johann Schopplich
4+
Copyright (c) 2025-PRESENT Wind (@productdevbook)
45

56
Permission is hereby granted, free of charge, to any person obtaining a copy
67
of this software and associated documentation files (the "Software"), to deal

docs/reference/openapi-type-helpers.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
When building clients with the [`OpenAPIBuilder` extension](/extensions/openapi), you may want to access the request and response types for each operation in the OpenAPI schema. APIful provides a set of shorthand types to help you with this.
44

5-
## Example
5+
## Basic Operation Helpers
66

77
Given the API service name `petStore`, APIful generates the following types:
88

@@ -37,3 +37,55 @@ interface _Pet {
3737
status?: 'available' | 'pending' | 'sold'
3838
}
3939
```
40+
41+
## Path-based Type Helpers
42+
43+
In addition to operation-based helpers, APIful also provides more direct path-based type helpers. These allow you to extract types directly from path and HTTP method combinations:
44+
45+
- `PathParamsFrom<API>` - Extract path parameters for a specific path and method
46+
- `RequestBodyFrom<API>` - Extract request body type for a specific path and method
47+
- `QueryParamsFrom<API>` - Extract query parameters for a specific path and method
48+
- `ResponseFrom<API>` - Extract response body type for a specific path and method with optional status code
49+
50+
### Examples
51+
52+
Using the same `petStore` API, you can access types directly from paths:
53+
54+
```ts
55+
import type {
56+
PathParamsFromPetStore,
57+
QueryParamsFromPetStore,
58+
RequestBodyFromPetStore,
59+
ResponseFromPetStore
60+
} from 'apiful/schema'
61+
62+
// Path parameters for GET /pet/{petId}
63+
type PetIdParam = PathParamsFromPetStore<'/pet/{petId}', 'get'>
64+
// ^? { petId: number }
65+
66+
// Request body for POST /pet
67+
type CreatePetBody = RequestBodyFromPetStore<'/pet', 'post'>
68+
// ^? { id?: number; name: string; /* ... */ }
69+
70+
// Query parameters for GET /pet/findByStatus
71+
type StatusQueryParam = QueryParamsFromPetStore<'/pet/findByStatus', 'get'>
72+
// ^? { status?: "available" | "pending" | "sold" }
73+
74+
// Response type for GET /pet/{petId}
75+
type PetResponse = ResponseFromPetStore<'/pet/{petId}', 'get'>
76+
// ^? { id?: number; name: string; /* ... */ }
77+
78+
// Response type for a specific status code (e.g., 201 Created)
79+
type CreatedPetResponse = ResponseFromPetStore<'/pet', 'post', '201'>
80+
```
81+
82+
### Benefits of Path-based Helpers
83+
84+
The path-based helpers offer several advantages:
85+
86+
1. **Direct access by URL path** - Use the actual API path as you would write it in your code
87+
2. **HTTP method specificity** - Extract types for specific HTTP methods on the same path
88+
3. **Status code support** - Extract response types for specific status codes
89+
4. **IDE integration** - Better completion support in IDEs since paths are directly tied to your OpenAPI schema
90+
91+
These path-based helpers work particularly well when you want to type parameters or responses for specific API endpoints that you're working with in your code.

src/openapi/generate.ts

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { defu } from 'defu'
44
import { pascalCase } from 'scule'
55
import { CODE_HEADER_DIRECTIVES } from '../constants'
66

7+
export type ParseInt<S extends string> = S extends `${infer N extends number}` ? N : never
8+
79
export async function generateDTS(
810
services: Record<string, ServiceOptions>,
911
openAPITSOptions?: OpenAPITSOptions,
@@ -18,41 +20,133 @@ export async function generateDTS(
1820
)
1921

2022
const resolvedSchemas = Object.fromEntries(resolvedSchemaEntries)
23+
const serviceIds = Object.keys(resolvedSchemas)
24+
25+
// Build import statements
26+
const imports = serviceIds
27+
.map(id => ` import { paths as ${pascalCase(id)}Paths, operations as ${pascalCase(id)}Operations } from 'apiful/__${id}__'`)
28+
.join('\n')
29+
30+
// Build repository interface entries
31+
const repositoryEntries = serviceIds
32+
.map(id => ` ${id}: ${pascalCase(id)}Paths`)
33+
.join('\n')
34+
35+
// Build type exports
36+
const typeExports = serviceIds
37+
.map((id) => {
38+
return [`
39+
export type ${pascalCase(id)}Response<
40+
T extends keyof ${pascalCase(id)}Operations,
41+
R extends keyof ${pascalCase(id)}Operations[T]['responses'] = 200 extends keyof ${pascalCase(id)}Operations[T]['responses'] ? 200 : never
42+
> = ${pascalCase(id)}Operations[T]['responses'][R] extends { content: { 'application/json': infer U } } ? U : never
43+
export type ${pascalCase(id)}RequestBody<
44+
T extends keyof ${pascalCase(id)}Operations
45+
> = ${pascalCase(id)}Operations[T]['requestBody'] extends { content: { 'application/json': infer U } } ? U : never
46+
export type ${pascalCase(id)}RequestQuery<
47+
T extends keyof ${pascalCase(id)}Operations
48+
> = ${pascalCase(id)}Operations[T]['parameters'] extends { query?: infer U } ? U : never
49+
50+
// Helper type to get the operation from a path entry
51+
export type GetOperation<T, M extends string> =
52+
M extends 'get' ? T extends { get: infer Op } ? Op : never :
53+
M extends 'post' ? T extends { post: infer Op } ? Op : never :
54+
M extends 'put' ? T extends { put: infer Op } ? Op : never :
55+
M extends 'delete' ? T extends { delete: infer Op } ? Op : never :
56+
M extends 'patch' ? T extends { patch: infer Op } ? Op : never :
57+
never
58+
59+
// Direct type that allows accessing path parameters by specifying the HTTP method
60+
export type PathParamsFrom${pascalCase(id)}<
61+
P extends keyof ${pascalCase(id)}Paths,
62+
M extends NonNeverKeysWithoutParams<${pascalCase(id)}Paths[P]>
63+
> = GetOperation<${pascalCase(id)}Paths[P], M> extends infer Op
64+
? Op extends { parameters?: any }
65+
? NonNullable<Op['parameters']>['path'] extends infer Params
66+
? Params extends object
67+
? Params
68+
: Record<string, never>
69+
: Record<string, never>
70+
: Record<string, never>
71+
: Record<string, never>
72+
73+
// Direct type that allows accessing request body by specifying the HTTP method
74+
export type RequestBodyFrom${pascalCase(id)}<
75+
P extends keyof ${pascalCase(id)}Paths,
76+
M extends NonNeverKeysWithoutParams<${pascalCase(id)}Paths[P]>
77+
> = GetOperation<${pascalCase(id)}Paths[P], M> extends infer Op
78+
? Op extends { requestBody?: any }
79+
? NonNullable<Op['requestBody']>['content']['application/json'] extends infer Body
80+
? Body extends object
81+
? Body
82+
: Record<string, never>
83+
: Record<string, never>
84+
: Record<string, never>
85+
: Record<string, never>
86+
87+
// Direct type that allows accessing query parameters by specifying the HTTP method
88+
export type QueryParamsFrom${pascalCase(id)}<
89+
P extends keyof ${pascalCase(id)}Paths,
90+
M extends NonNeverKeysWithoutParams<${pascalCase(id)}Paths[P]>
91+
> = GetOperation<${pascalCase(id)}Paths[P], M> extends infer Op
92+
? Op extends { parameters?: any }
93+
? NonNullable<Op['parameters']>['query'] extends infer Params
94+
? Params extends object
95+
? Params
96+
: Record<string, never>
97+
: Record<string, never>
98+
: Record<string, never>
99+
: Record<string, never>
100+
101+
// Direct type that allows accessing response body by specifying the HTTP method
102+
export type ResponseFrom${pascalCase(id)}<
103+
P extends keyof ${pascalCase(id)}Paths,
104+
M extends NonNeverKeysWithoutParams<${pascalCase(id)}Paths[P]>,
105+
C extends \`\${keyof NonNullable<GetOperation<${pascalCase(id)}Paths[P], M>>['responses']}\` = '200'
106+
> = GetOperation<${pascalCase(id)}Paths[P], M> extends infer Op
107+
? Op extends { responses?: any }
108+
? ParseInt<C> extends keyof Op['responses']
109+
? Op['responses'][ParseInt<C>] extends { content: { 'application/json': infer Body } }
110+
? Body
111+
: Record<string, never>
112+
: Record<string, never>
113+
: Record<string, never>
114+
: Record<string, never>
115+
`.trim()].join('\n')
116+
})
117+
.join('\n\n')
118+
119+
// Build module declarations
120+
const moduleDeclarations = Object.entries(resolvedSchemas)
121+
.map(([id, types]) => `declare module 'apiful/__${id}__' {
122+
${normalizeIndentation(types).trimEnd()}
123+
}`)
124+
.join('\n\n')
21125

22126
return `
23127
${CODE_HEADER_DIRECTIVES}
128+
24129
declare module 'apiful/schema' {
25-
${Object.keys(resolvedSchemas)
26-
.map(i => ` import { paths as ${pascalCase(i)}Paths, operations as ${pascalCase(i)}Operations } from 'apiful/__${i}__'`)
27-
.join('\n')}
130+
${imports}
131+
132+
type NonNeverKeys<T> = {
133+
[K in keyof T]: [T[K]] extends [never]
134+
? never
135+
: [undefined] extends [T[K]]
136+
? [never] extends [Exclude<T[K], undefined>] ? never : K
137+
: K;
138+
}[keyof T];
139+
type NonNeverKeysWithoutParams<T> = Exclude<NonNeverKeys<T>, 'parameters'>
140+
type ParseInt<S extends string> = S extends \`\${infer N extends number}\` ? N : never
28141
29142
interface OpenAPISchemaRepository {
30-
${Object.keys(resolvedSchemas)
31-
.map(i => `${i}: ${pascalCase(i)}Paths`.replace(/^/gm, ' '))
32-
.join('\n')}
143+
${repositoryEntries}
33144
}
34145
35-
${Object.keys(resolvedSchemas)
36-
.map(i => ` export type ${pascalCase(i)}Response<
37-
T extends keyof ${pascalCase(i)}Operations,
38-
R extends keyof ${pascalCase(i)}Operations[T]['responses'] = 200 extends keyof ${pascalCase(i)}Operations[T]['responses'] ? 200 : never
39-
> = ${pascalCase(i)}Operations[T]['responses'][R] extends { content: { 'application/json': infer U } } ? U : never
40-
export type ${pascalCase(i)}RequestBody<
41-
T extends keyof ${pascalCase(i)}Operations
42-
> = ${pascalCase(i)}Operations[T]['requestBody'] extends { content: { 'application/json': infer U } } ? U : never
43-
export type ${pascalCase(i)}RequestQuery<
44-
T extends keyof ${pascalCase(i)}Operations
45-
> = ${pascalCase(i)}Operations[T]['parameters'] extends { query?: infer U } ? U : never
46-
`)
47-
.join('\n')
48-
.trimEnd()}
146+
${applyLineIndent(typeExports)}
49147
}
50148
51-
${Object.entries(resolvedSchemas)
52-
.map(([id, types]) => `declare module 'apiful/__${id}__' {
53-
${normalizeIndentation(types).trimEnd()}
54-
}`)
55-
.join('\n\n')}
149+
${moduleDeclarations}
56150
`.trimStart()
57151
}
58152

@@ -112,6 +206,14 @@ function isValidUrl(url: string) {
112206
}
113207
}
114208

209+
// Add indentation of two spaces to each line
210+
function applyLineIndent(code: string) {
211+
return code
212+
.split('\n')
213+
.map(line => line.replace(/^/gm, ' '))
214+
.join('\n')
215+
}
216+
115217
function normalizeIndentation(code: string) {
116218
// Replace each cluster of four spaces with two spaces
117219
const replacedCode = code.replace(/^( {4})+/gm, match => ' '.repeat(match.length / 4))

test/__snapshots__/openapi.test.ts.snap

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@ exports[`OpenAPI to TypeScript > generates OpenAPI types 1`] = `
44
"/* eslint-disable */
55
/* prettier-ignore */
66
7+
78
declare module 'apiful/schema' {
89
import { paths as TestEchoPaths, operations as TestEchoOperations } from 'apiful/__testEcho__'
910
11+
type NonNeverKeys<T> = {
12+
[K in keyof T]: [T[K]] extends [never]
13+
? never
14+
: [undefined] extends [T[K]]
15+
? [never] extends [Exclude<T[K], undefined>] ? never : K
16+
: K;
17+
}[keyof T];
18+
type NonNeverKeysWithoutParams<T> = Exclude<NonNeverKeys<T>, 'parameters'>
19+
type ParseInt<S extends string> = S extends \`\${infer N extends number}\` ? N : never
20+
1021
interface OpenAPISchemaRepository {
1122
testEcho: TestEchoPaths
1223
}
@@ -21,6 +32,72 @@ declare module 'apiful/schema' {
2132
export type TestEchoRequestQuery<
2233
T extends keyof TestEchoOperations
2334
> = TestEchoOperations[T]['parameters'] extends { query?: infer U } ? U : never
35+
36+
// Helper type to get the operation from a path entry
37+
export type GetOperation<T, M extends string> =
38+
M extends 'get' ? T extends { get: infer Op } ? Op : never :
39+
M extends 'post' ? T extends { post: infer Op } ? Op : never :
40+
M extends 'put' ? T extends { put: infer Op } ? Op : never :
41+
M extends 'delete' ? T extends { delete: infer Op } ? Op : never :
42+
M extends 'patch' ? T extends { patch: infer Op } ? Op : never :
43+
never
44+
45+
// Direct type that allows accessing path parameters by specifying the HTTP method
46+
export type PathParamsFromTestEcho<
47+
P extends keyof TestEchoPaths,
48+
M extends NonNeverKeysWithoutParams<TestEchoPaths[P]>
49+
> = GetOperation<TestEchoPaths[P], M> extends infer Op
50+
? Op extends { parameters?: any }
51+
? NonNullable<Op['parameters']>['path'] extends infer Params
52+
? Params extends object
53+
? Params
54+
: Record<string, never>
55+
: Record<string, never>
56+
: Record<string, never>
57+
: Record<string, never>
58+
59+
// Direct type that allows accessing request body by specifying the HTTP method
60+
export type RequestBodyFromTestEcho<
61+
P extends keyof TestEchoPaths,
62+
M extends NonNeverKeysWithoutParams<TestEchoPaths[P]>
63+
> = GetOperation<TestEchoPaths[P], M> extends infer Op
64+
? Op extends { requestBody?: any }
65+
? NonNullable<Op['requestBody']>['content']['application/json'] extends infer Body
66+
? Body extends object
67+
? Body
68+
: Record<string, never>
69+
: Record<string, never>
70+
: Record<string, never>
71+
: Record<string, never>
72+
73+
// Direct type that allows accessing query parameters by specifying the HTTP method
74+
export type QueryParamsFromTestEcho<
75+
P extends keyof TestEchoPaths,
76+
M extends NonNeverKeysWithoutParams<TestEchoPaths[P]>
77+
> = GetOperation<TestEchoPaths[P], M> extends infer Op
78+
? Op extends { parameters?: any }
79+
? NonNullable<Op['parameters']>['query'] extends infer Params
80+
? Params extends object
81+
? Params
82+
: Record<string, never>
83+
: Record<string, never>
84+
: Record<string, never>
85+
: Record<string, never>
86+
87+
// Direct type that allows accessing response body by specifying the HTTP method
88+
export type ResponseFromTestEcho<
89+
P extends keyof TestEchoPaths,
90+
M extends NonNeverKeysWithoutParams<TestEchoPaths[P]>,
91+
C extends \`\${keyof NonNullable<GetOperation<TestEchoPaths[P], M>>['responses']}\` = '200'
92+
> = GetOperation<TestEchoPaths[P], M> extends infer Op
93+
? Op extends { responses?: any }
94+
? ParseInt<C> extends keyof Op['responses']
95+
? Op['responses'][ParseInt<C>] extends { content: { 'application/json': infer Body } }
96+
? Body
97+
: Record<string, never>
98+
: Record<string, never>
99+
: Record<string, never>
100+
: Record<string, never>
24101
}
25102
26103
declare module 'apiful/__testEcho__' {

0 commit comments

Comments
 (0)