@stacksjs/api 0.75.431 → 0.75.439
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -2
- package/dist/client-runtime.d.ts +2 -0
- package/dist/client-runtime.js +154 -0
- package/dist/digest.d.ts +4 -0
- package/dist/digest.js +1 -0
- package/dist/fetcher.js +1 -1
- package/dist/generate-client.d.ts +0 -7
- package/dist/generate-client.js +7 -94
- package/dist/generate-types.d.ts +51 -8
- package/dist/generate-types.js +20 -20
- package/dist/query.d.ts +2 -0
- package/dist/query.js +1 -0
- package/dist/resource.d.ts +6 -25
- package/dist/resource.js +1 -1
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A simple, powerful HTTP client that wraps the fetch API with a more elegant inte
|
|
|
7
7
|
- 🚀 Simple, elegant API
|
|
8
8
|
- 💪 Full TypeScript support
|
|
9
9
|
- 🔄 JSON handling by default
|
|
10
|
-
- 📦
|
|
10
|
+
- 📦 Native fetch transport with browser-compatible digest hashing
|
|
11
11
|
- 🎯 Type-safe requests and responses
|
|
12
12
|
|
|
13
13
|
## 🤖 Usage
|
|
@@ -79,10 +79,115 @@ interface FetcherResponse<T> {
|
|
|
79
79
|
data: T // The response data (typed as T)
|
|
80
80
|
status: number // HTTP status code
|
|
81
81
|
headers: Headers // Response headers
|
|
82
|
-
|
|
82
|
+
isOk: boolean // Whether the request was successful
|
|
83
|
+
ok(): boolean // Whether the status is exactly 200
|
|
83
84
|
}
|
|
84
85
|
```
|
|
85
86
|
|
|
87
|
+
### Request configuration and responses
|
|
88
|
+
|
|
89
|
+
Fluent settings apply to the next request only. Starting a request consumes its headers,
|
|
90
|
+
credentials, query parameters, body and attachments synchronously. Overlapping calls keep
|
|
91
|
+
independent settings, including when serialization, transport or response parsing fails.
|
|
92
|
+
Header overrides are case-insensitive. Query values are appended to existing parameters
|
|
93
|
+
before a fragment; null and undefined values are omitted.
|
|
94
|
+
|
|
95
|
+
Response data is parsed as JSON for `application/json` and `+json` media types. Empty
|
|
96
|
+
responses have `data: null`; other media types return text. Invalid declared JSON rejects.
|
|
97
|
+
HTTP error statuses still return a response so callers can inspect its status and data.
|
|
98
|
+
|
|
99
|
+
### Digest authentication
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
const response = await fetcher
|
|
103
|
+
.withDigestAuth('user', 'password')
|
|
104
|
+
.withBody({ message: 'hello' })
|
|
105
|
+
.post('/protected')
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The client supports MD5 and SHA-256, their session variants, `auth`, `auth-int`, and legacy
|
|
109
|
+
challenges without qop. It chooses SHA-256 when offered, parses quoted challenge values,
|
|
110
|
+
normalizes UTF-8 credentials to NFC, and replays the exact prepared body bytes, including
|
|
111
|
+
multipart boundaries, once. Unsupported
|
|
112
|
+
challenges return the original 401. Digest hashing loads `hash-wasm` on demand and needs
|
|
113
|
+
WebAssembly in browsers. MD5 is supported for legacy digest servers only.
|
|
114
|
+
|
|
115
|
+
Digest requests omit ambient cookies and browser HTTP-auth credentials to let the client
|
|
116
|
+
handle the challenge without a native login dialog. Explicit headers still apply. Challenges
|
|
117
|
+
received after a redirect are not authenticated; call the final protected URL directly.
|
|
118
|
+
|
|
119
|
+
### Resource serialization
|
|
120
|
+
|
|
121
|
+
`JsonResource` and `ResourceCollection` can be returned directly from a route. Serialization
|
|
122
|
+
uses the public projection, including nested resources and collections, without exposing
|
|
123
|
+
source records or the stored request. Nested projections receive the parent request.
|
|
124
|
+
Conditional values are resolved at every level and missing entries are omitted from objects
|
|
125
|
+
and arrays. Nested arrays retain their shape. Cycles and excessive nesting throw before any
|
|
126
|
+
raw fallback can be serialized; the shared resolution budget is 20 object or wrapper levels.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
class UserResource extends JsonResource<User> {
|
|
130
|
+
toArray() {
|
|
131
|
+
return {
|
|
132
|
+
id: this.resource.id,
|
|
133
|
+
...this.merge({ name: this.resource.name }),
|
|
134
|
+
...this.mergeWhen(this.resource.isPublic, { email: this.resource.email }),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Spread merges follow ordinary object spread ordering. Named merge placeholders and explicit
|
|
141
|
+
`MergeValue` instances remain supported. Special field names such as `__proto__` are retained
|
|
142
|
+
as data properties. Direct paginated collections retain projected rows, metadata and links.
|
|
143
|
+
Pagination requires positive safe integer page numbers and sizes and a nonnegative safe
|
|
144
|
+
integer total. Empty pages report null row bounds, and empty results have one page. Links
|
|
145
|
+
replace an existing `page` parameter while retaining filters, duplicate parameters and fragments.
|
|
146
|
+
|
|
147
|
+
### Generated API clients and types
|
|
148
|
+
|
|
149
|
+
The OpenAPI generators emit standalone TypeScript clients with no runtime imports.
|
|
150
|
+
Framework contributors regenerate the checked artifacts with `./buddy docs:artifacts`
|
|
151
|
+
and verify them with `./buddy docs:artifacts:check`.
|
|
152
|
+
|
|
153
|
+
Methods retain flat path/query arguments when names are unique. Duplicate names,
|
|
154
|
+
or collisions with a body or typed-header container that exists, use location groups:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
await client.show({ path: { id: 'alice' }, query: { id: 7 } })
|
|
158
|
+
await client.upload({ headers: { 'X-Tenant': 'tenant-a' }, body: file })
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Required headers and bodies are typed and checked before transport. Request settings
|
|
162
|
+
merge in this order: client headers, bearer token, per-call headers, typed header inputs.
|
|
163
|
+
Header names are case-insensitive. Dot-only path segments are rejected before sending.
|
|
164
|
+
Generated method names reserve client members and stay unique after normalization.
|
|
165
|
+
Documentation is escaped before it is embedded in generated comments.
|
|
166
|
+
|
|
167
|
+
Query serialization supports form arrays and objects, deepObject filters with scalar
|
|
168
|
+
values, and space/pipe-delimited arrays. Path and header parameters support simple
|
|
169
|
+
serialization. Unsupported locations or styles fail during generation. Cookie parameters
|
|
170
|
+
are unsupported because browser fetch cannot set the Cookie header directly.
|
|
171
|
+
|
|
172
|
+
Bodies support JSON (including `+json`), URL-encoded forms, multipart objects or FormData,
|
|
173
|
+
and raw text/binary payloads. Select among declared formats with the second argument,
|
|
174
|
+
`{ contentType: 'application/x-www-form-urlencoded' }`. Multipart boundaries are set by
|
|
175
|
+
fetch. Custom per-part encodings and automatic XML serialization are not provided.
|
|
176
|
+
Responses are decoded by media type; declared binary schemas return Blob values, text
|
|
177
|
+
stays text, and empty/HEAD responses return null. All declared successful response
|
|
178
|
+
schemas contribute to the result union. HTTP error statuses return `ok: false`.
|
|
179
|
+
|
|
180
|
+
Schema generation supports local component references (including recursive models),
|
|
181
|
+
nullable fields, literal JSON enums, unions and intersections. Unresolved, external and
|
|
182
|
+
cyclic alias references fail during generation. Request schemas omit read-only fields;
|
|
183
|
+
response schemas omit write-only fields. `components.requestSchemas` exposes request
|
|
184
|
+
variants alongside response `components.schemas`.
|
|
185
|
+
|
|
186
|
+
TypeScript index signatures apply to named fields too. For schemas combining named
|
|
187
|
+
properties and typed additional properties, the generated index includes the named
|
|
188
|
+
field types and optional undefined values to remain compilable. Runtime validation
|
|
189
|
+
must enforce the narrower constraints on additional properties.
|
|
190
|
+
|
|
86
191
|
## 🧪 Testing
|
|
87
192
|
|
|
88
193
|
```bash
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export const clientRuntime=String.raw`
|
|
2
|
+
export type ApiResult<T> =
|
|
3
|
+
| { ok: true, status: number, data: T, headers: Headers }
|
|
4
|
+
| { ok: false, status: number, error: unknown, headers: Headers }
|
|
5
|
+
|
|
6
|
+
export interface ClientConfig {
|
|
7
|
+
baseUrl: string
|
|
8
|
+
token?: string
|
|
9
|
+
headers?: Record<string, string>
|
|
10
|
+
fetch?: typeof fetch
|
|
11
|
+
}
|
|
12
|
+
export interface RequestOptions {
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
headers?: Record<string, string>
|
|
15
|
+
/** Select one of the operation's declared request media types. */
|
|
16
|
+
contentType?: string
|
|
17
|
+
}
|
|
18
|
+
interface Parameter {
|
|
19
|
+
name: string
|
|
20
|
+
in: string
|
|
21
|
+
required: boolean
|
|
22
|
+
group?: string
|
|
23
|
+
style?: string
|
|
24
|
+
explode?: boolean
|
|
25
|
+
}
|
|
26
|
+
interface BodyDescription { required: boolean, content: string[] }
|
|
27
|
+
|
|
28
|
+
function parameterValue(input: Record<string, unknown>, parameter: Parameter): unknown {
|
|
29
|
+
const fields = parameter.group ? input[parameter.group] : input
|
|
30
|
+
if (parameter.in === 'header' && fields && typeof fields === 'object') {
|
|
31
|
+
const entries = Object.entries(fields).filter(([key]) => key.toLowerCase() === parameter.name.toLowerCase())
|
|
32
|
+
return entries.at(-1)?.[1]
|
|
33
|
+
}
|
|
34
|
+
return fields && typeof fields === 'object' && Object.hasOwn(fields, parameter.name)
|
|
35
|
+
? (fields as Record<string, unknown>)[parameter.name] : undefined
|
|
36
|
+
}
|
|
37
|
+
function scalar(value: unknown): string {
|
|
38
|
+
if (typeof value === 'object' && value !== null) throw new TypeError('Nested parameter values require an explicit supported serialization')
|
|
39
|
+
return String(value)
|
|
40
|
+
}
|
|
41
|
+
function queryParameter(url: URL, parameter: Parameter, value: unknown): void {
|
|
42
|
+
const style = parameter.style ?? 'form'
|
|
43
|
+
const explode = parameter.explode ?? style === 'form'
|
|
44
|
+
if (style === 'deepObject') {
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('deepObject requires an object')
|
|
46
|
+
for (const [key, item] of Object.entries(value)) url.searchParams.append(parameter.name + '[' + key + ']', scalar(item))
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(value)) {
|
|
49
|
+
if (style === 'form' && explode) {
|
|
50
|
+
for (const item of value) url.searchParams.append(parameter.name, scalar(item))
|
|
51
|
+
}
|
|
52
|
+
else url.searchParams.append(parameter.name, value.map(scalar).join(style === 'spaceDelimited' ? ' ' : style === 'pipeDelimited' ? '|' : ','))
|
|
53
|
+
}
|
|
54
|
+
else if (value && typeof value === 'object') {
|
|
55
|
+
if (style !== 'form') throw new TypeError('Object queries require form or deepObject style')
|
|
56
|
+
if (explode) for (const [key, item] of Object.entries(value)) url.searchParams.append(key, scalar(item))
|
|
57
|
+
else url.searchParams.append(parameter.name, Object.entries(value).flatMap(([key, item]) => [key, scalar(item)]).join(','))
|
|
58
|
+
}
|
|
59
|
+
else url.searchParams.append(parameter.name, scalar(value))
|
|
60
|
+
}
|
|
61
|
+
function simpleParameter(value: unknown, explode: boolean, encode: (value: string) => string): string {
|
|
62
|
+
if (Array.isArray(value)) return value.map(item => encode(scalar(item))).join(',')
|
|
63
|
+
if (value && typeof value === 'object') return Object.entries(value).map(([key, item]) => encode(key) + (explode ? '=' : ',') + encode(scalar(item))).join(',')
|
|
64
|
+
return encode(scalar(value))
|
|
65
|
+
}
|
|
66
|
+
function buildUrl(config: ClientConfig, route: string, input: Record<string, unknown>, parameters: Parameter[], headers: Headers): string {
|
|
67
|
+
const path = new Map<string, string>()
|
|
68
|
+
for (const parameter of parameters) {
|
|
69
|
+
const value = parameterValue(input, parameter)
|
|
70
|
+
if (value === undefined || value === null) {
|
|
71
|
+
if (parameter.required) throw new TypeError('Missing required ' + parameter.in + ' parameter: ' + parameter.name)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (parameter.in === 'path') {
|
|
75
|
+
const segment = simpleParameter(value, parameter.explode ?? false, encodeURIComponent)
|
|
76
|
+
if (segment === '.' || segment === '..') throw new TypeError('Dot-only path parameters are not allowed')
|
|
77
|
+
path.set(parameter.name, segment)
|
|
78
|
+
}
|
|
79
|
+
else if (parameter.in === 'header') headers.set(parameter.name, simpleParameter(value, parameter.explode ?? false, value => value))
|
|
80
|
+
}
|
|
81
|
+
const filled = route.replace(/\{([^{}]+)\}/g, (_, key: string) => {
|
|
82
|
+
const value = path.get(key)
|
|
83
|
+
if (value === undefined) throw new TypeError('Missing path parameter: ' + key)
|
|
84
|
+
return value
|
|
85
|
+
})
|
|
86
|
+
const url = new URL(filled.replace(/^\/+/, '/'), config.baseUrl.endsWith('/') ? config.baseUrl : config.baseUrl + '/')
|
|
87
|
+
for (const parameter of parameters) {
|
|
88
|
+
const value = parameterValue(input, parameter)
|
|
89
|
+
if (parameter.in === 'query' && value !== undefined && value !== null) queryParameter(url, parameter, value)
|
|
90
|
+
}
|
|
91
|
+
return url.toString()
|
|
92
|
+
}
|
|
93
|
+
function isJson(type: string): boolean { return type === 'application/json' || type.endsWith('+json') }
|
|
94
|
+
function mediaType(type: string): string { return type.split(';')[0]!.trim().toLowerCase() }
|
|
95
|
+
function requestBody(value: unknown, description: BodyDescription | null, headers: Headers, options?: RequestOptions): BodyInit | undefined {
|
|
96
|
+
if (!description) return undefined
|
|
97
|
+
if (value === undefined) {
|
|
98
|
+
if (description.required) throw new TypeError('Missing required request body')
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
const declared = description.content
|
|
102
|
+
const selected = options?.contentType ?? headers.get('content-type') ?? declared.find(isJson) ?? declared[0]
|
|
103
|
+
if (!selected || !declared.some(type => mediaType(type) === mediaType(selected))) throw new TypeError('Request media type is not declared by this operation')
|
|
104
|
+
const type = mediaType(selected)
|
|
105
|
+
headers.set('content-type', selected)
|
|
106
|
+
if (isJson(type)) return JSON.stringify(value)
|
|
107
|
+
if (type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data') {
|
|
108
|
+
if (type === 'multipart/form-data' && value instanceof FormData) { headers.delete('content-type'); return value }
|
|
109
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Form bodies require an object')
|
|
110
|
+
if (type === 'application/x-www-form-urlencoded') {
|
|
111
|
+
const form = new URLSearchParams()
|
|
112
|
+
for (const [name, field] of Object.entries(value)) {
|
|
113
|
+
for (const item of Array.isArray(field) ? field : [field]) if (item != null) form.append(name, scalar(item))
|
|
114
|
+
}
|
|
115
|
+
return form.toString()
|
|
116
|
+
}
|
|
117
|
+
const form = new FormData()
|
|
118
|
+
for (const [name, field] of Object.entries(value)) {
|
|
119
|
+
for (const item of Array.isArray(field) ? field : [field]) {
|
|
120
|
+
if (item == null) continue
|
|
121
|
+
form.append(name, item instanceof Blob ? item : typeof item === 'object' ? JSON.stringify(item) : String(item))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
headers.delete('content-type')
|
|
125
|
+
return form
|
|
126
|
+
}
|
|
127
|
+
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value as BodyInit
|
|
128
|
+
if (typeof value === 'string') return value
|
|
129
|
+
throw new TypeError('Non-JSON request bodies require text or binary data')
|
|
130
|
+
}
|
|
131
|
+
async function request<T>(config: ClientConfig, method: string, route: string, input: Record<string, unknown>, parameters: Parameter[], description: BodyDescription | null, accept: string, binary: string[], options?: RequestOptions): Promise<ApiResult<T>> {
|
|
132
|
+
const headers = new Headers({ Accept: accept })
|
|
133
|
+
for (const [name, value] of Object.entries(config.headers ?? {})) headers.set(name, value)
|
|
134
|
+
if (config.token) headers.set('authorization', 'Bearer ' + config.token)
|
|
135
|
+
for (const [name, value] of Object.entries(options?.headers ?? {})) headers.set(name, value)
|
|
136
|
+
const url = buildUrl(config, route, input, parameters, headers)
|
|
137
|
+
const body = requestBody(input.body, description, headers, options)
|
|
138
|
+
const response = await (config.fetch ?? fetch)(url, { method, headers, body, signal: options?.signal })
|
|
139
|
+
const type = mediaType(response.headers.get('content-type') ?? '')
|
|
140
|
+
let parsed: unknown
|
|
141
|
+
if (method === 'HEAD' || response.status === 204 || response.status === 205) parsed = null
|
|
142
|
+
else if (binary.includes(type)) parsed = await response.blob()
|
|
143
|
+
else {
|
|
144
|
+
const text = await response.text()
|
|
145
|
+
// Keep malformed JSON available as evidence, without parsing other media as JSON.
|
|
146
|
+
if (!text.length) parsed = type.startsWith('text/') ? '' : null
|
|
147
|
+
else if (isJson(type)) { try { parsed = JSON.parse(text) } catch { parsed = text } }
|
|
148
|
+
else parsed = text
|
|
149
|
+
}
|
|
150
|
+
return response.ok
|
|
151
|
+
? { ok: true, status: response.status, data: parsed as T, headers: response.headers }
|
|
152
|
+
: { ok: false, status: response.status, error: parsed, headers: response.headers }
|
|
153
|
+
}
|
|
154
|
+
`;
|
package/dist/digest.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** RFC 7616 authentication. MD5 is used only when a legacy server challenges with it. */
|
|
2
|
+
export declare function digestAuthorization(header: string, credentials: DigestCredentials, method: string, url: string, body: Uint8Array): Promise<string | null>;
|
|
3
|
+
export declare interface DigestCredentials { username: string, password: string }
|
|
4
|
+
declare type Challenge = Record<string, string>;
|
package/dist/digest.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{md5,sha256}from"hash-wasm";function digestChallenges(header){const parts=[];let quoted=!1,escaped=!1,start=0;for(let index=0;index<header.length;index++){const char=header[index];if(escaped){escaped=!1;continue}if(quoted&&char==="\\"){escaped=!0;continue}if(char==='"')quoted=!quoted;if(char===","&&!quoted){parts.push(header.slice(start,index));start=index+1}}if(quoted||escaped)return[];parts.push(header.slice(start));const challenges=[];let current;for(let part of parts){part=part.trim();const scheme=/^([a-z][a-z0-9_-]*)[ \t]+(?=[^ \t=])/i.exec(part);if(scheme){current=scheme[1].toLowerCase()==="digest"?Object.create(null):void 0;if(current)challenges.push(current);part=part.slice(scheme[0].length)}if(!current)continue;const parameter=/^([a-z][a-z0-9_-]*)\s*=\s*("(?:\\.|[^"\\])*"|[^\s]+)$/i.exec(part);if(!parameter){current=void 0;challenges.pop();continue}const key=parameter[1].toLowerCase();if(key in current){current=void 0;challenges.pop();continue}const raw=parameter[2];current[key]=raw.startsWith('"')?raw.slice(1,-1).replace(/\\(.)/g,"$1"):raw}return challenges}export async function digestAuthorization(header,credentials,method,url,body){const candidates=digestChallenges(header).filter((challenge)=>/^(?:MD5|SHA-256)(?:-sess)?$/i.test(challenge.algorithm??"MD5")).sort((a,b)=>Number(/^SHA-256/i.test(b.algorithm??""))-Number(/^SHA-256/i.test(a.algorithm??"")));for(const challenge of candidates){if(challenge.realm===void 0||!challenge.nonce)continue;if(challenge.charset&&challenge.charset.toUpperCase()!=="UTF-8")continue;const user=challenge.charset?credentials.username.normalize("NFC"):credentials.username,password=challenge.charset?credentials.password.normalize("NFC"):credentials.password,offered=challenge.qop?.split(",").map((value)=>value.trim().toLowerCase()),qop=offered?.includes("auth")?"auth":offered?.includes("auth-int")?"auth-int":void 0;if(offered&&!qop)continue;const algorithm=(challenge.algorithm??"MD5").toUpperCase(),session=algorithm.endsWith("-SESS"),base=algorithm.startsWith("SHA-256")?"SHA-256":"MD5",hash=base==="MD5"?md5:sha256,quote=(value)=>`"${value.replace(/[\\"]/g,"\\$&")}"`,cnonce=Array.from(crypto.getRandomValues(new Uint8Array(16)),(value)=>value.toString(16).padStart(2,"0")).join(""),nonceCount="00000001",target=new URL(url),uri=target.pathname+target.search;let ha1=await hash(`${user}:${challenge.realm}:${password}`);if(session)ha1=await hash(`${ha1}:${challenge.nonce}:${cnonce}`);const ha2=await hash(`${method}:${uri}${qop==="auth-int"?`:${await hash(body)}`:""}`),response=await hash(qop?`${ha1}:${challenge.nonce}:${nonceCount}:${cnonce}:${qop}:${ha2}`:`${ha1}:${challenge.nonce}:${ha2}`),username=challenge.userhash?.toLowerCase()==="true"?await hash(`${user}:${challenge.realm}`):user,encodedUsername=encodeURIComponent(username).replace(/['()*]/g,(char)=>`%${char.charCodeAt(0).toString(16).toUpperCase()}`),fields=[/[^\x20-\x7E]/.test(username)?`username*=UTF-8''${encodedUsername}`:`username=${quote(username)}`,`realm=${quote(challenge.realm)}`,`nonce=${quote(challenge.nonce)}`,`uri=${quote(uri)}`,`algorithm=${base}${session?"-sess":""}`,`response=${quote(response)}`];if(qop)fields.push(`qop=${qop}`,`nc=${nonceCount}`);if(qop||session)fields.push(`cnonce=${quote(cnonce)}`);if(challenge.opaque!==void 0)fields.push(`opaque=${quote(challenge.opaque)}`);if(challenge.userhash?.toLowerCase()==="true")fields.push("userhash=true");return`Digest ${fields.join(", ")}`}return null}
|
package/dist/fetcher.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
class FetcherResponseImpl{data;status;headers;isOk;constructor(data,status,headers,isOk){this.data=data;this.status=status;this.headers=headers;this.isOk=isOk}ok(){return this.status===200}created(){return this.status===201}accepted(){return this.status===202}noContent(){return this.status===204}movedPermanently(){return this.status===301}found(){return this.status===302}badRequest(){return this.status===400}unauthorized(){return this.status===401}paymentRequired(){return this.status===402}forbidden(){return this.status===403}notFound(){return this.status===404}requestTimeout(){return this.status===408}conflict(){return this.status===409}unprocessableEntity(){return this.status===422}tooManyRequests(){return this.status===429}serverError(){return this.status===500}}class Fetcher{defaultHeaders={Accept:"application/json","Content-Type":"application/json"};queryParams;bodyData;isFormData=!1;customHeaders={};attachments=[];isMultipart=!1;digestAuth;withQueryParams(params){this.queryParams=params;return this}withHeaders(headers){this.customHeaders=
|
|
1
|
+
import{withQuery}from"./query";class FetcherResponseImpl{data;status;headers;isOk;constructor(data,status,headers,isOk){this.data=data;this.status=status;this.headers=headers;this.isOk=isOk}ok(){return this.status===200}created(){return this.status===201}accepted(){return this.status===202}noContent(){return this.status===204}movedPermanently(){return this.status===301}found(){return this.status===302}badRequest(){return this.status===400}unauthorized(){return this.status===401}paymentRequired(){return this.status===402}forbidden(){return this.status===403}notFound(){return this.status===404}requestTimeout(){return this.status===408}conflict(){return this.status===409}unprocessableEntity(){return this.status===422}tooManyRequests(){return this.status===429}serverError(){return this.status===500}}class Fetcher{defaultHeaders={Accept:"application/json","Content-Type":"application/json"};queryParams;bodyData;isFormData=!1;customHeaders={};attachments=[];isMultipart=!1;digestAuth;withQueryParams(params){this.queryParams=params;return this}withHeaders(headers){for(const[name,value]of Object.entries(headers))this.customHeaders[name.toLowerCase()]=value;return this}accept(contentType){this.defaultHeaders.Accept=contentType;return this}acceptJson(){return this.accept("application/json")}withToken(token){this.customHeaders.authorization=`Bearer ${token}`;return this}withBasicAuth(username,password){const credentials=btoa(`${username}:${password}`);this.customHeaders.authorization=`Basic ${credentials}`;return this}withDigestAuth(username,password){this.digestAuth={username,password};return this}withBody(data){this.bodyData=data;return this}asForm(){this.isFormData=!0;this.defaultHeaders["Content-Type"]="application/x-www-form-urlencoded";return this}attach(name,content,filename,headers){const blobContent=typeof content==="string"?new Blob([content],{type:headers?.["Content-Type"]||"text/plain"}):content;this.attachments.push({name,content:blobContent,filename,headers});this.isMultipart=!0;delete this.defaultHeaders["Content-Type"];return this}getHeaders(){const headers=new Headers(this.defaultHeaders);for(const[name,value]of Object.entries(this.customHeaders))headers.set(name,value);return headers}addQueryParams(url){if(!this.queryParams)return url;return withQuery(url,this.queryParams)}async formatBody(body){const finalBody=body||this.bodyData;if(this.isMultipart){const formData=new FormData;if(finalBody)Object.entries(finalBody).forEach(([key,value])=>{formData.append(key,String(value))});for(const attachment of this.attachments)if(attachment.headers){const blob=attachment.content instanceof Blob?new Blob([attachment.content],{type:attachment.headers["Content-Type"]}):attachment.content;formData.append(attachment.name,blob,attachment.filename)}else formData.append(attachment.name,attachment.content,attachment.filename);return formData}if(this.isFormData&&finalBody){const formData=new URLSearchParams;Object.entries(finalBody).forEach(([key,value])=>{if(value!=null)formData.append(key,String(value))});return formData.toString()}return finalBody?JSON.stringify(finalBody):void 0}resetState(){this.queryParams=void 0;this.bodyData=void 0;this.isFormData=!1;this.isMultipart=!1;this.customHeaders={};this.attachments=[];this.digestAuth=void 0;this.defaultHeaders={Accept:"application/json","Content-Type":"application/json"}}request(url,method,body){const owned=new Fetcher;Object.assign(owned,this,{defaultHeaders:{...this.defaultHeaders},customHeaders:{...this.customHeaders},attachments:[...this.attachments],digestAuth:this.digestAuth?{...this.digestAuth}:void 0});this.resetState();return owned.perform(url,method,body)}async perform(url,method,body){const urlWithParams=this.addQueryParams(url),formattedBody=await this.formatBody(body),headers=this.getHeaders();if(this.isMultipart)headers.delete("content-type");const prepared=new Request(urlWithParams,{method,headers,body:formattedBody});let response;if(this.digestAuth){const bytes=prepared.body?new Uint8Array(await prepared.arrayBuffer()):void 0,send=(requestHeaders)=>fetch(prepared.url,{method,headers:requestHeaders,body:bytes,credentials:"omit"});response=await send(prepared.headers);const target=new URL(prepared.url);target.hash="";const respondedAt=new URL(response.url);respondedAt.hash="";if(response.status===401&&respondedAt.href===target.href){const challenge=response.headers.get("www-authenticate");if(challenge){const{digestAuthorization}=await import("./digest"),authorization=await digestAuthorization(challenge,this.digestAuth,method,target.href,bytes??new Uint8Array);if(authorization){await response.body?.cancel();const authenticated=new Headers(prepared.headers);authenticated.set("authorization",authorization);response=await send(authenticated)}}}}else response=await fetch(prepared);const text=await response.text(),contentType=response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()??"",json=contentType==="application/json"||contentType.endsWith("+json"),data=text===""?null:json?JSON.parse(text):text;this.resetState();return new FetcherResponseImpl(data,response.status,response.headers,response.ok)}async get(url){return this.request(url,"GET")}async post(url,body){return this.request(url,"POST",body)}async put(url,body){return this.request(url,"PUT",body)}async patch(url,body){return this.request(url,"PATCH",body)}async delete(url){return this.request(url,"DELETE")}}export const fetcher=new Fetcher;
|
|
@@ -12,11 +12,4 @@ import type { OpenApiDocument } from './generate-types';
|
|
|
12
12
|
* numbering the second one tells the reader nothing.
|
|
13
13
|
*/
|
|
14
14
|
export declare function methodName(operationId: string): string;
|
|
15
|
-
/**
|
|
16
|
-
* Render the client for a document.
|
|
17
|
-
*
|
|
18
|
-
* Exported separately from the file writing so it can be tested on a document
|
|
19
|
-
* built in memory, which is the only way to assert what it does with a shape
|
|
20
|
-
* the current app happens not to have.
|
|
21
|
-
*/
|
|
22
15
|
export declare function renderApiClient(document: OpenApiDocument, options?: { name?: string }): string;
|
package/dist/generate-client.js
CHANGED
|
@@ -1,108 +1,21 @@
|
|
|
1
|
-
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";import{schemaType}from"./generate-types";const METHODS=["get","put","post","delete","options","head","patch"];export function methodName(operationId){const parts=operationId.replace(/[{}]/g,"").split(/[^a-z0-9]+/i).filter(Boolean),trimmed=parts[0]?.toLowerCase()==="api"?parts.slice(1):parts[1]?.toLowerCase()==="api"?[parts[0],...parts.slice(2)]:parts,[first,...rest]=trimmed;if(!first)return"call";const head=first.charAt(0).toLowerCase()+first.slice(1),tail=rest.map((part)=>part.charAt(0).toUpperCase()+part.slice(1)),name=[head,...tail].join("");return/^[a-z_$]/i.test(name)?name:`call${name.charAt(0).toUpperCase()}${name.slice(1)}`}function successType(operation){const
|
|
1
|
+
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";import{clientRuntime}from"./client-runtime";import{operationParameters,renderSchemaComponents,resolveOpenApiReference,schemaType}from"./generate-types";const METHODS=["get","put","post","delete","options","head","patch"];export function methodName(operationId){const parts=operationId.replace(/[{}]/g,"").split(/[^a-z0-9]+/i).filter(Boolean),trimmed=parts[0]?.toLowerCase()==="api"?parts.slice(1):parts[1]?.toLowerCase()==="api"?[parts[0],...parts.slice(2)]:parts,[first,...rest]=trimmed;if(!first)return"call";const head=first.charAt(0).toLowerCase()+first.slice(1),tail=rest.map((part)=>part.charAt(0).toUpperCase()+part.slice(1)),name=[head,...tail].join("");return/^[a-z_$]/i.test(name)?name:`call${name.charAt(0).toUpperCase()}${name.slice(1)}`}function successType(operation,document,method){if(method==="head")return"null";const types=[];for(const[status,entry]of Object.entries(operation.responses??{})){if(!/^2(?:\d\d|XX)$/i.test(status))continue;const response=resolveOpenApiReference(entry,document);if(status==="204"||status==="205"||!Object.keys(response.content??{}).length)types.push("null");else for(const media of Object.values(response.content??{}))types.push(schemaType(media.schema,{document,direction:"response"}))}return types.includes("unknown")?"unknown":[...new Set(types)].join(" | ")||"unknown"}function comment(lines,indent=" "){const kept=lines.filter(Boolean).flatMap((line)=>line.replace(/\*\//g,"*\\/").split(/\r?\n/));return kept.length?`${indent}/**
|
|
2
2
|
${kept.map((line)=>`${indent} * ${line}`).join(`
|
|
3
3
|
`)}
|
|
4
4
|
${indent} */
|
|
5
|
-
|
|
6
|
-
return request(config, ${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(route)}, ${fields.length?"input ?? {}":"{}"}, ${
|
|
7
|
-
},`}export function renderApiClient(document,options={}){const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* Do not edit. Run \`buddy generate:openapi\` to rebuild it from the routes.
|
|
12
|
-
*
|
|
13
|
-
* No imports and no dependencies: copy this file anywhere that has \`fetch\`.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Every call answers with one of these. Nothing here throws for a 4xx. */
|
|
17
|
-
export type ApiResult<T> =
|
|
18
|
-
| { ok: true, status: number, data: T, headers: Headers }
|
|
19
|
-
| { ok: false, status: number, error: unknown, headers: Headers }
|
|
20
|
-
|
|
21
|
-
export interface ClientConfig {
|
|
22
|
-
/** Where the API lives, e.g. \`https://example.com\`. No trailing slash needed. */
|
|
23
|
-
baseUrl: string
|
|
24
|
-
/** Sent as \`Authorization: Bearer \u2026\` when present. */
|
|
25
|
-
token?: string
|
|
26
|
-
/** Extra headers on every request. */
|
|
27
|
-
headers?: Record<string, string>
|
|
28
|
-
/** Swap in a different fetch - a test double, or one that retries. */
|
|
29
|
-
fetch?: typeof fetch
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface RequestOptions {
|
|
33
|
-
/** Abort in flight. */
|
|
34
|
-
signal?: AbortSignal
|
|
35
|
-
/** Headers for this call only, merged over the client's. */
|
|
36
|
-
headers?: Record<string, string>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function buildUrl(config: ClientConfig, route: string, input: Record<string, unknown>, query: string[]): string {
|
|
40
|
-
// Path parameters are substituted, never appended: a \`{id}\` left in the URL
|
|
41
|
-
// is a 404 whose message is about the literal string "{id}".
|
|
42
|
-
const filled = route.replace(/\\{(\\w+)\\}/g, (_, key: string) => encodeURIComponent(String(input[key] ?? '')))
|
|
43
|
-
const url = new URL(filled.replace(/^\\/+/, '/'), config.baseUrl.endsWith('/') ? config.baseUrl : \`\${config.baseUrl}/\`)
|
|
44
|
-
|
|
45
|
-
for (const key of query) {
|
|
46
|
-
const value = input[key]
|
|
47
|
-
// Absent means absent. Sending \`?path=\` asks for the file called empty
|
|
48
|
-
// string, which is a different question from not filtering.
|
|
49
|
-
if (value === undefined || value === null) continue
|
|
50
|
-
url.searchParams.set(key, String(value))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return url.toString()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function request<T>(
|
|
57
|
-
config: ClientConfig,
|
|
58
|
-
method: string,
|
|
59
|
-
route: string,
|
|
60
|
-
input: Record<string, unknown>,
|
|
61
|
-
query: string[],
|
|
62
|
-
hasBody: boolean,
|
|
63
|
-
options?: RequestOptions,
|
|
64
|
-
): Promise<ApiResult<T>> {
|
|
65
|
-
const call = config.fetch ?? fetch
|
|
66
|
-
const url = buildUrl(config, route, input, query)
|
|
67
|
-
|
|
68
|
-
const headers: Record<string, string> = {
|
|
69
|
-
Accept: 'application/json',
|
|
70
|
-
...(config.headers ?? {}),
|
|
71
|
-
...(options?.headers ?? {}),
|
|
72
|
-
}
|
|
73
|
-
if (config.token) headers.Authorization = \`Bearer \${config.token}\`
|
|
74
|
-
|
|
75
|
-
const body = hasBody && input.body !== undefined ? JSON.stringify(input.body) : undefined
|
|
76
|
-
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
77
|
-
|
|
78
|
-
const response = await call(url, { method, headers, body, signal: options?.signal })
|
|
79
|
-
|
|
80
|
-
const text = await response.text()
|
|
81
|
-
let parsed: unknown = text
|
|
82
|
-
if (text.length) {
|
|
83
|
-
// A non-JSON body is kept verbatim rather than replaced with a parse error.
|
|
84
|
-
// An endpoint that answered with HTML is worth seeing.
|
|
85
|
-
try { parsed = JSON.parse(text) } catch { parsed = text }
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
parsed = null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return response.ok
|
|
92
|
-
? { ok: true, status: response.status, data: parsed as T, headers: response.headers }
|
|
93
|
-
: { ok: false, status: response.status, error: parsed, headers: response.headers }
|
|
94
|
-
}
|
|
5
|
+
`:""}function renderOperation(route,method,operation,item,name,document){const parameters=operationParameters(item,operation,document),body=operation.requestBody&&resolveOpenApiReference(operation.requestBody,document),pathAndQuery=parameters.filter((parameter)=>parameter.in==="path"||parameter.in==="query"),names=pathAndQuery.map((parameter)=>parameter.name),grouped=new Set(names).size!==names.length||Boolean(body)&&names.includes("body")||parameters.some((parameter)=>parameter.in==="header")&&names.includes("headers"),descriptors=parameters.map((parameter)=>{const styles=parameter.in==="query"?["form","deepObject","spaceDelimited","pipeDelimited"]:["simple"];if(!["path","query","header"].includes(parameter.in)||parameter.style&&!styles.includes(parameter.style))throw TypeError(`Unsupported parameter serialization: ${parameter.in} ${parameter.style??""}`);return{name:parameter.name,in:parameter.in,required:parameter.in==="path"||Boolean(parameter.required),...parameter.in==="header"?{group:"headers"}:grouped?{group:parameter.in}:{},...parameter.style?{style:parameter.style}:{},...parameter.explode!==void 0?{explode:parameter.explode}:{}}}),fields=[],required=(parameter)=>parameter.in==="path"||parameter.required,field=(parameter)=>`${JSON.stringify(parameter.name)}${required(parameter)?"":"?"}: ${schemaType(parameter.schema,{document,direction:"request"})}`;if(!grouped)fields.push(...pathAndQuery.map(field));for(const location of[...grouped?["path","query"]:[],"header"]){const entries=parameters.filter((parameter)=>parameter.in===location);if(entries.length)fields.push(`${location==="header"?"headers":location}${entries.some(required)?"":"?"}: { ${entries.map(field).join("; ")} }`)}const content=Object.keys(body?.content??{});if(body&&!content.length)throw TypeError("Request body must declare a media type");if(body){const bodyTypes=Object.entries(body.content??{}).map(([type,media])=>{const shape=schemaType(media.schema,{document,direction:"request"});return type==="multipart/form-data"?`${shape} | FormData`:shape});fields.push(`body${body.required?"":"?"}: ${[...new Set(bodyTypes)].join(" | ")}`)}const everyFieldOptional=!parameters.some(required)&&!body?.required,argument=fields.length?`input${everyFieldOptional?"?":""}: { ${fields.join("; ")} }, `:"",documentation=comment([operation.summary&&operation.summary!==route?operation.summary:`${method.toUpperCase()} ${route}`,operation.description??""]),accept=[...new Set(Object.values(operation.responses??{}).flatMap((entry)=>Object.keys(resolveOpenApiReference(entry,document).content??{})))].join(", ")||"application/json",binary=[...new Set(Object.values(operation.responses??{}).flatMap((entry)=>Object.entries(resolveOpenApiReference(entry,document).content??{}).filter(([,media])=>{const schema=resolveOpenApiReference(media.schema??{},document);return schema.type==="string"&&schema.format==="binary"}).map(([type])=>type.split(";")[0].trim().toLowerCase())))];return`${documentation} ${name}(${argument}options?: RequestOptions): Promise<ApiResult<${successType(operation,document,method)}>> {
|
|
6
|
+
return request(config, ${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(route)}, ${fields.length?"input ?? {}":"{}"}, ${JSON.stringify(descriptors)}, ${body?JSON.stringify({required:Boolean(body.required),content}):"null"}, ${JSON.stringify(accept)}, ${JSON.stringify(binary)}, options)
|
|
7
|
+
},`}export function renderApiClient(document,options={}){const operations=Object.entries(document.paths??{}).flatMap(([route,item])=>METHODS.flatMap((method)=>{const operation=item[method];return operation?[{route,item,method,operation,preferred:methodName(operation.operationId??`${method}_${route}`)}]:[]})),reserved=new Set(["config","then","constructor","prototype","__proto__"]),preferred=new Set(operations.map((operation)=>operation.preferred)),used=new Set(reserved),methods=operations.map((entry)=>{let name=entry.preferred,suffix=2;if(used.has(name))do name=`${entry.preferred}${suffix++}`;while(used.has(name)||preferred.has(name));used.add(name);return renderOperation(entry.route,entry.method,entry.operation,entry.item,name,document)});return`/* eslint-disable */
|
|
8
|
+
${comment([`${options.name??"API"} client, generated from OpenAPI.`,"Do not edit; run buddy generate:openapi.","No imports or dependencies."],"")}
|
|
9
|
+
${clientRuntime}
|
|
10
|
+
${renderSchemaComponents(document)}
|
|
95
11
|
|
|
96
12
|
export function createClient(config: ClientConfig) {
|
|
97
13
|
return {
|
|
98
|
-
/** The configuration in use, so a caller can rebuild a variant of it. */
|
|
99
14
|
config,
|
|
100
|
-
|
|
101
15
|
${methods.join(`
|
|
102
16
|
|
|
103
17
|
`)}
|
|
104
18
|
}
|
|
105
19
|
}
|
|
106
|
-
|
|
107
20
|
export type Client = ReturnType<typeof createClient>
|
|
108
21
|
`}if(import.meta.main){const apiRoot=resolve(import.meta.dir,"../../../api"),input=resolve(process.argv[2]||resolve(apiRoot,"openapi.json")),output=resolve(process.argv[3]||resolve(apiRoot,"client.ts")),document=JSON.parse(readFileSync(input,"utf8"));writeFileSync(output,renderApiClient(document));console.log(`Generated ${output}`)}
|
package/dist/generate-types.d.ts
CHANGED
|
@@ -1,27 +1,70 @@
|
|
|
1
|
-
|
|
1
|
+
/** Local JSON references only. Missing, external and cyclic aliases fail during generation. */
|
|
2
|
+
export declare function resolveOpenApiReference<T extends object>(value: T | OpenApiReference, document: OpenApiDocument): T;
|
|
3
|
+
/** Parameters are unique by location and name; operation declarations override inherited ones. */
|
|
4
|
+
export declare function operationParameters(item: OpenApiPathItem, operation: OpenApiOperation, document: OpenApiDocument): OpenApiParameter[];
|
|
5
|
+
/** Render schemas for their use in a request or response, preserving recursive component references. */
|
|
6
|
+
export declare function schemaType(schema?: SchemaObject, options?: SchemaTypeOptions): string;
|
|
7
|
+
export declare function renderSchemaComponents(document: OpenApiDocument): string;
|
|
2
8
|
export declare function renderOpenApiTypes(document: OpenApiDocument): string;
|
|
3
9
|
export declare interface SchemaObject {
|
|
4
10
|
[key: string]: unknown
|
|
11
|
+
$ref?: string
|
|
5
12
|
type?: string
|
|
13
|
+
nullable?: boolean
|
|
14
|
+
readOnly?: boolean
|
|
15
|
+
writeOnly?: boolean
|
|
6
16
|
enum?: unknown[]
|
|
7
17
|
properties?: Record<string, SchemaObject>
|
|
8
18
|
required?: string[]
|
|
9
19
|
items?: SchemaObject
|
|
10
20
|
additionalProperties?: boolean | SchemaObject
|
|
21
|
+
oneOf?: SchemaObject[]
|
|
22
|
+
anyOf?: SchemaObject[]
|
|
23
|
+
allOf?: SchemaObject[]
|
|
11
24
|
}
|
|
12
|
-
declare interface
|
|
25
|
+
export declare interface OpenApiReference { $ref: string }
|
|
26
|
+
export declare interface OpenApiParameter {
|
|
13
27
|
name: string
|
|
14
28
|
in: string
|
|
15
29
|
required?: boolean
|
|
16
30
|
schema?: SchemaObject
|
|
31
|
+
description?: string
|
|
32
|
+
style?: string
|
|
33
|
+
explode?: boolean
|
|
17
34
|
}
|
|
18
|
-
declare interface
|
|
35
|
+
export declare interface OpenApiMedia { schema?: SchemaObject }
|
|
36
|
+
export declare interface OpenApiRequestBody { required?: boolean, content?: Record<string, OpenApiMedia> }
|
|
37
|
+
export declare interface OpenApiResponse { description?: string, content?: Record<string, OpenApiMedia> }
|
|
38
|
+
export declare interface OpenApiOperation {
|
|
19
39
|
operationId?: string
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
summary?: string
|
|
41
|
+
description?: string
|
|
42
|
+
parameters?: Array<OpenApiParameter | OpenApiReference>
|
|
43
|
+
requestBody?: OpenApiRequestBody | OpenApiReference
|
|
44
|
+
responses?: Record<string, OpenApiResponse | OpenApiReference>
|
|
45
|
+
}
|
|
46
|
+
export declare interface OpenApiPathItem {
|
|
47
|
+
[key: string]: unknown
|
|
48
|
+
parameters?: Array<OpenApiParameter | OpenApiReference>
|
|
49
|
+
get?: OpenApiOperation
|
|
50
|
+
put?: OpenApiOperation
|
|
51
|
+
post?: OpenApiOperation
|
|
52
|
+
delete?: OpenApiOperation
|
|
53
|
+
options?: OpenApiOperation
|
|
54
|
+
head?: OpenApiOperation
|
|
55
|
+
patch?: OpenApiOperation
|
|
56
|
+
trace?: OpenApiOperation
|
|
23
57
|
}
|
|
24
58
|
export declare interface OpenApiDocument {
|
|
25
|
-
paths?: Record<string,
|
|
26
|
-
components?: {
|
|
59
|
+
paths?: Record<string, OpenApiPathItem>
|
|
60
|
+
components?: {
|
|
61
|
+
schemas?: Record<string, SchemaObject>
|
|
62
|
+
parameters?: Record<string, OpenApiParameter | OpenApiReference>
|
|
63
|
+
requestBodies?: Record<string, OpenApiRequestBody | OpenApiReference>
|
|
64
|
+
responses?: Record<string, OpenApiResponse | OpenApiReference>
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export declare interface SchemaTypeOptions {
|
|
68
|
+
document?: OpenApiDocument
|
|
69
|
+
direction?: 'request' | 'response'
|
|
27
70
|
}
|
package/dist/generate-types.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";const methods=["get","put","post","delete","options","head","patch","trace"];function literal(value){return
|
|
2
|
-
|
|
1
|
+
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";const methods=["get","put","post","delete","options","head","patch","trace"];export function resolveOpenApiReference(value,document){const visited=new Set;let current=value;while(current&&typeof current==="object"&&"$ref"in current){const ref=current.$ref;if(!ref.startsWith("#/")||visited.has(ref))throw TypeError(`Unsupported or cyclic OpenAPI reference: ${ref}`);visited.add(ref);let target=document;for(const key of decodeURIComponent(ref.slice(2)).split("/").map((part)=>part.replace(/~1/g,"/").replace(/~0/g,"~"))){if(!target||typeof target!=="object"||!Object.hasOwn(target,key))throw TypeError(`Missing OpenAPI reference: ${ref}`);target=target[key]}current=target}if(!current||typeof current!=="object"||Array.isArray(current))throw TypeError("OpenAPI reference must resolve to an object");return current}export function operationParameters(item,operation,document){const parameters=new Map;for(const entry of[...item.parameters??[],...operation.parameters??[]]){const parameter=resolveOpenApiReference(entry,document),name=parameter.in==="header"?parameter.name.toLowerCase():parameter.name;parameters.set(`${parameter.in}:${name}`,parameter)}return[...parameters.values()]}function literal(value,ancestors=new Set){if(value===null)return"null";if(typeof value==="string")return JSON.stringify(value);if(typeof value==="number"&&Number.isFinite(value))return String(value);if(typeof value==="boolean")return String(value);if(!value||typeof value!=="object"||ancestors.has(value))throw TypeError("OpenAPI enums must contain finite JSON values");ancestors.add(value);try{if(Array.isArray(value))return`[${value.map((item)=>literal(item,ancestors)).join(", ")}]`;return`{ ${Object.entries(value).map(([key,item])=>`${JSON.stringify(key)}: ${literal(item,ancestors)}`).join("; ")} }`}finally{ancestors.delete(value)}}function union(types){return types.includes("unknown")?"unknown":[...new Set(types)].join(" | ")||"never"}export function schemaType(schema,options={}){return renderSchema(schema,options,new Set)}function renderSchema(schema,options,ancestors){if(!schema)return"unknown";if(ancestors.has(schema))throw TypeError("Recursive schemas must use component references");ancestors.add(schema);const render=(value)=>renderSchema(value,options,ancestors);try{if(schema.$ref){if(!options.document||!schema.$ref.startsWith("#/components/schemas/"))throw TypeError(`Unsupported schema reference: ${schema.$ref}`);resolveOpenApiReference(schema,options.document);const pointer=decodeURIComponent(schema.$ref.slice(21));if(pointer.includes("/"))return render(resolveOpenApiReference(schema,options.document));const name=pointer.replace(/~1/g,"/").replace(/~0/g,"~");return`components[${JSON.stringify(options.direction==="request"?"requestSchemas":"schemas")}][${JSON.stringify(name)}]`}if(schema.enum?.length)return union(schema.enum.map((value)=>literal(value)));let base;if(schema.type==="string")base=schema.format==="binary"?"Blob":"string";else if(schema.type==="number"||schema.type==="integer")base="number";else if(schema.type==="boolean")base="boolean";else if(schema.type==="null")base="null";else if(schema.type==="array")base=`Array<${render(schema.items)}>`;else if(schema.type==="object"||schema.properties||schema.additionalProperties!==void 0){const required=new Set(schema.required??[]),propertyTypes=Object.entries(schema.properties??{}).filter(([,value])=>{const resolved=value.$ref&&options.document?resolveOpenApiReference(value,options.document):value;return!(options.direction==="request"&&resolved.readOnly)&&!(options.direction==="response"&&resolved.writeOnly)}).map(([name,value])=>({name,type:render(value),optional:!required.has(name)})),fields=propertyTypes.map(({name,type,optional})=>`${JSON.stringify(name)}${optional?"?":""}: ${type}`);if(schema.additionalProperties){const extra=schema.additionalProperties===!0?"unknown":render(schema.additionalProperties),index=union([extra,...propertyTypes.map((field)=>field.type),...propertyTypes.some((field)=>field.optional)?["undefined"]:[]]);fields.push(`[key: string]: ${index}`)}base=fields.length?`{ ${fields.join("; ")} }`:schema.additionalProperties===!1?"Record<string, never>":"Record<string, unknown>"}const composed=[];if(base)composed.push(base);for(const variants of[schema.oneOf,schema.anyOf])if(variants?.length)composed.push(union(variants.map(render)));if(schema.allOf?.length)composed.push(...schema.allOf.map(render));const result=composed.length>1?composed.map((type)=>`(${type})`).join(" & "):composed[0]??"unknown";return schema.nullable&&schema.type?union([result,"null"]):result}finally{ancestors.delete(schema)}}export function renderSchemaComponents(document){const map=(direction)=>Object.entries(document.components?.schemas??{}).map(([name,schema])=>` ${JSON.stringify(name)}: ${schemaType(schema,{document,direction})}`).join(`
|
|
2
|
+
`);return`export interface components {
|
|
3
|
+
schemas: {
|
|
4
|
+
${map("response")}
|
|
5
|
+
}
|
|
6
|
+
requestSchemas: {
|
|
7
|
+
${map("request")}
|
|
8
|
+
}
|
|
9
|
+
}`}function parameterGroups(parameters,document){return` parameters: {
|
|
10
|
+
${["query","header","path","cookie"].map((location)=>{const entries=parameters.filter((parameter)=>parameter.in===location);if(!entries.length)return` ${location}?: never`;const required=(parameter)=>parameter.in==="path"||parameter.required,fields=entries.map((parameter)=>`${JSON.stringify(parameter.name)}${required(parameter)?"":"?"}: ${schemaType(parameter.schema,{document,direction:"request"})}`);return` ${location}${entries.every((parameter)=>!required(parameter))?"?":""}: { ${fields.join("; ")} }`}).join(`
|
|
3
11
|
`)}
|
|
4
|
-
}`}function contentTypes(content){if(!content||!Object.keys(content).length)return"never";return`{ ${Object.entries(content).map(([type,value])=>`${JSON.stringify(type)}: ${schemaType(value.schema)}`).join("; ")} }`}function renderOperation(operation){const
|
|
5
|
-
requestBody${
|
|
6
|
-
requestBody?: never`,responses=Object.entries(operation.responses
|
|
7
|
-
${parameterGroups(operation
|
|
12
|
+
}`}function contentTypes(content,document,direction){if(!content||!Object.keys(content).length)return"never";return`{ ${Object.entries(content).map(([type,value])=>`${JSON.stringify(type)}: ${schemaType(value.schema,{document,direction})}`).join("; ")} }`}function renderOperation(item,operation,document){const body=operation.requestBody&&resolveOpenApiReference(operation.requestBody,document),requestBody=body?`
|
|
13
|
+
requestBody${body.required?"":"?"}: { content: ${contentTypes(body.content,document,"request")} }`:`
|
|
14
|
+
requestBody?: never`,responses=Object.entries(operation.responses??{}).map(([status,entry])=>{const response=resolveOpenApiReference(entry,document);return` ${JSON.stringify(status)}: { content: ${contentTypes(response.content,document,"response")} }`});return`{
|
|
15
|
+
${parameterGroups(operationParameters(item,operation,document),document)}${requestBody}
|
|
8
16
|
responses: {
|
|
9
17
|
${responses.join(`
|
|
10
18
|
`)}
|
|
11
19
|
}
|
|
12
|
-
}`}export function renderOpenApiTypes(document){
|
|
13
|
-
${operations.join(`
|
|
14
|
-
`)}
|
|
15
|
-
}`}),schemas=Object.entries(document.components?.schemas||{}).map(([name,schema])=>` ${JSON.stringify(name)}: ${schemaType(schema)}`);return`/**
|
|
16
|
-
* Generated from storage/framework/api/openapi.json by @stacksjs/api.
|
|
17
|
-
* Do not edit this file directly; run bun run docs:artifacts to regenerate it.
|
|
18
|
-
*/
|
|
20
|
+
}`}export function renderOpenApiTypes(document){return`/** Generated from OpenAPI. Do not edit; run buddy generate:openapi. */
|
|
19
21
|
|
|
20
22
|
export interface paths {
|
|
21
|
-
${paths.
|
|
23
|
+
${Object.entries(document.paths??{}).map(([route,item])=>{const operations=methods.map((method)=>{const operation=item[method];return operation?` ${method}: ${renderOperation(item,operation,document)}`:` ${method}?: never`});return` ${JSON.stringify(route)}: {
|
|
24
|
+
${operations.join(`
|
|
22
25
|
`)}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface components {
|
|
26
|
-
schemas: {
|
|
27
|
-
${schemas.join(`
|
|
26
|
+
}`}).join(`
|
|
28
27
|
`)}
|
|
29
|
-
}
|
|
30
28
|
}
|
|
29
|
+
|
|
30
|
+
${renderSchemaComponents(document)}
|
|
31
31
|
`}if(import.meta.main){const apiRoot=resolve(import.meta.dir,"../../../api"),input=resolve(process.argv[2]||resolve(apiRoot,"openapi.json")),output=resolve(process.argv[3]||resolve(apiRoot,"api-types.ts")),document=JSON.parse(readFileSync(input,"utf8"));writeFileSync(output,renderOpenApiTypes(document));console.log(`Generated ${output}`)}
|
package/dist/query.d.ts
ADDED
package/dist/query.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function withQuery(url,values,replace=!1){const hashIndex=url.indexOf("#"),fragment=hashIndex<0?"":url.slice(hashIndex),head=hashIndex<0?url:url.slice(0,hashIndex),queryIndex=head.indexOf("?"),path=queryIndex<0?head:head.slice(0,queryIndex),params=new URLSearchParams(queryIndex<0?"":head.slice(queryIndex+1));for(const[name,value]of Object.entries(values))if(value!=null)if(replace)params.set(name,String(value));else params.append(name,String(value));const query=params.toString();return path+(query?`?${query}`:"")+fragment}
|
package/dist/resource.d.ts
CHANGED
|
@@ -32,29 +32,7 @@ export declare interface PaginatedData<T> {
|
|
|
32
32
|
meta: PaginationMeta
|
|
33
33
|
links: PaginationLinks
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
* API Resources
|
|
37
|
-
*
|
|
38
|
-
* Laravel-like API resources for transforming models and collections
|
|
39
|
-
* into JSON responses with fine-grained control.
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* class UserResource extends JsonResource<User> {
|
|
43
|
-
* toArray() {
|
|
44
|
-
* return {
|
|
45
|
-
* id: this.resource.id,
|
|
46
|
-
* name: this.resource.name,
|
|
47
|
-
* email: this.resource.email,
|
|
48
|
-
* posts: PostResource.collection(this.whenLoaded('posts')),
|
|
49
|
-
* created_at: this.resource.created_at,
|
|
50
|
-
* }
|
|
51
|
-
* }
|
|
52
|
-
* }
|
|
53
|
-
*
|
|
54
|
-
* // Usage
|
|
55
|
-
* return new UserResource(user).toResponse()
|
|
56
|
-
* return UserResource.collection(users).toResponse()
|
|
57
|
-
*/
|
|
35
|
+
declare interface ResolutionContext { depth: number, ancestors: WeakSet<object> }
|
|
58
36
|
declare type Request = Record<string, unknown>;
|
|
59
37
|
/**
|
|
60
38
|
* Wrapper value types for conditional inclusion
|
|
@@ -91,6 +69,7 @@ export declare abstract class JsonResource<T = unknown> {
|
|
|
91
69
|
withRequest(request: Request): this;
|
|
92
70
|
withAdditional(data: Record<string, any>): this;
|
|
93
71
|
resolve(request?: Request): Record<string, any>;
|
|
72
|
+
resolveAt(request: Request | undefined, context: ResolutionContext): Record<string, any>;
|
|
94
73
|
toResponse(request?: Request): Record<string, any>;
|
|
95
74
|
toJson(request?: Request): string;
|
|
96
75
|
toJSON(): Record<string, unknown>;
|
|
@@ -99,8 +78,8 @@ export declare abstract class JsonResource<T = unknown> {
|
|
|
99
78
|
protected whenNotNull<V>(value: V | null | undefined, transform?: (v: V) => any): any | MissingValue;
|
|
100
79
|
protected whenLoaded<V>(relationship: string, value?: V | (() => V), defaultValue?: any): V | any[] | MissingValue;
|
|
101
80
|
protected whenCounted(relationship: string, defaultValue?: number): number | MissingValue;
|
|
102
|
-
protected merge(data: Record<string, any>):
|
|
103
|
-
protected mergeWhen(condition: boolean | (() => boolean), data: Record<string, any> | (() => Record<string, any>)):
|
|
81
|
+
protected merge(data: Record<string, any>): Record<string, any>;
|
|
82
|
+
protected mergeWhen(condition: boolean | (() => boolean), data: Record<string, any> | (() => Record<string, any>)): Record<string, any> | MissingValue;
|
|
104
83
|
static withoutWrapping(): void;
|
|
105
84
|
static wrapWith(key: string): void;
|
|
106
85
|
}
|
|
@@ -119,8 +98,10 @@ export declare class ResourceCollection<T = unknown, R extends JsonResource<T> =
|
|
|
119
98
|
withRequest(request: Request): this;
|
|
120
99
|
withAdditional(data: Record<string, any>): this;
|
|
121
100
|
resolve(request?: Request): Record<string, any>[];
|
|
101
|
+
resolveAt(request: Request | undefined, context: ResolutionContext): Record<string, any>[];
|
|
122
102
|
toResponse(request?: Request): Record<string, any>;
|
|
123
103
|
toJson(request?: Request): string;
|
|
104
|
+
toJSON(): Record<string, unknown>;
|
|
124
105
|
count(): number;
|
|
125
106
|
isEmpty(): boolean;
|
|
126
107
|
isNotEmpty(): boolean;
|
package/dist/resource.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export class MissingValue{static instance=new MissingValue;isMissing(){return!0}}export class MergeValue{data;constructor(data){this.data=data}}export class ConditionalValue{condition;value;defaultValue;constructor(condition,value,defaultValue=new MissingValue){this.condition=condition;this.value=value;this.defaultValue=defaultValue}resolve(){return(typeof this.condition==="function"?this.condition():this.condition)?this.value:this.defaultValue}}export class JsonResource{resource;additional={};request;static wrap="data";static additionalMeta={};constructor(resource){this.resource=resource}withRequest(request){this.request=request;return this}withAdditional(data){this.additional={...this.additional,...data};return this}resolve(request){
|
|
1
|
+
import{toPaginator}from"@stacksjs/pagination";import{withQuery}from"./query";export class MissingValue{static instance=new MissingValue;isMissing(){return!0}}export class MergeValue{data;constructor(data){this.data=data}}export class ConditionalValue{condition;value;defaultValue;constructor(condition,value,defaultValue=new MissingValue){this.condition=condition;this.value=value;this.defaultValue=defaultValue}resolve(){return(typeof this.condition==="function"?this.condition():this.condition)?this.value:this.defaultValue}}const mergePayloads=new WeakMap,MAX_RESOLVE_DEPTH=20;function resolveValue(value,request,context){if(value instanceof MissingValue||value===null||typeof value!=="object"||value instanceof Date)return value;if(context.depth>=MAX_RESOLVE_DEPTH)throw RangeError("Resource serialization exceeded the maximum depth");if(context.ancestors.has(value))throw TypeError("Resource serialization contains a cycle");context.ancestors.add(value);const next={depth:context.depth+1,ancestors:context.ancestors};try{if(value instanceof ConditionalValue)return resolveValue(value.resolve(),request,next);if(value instanceof MergeValue)return resolveValue(value.data,request,next);const merge=mergePayloads.get(value);if(merge)return resolveValue(merge,request,next);if(value instanceof JsonResource||value instanceof ResourceCollection)return value.resolveAt(request,next);if(Array.isArray(value)){const result=[];for(const item of value){const resolved=resolveValue(item,request,next);if(!(resolved instanceof MissingValue))result.push(resolved)}return result}if("toJSON"in value&&typeof value.toJSON==="function")return resolveValue(value.toJSON(),request,next);const entries=[];for(const[key,item]of Object.entries(value)){const resolved=resolveValue(item,request,next);if(resolved instanceof MissingValue)continue;if(item instanceof MergeValue||item!==null&&typeof item==="object"&&mergePayloads.has(item))entries.push(...Object.entries(resolved));else entries.push([key,resolved])}return Object.fromEntries(entries)}finally{context.ancestors.delete(value)}}export class JsonResource{resource;additional={};request;static wrap="data";static additionalMeta={};constructor(resource){this.resource=resource}withRequest(request){this.request=request;return this}withAdditional(data){this.additional={...this.additional,...data};return this}resolve(request){return resolveValue(this,request,{depth:0,ancestors:new WeakSet})}resolveAt(request,context){const req=request??this.request;return resolveValue(this.toArray(req),req,context)}toResponse(request){const data=this.resolve(request),wrap=this.constructor.wrap;if(wrap)return{[wrap]:data,...this.additional};return{...data,...this.additional}}toJson(request){return JSON.stringify(this.toResponse(request))}toJSON(){return this.toResponse()}static collection(resources){return new ResourceCollection(resources,this)}when(condition,value,defaultValue){if(typeof condition==="function"?condition():condition)return typeof value==="function"?value():value;return defaultValue!==void 0?defaultValue:MissingValue.instance}whenNotNull(value,transform){if(value===null||value===void 0)return MissingValue.instance;return transform?transform(value):value}whenLoaded(relationship,value,defaultValue){const resource=this.resource;if(!((relationship in resource)&&resource[relationship]!==void 0))return defaultValue!==void 0?defaultValue:MissingValue.instance;if(value!==void 0)return typeof value==="function"?value():value;return resource[relationship]}whenCounted(relationship,defaultValue){const resource=this.resource,countKey=`${relationship}_count`;if(countKey in resource)return Number(resource[countKey]);return defaultValue!==void 0?defaultValue:MissingValue.instance}merge(data){const spreadable={...data};mergePayloads.set(spreadable,data);return spreadable}mergeWhen(condition,data){if(!(typeof condition==="function"?condition():condition))return MissingValue.instance;const resolvedData=typeof data==="function"?data():data;return this.merge(resolvedData)}static withoutWrapping(){this.wrap=null}static wrapWith(key){this.wrap=key}}export class ResourceCollection{resources;ResourceClass;additional={};request;static wrap="data";constructor(resources,ResourceClass){this.resources=resources;this.ResourceClass=ResourceClass}withRequest(request){this.request=request;return this}withAdditional(data){this.additional={...this.additional,...data};return this}resolve(request){return resolveValue(this,request,{depth:0,ancestors:new WeakSet})}resolveAt(request,context){const req=request??this.request,resources=this.resources.map((resource)=>{const instance=new this.ResourceClass(resource);if(req)instance.withRequest(req);return instance});return resolveValue(resources,req,context)}toResponse(request){const data=this.resolve(request),wrap=this.constructor.wrap;if(wrap)return{[wrap]:data,...this.additional};return{data,...this.additional}}toJson(request){return JSON.stringify(this.toResponse(request))}toJSON(){return this.toResponse()}count(){return this.resources.length}isEmpty(){return this.resources.length===0}isNotEmpty(){return this.resources.length>0}}export class PaginatedResourceCollection extends ResourceCollection{meta;links;constructor(paginatedData,ResourceClass){super(paginatedData.data,ResourceClass);this.meta=paginatedData.meta;this.links=paginatedData.links}static fromPagination(data,ResourceClass,options){const{currentPage,perPage,total,baseUrl=""}=options;for(const[name,value]of Object.entries({currentPage,perPage,total}))if(!Number.isSafeInteger(value)||value<(name==="total"?0:1))throw RangeError(`${name} must be a safe integer ${name==="total"?"at least zero":"greater than zero"}`);const offset=(currentPage-1)*perPage;if(!Number.isSafeInteger(offset)||!Number.isSafeInteger(offset+data.length)||data.length>perPage||data.length>0&&offset+data.length>total)throw RangeError("Pagination rows do not fit the requested page and total");const page=toPaginator({data,meta:{page:currentPage,perPage,total,lastPage:Math.ceil(total/perPage)}}),link=(number)=>withQuery(baseUrl,{page:number},!0);return new PaginatedResourceCollection({data,meta:{current_page:currentPage,from:page.from,last_page:page.last_page,per_page:perPage,to:page.to,total},links:{first:link(1),last:link(page.last_page),prev:currentPage>1?link(currentPage-1):null,next:page.has_more_pages?link(currentPage+1):null}},ResourceClass)}toResponse(request){return{data:this.resolve(request),meta:this.meta,links:this.links,...this.additional}}}export function resource(data,transform){const transformed=transform(data);return{toResponse:()=>({data:transformed}),toJson:()=>JSON.stringify({data:transformed})}}export function collection(data,transform){const transformed=data.map(transform);return{toResponse:()=>({data:transformed}),toJson:()=>JSON.stringify({data:transformed})}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/api",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.439",
|
|
6
6
|
"description": "The Stacks API utilities.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -61,15 +61,17 @@
|
|
|
61
61
|
"prepublishOnly": "bun run build"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stacksjs/config": "0.75.
|
|
65
|
-
"@stacksjs/path": "0.75.
|
|
64
|
+
"@stacksjs/config": "0.75.439",
|
|
65
|
+
"@stacksjs/path": "0.75.439",
|
|
66
|
+
"@stacksjs/pagination": "0.75.439",
|
|
67
|
+
"hash-wasm": "^4.12.0"
|
|
66
68
|
},
|
|
67
69
|
"devDependencies": {
|
|
68
|
-
"@stacksjs/utils": "0.75.
|
|
70
|
+
"@stacksjs/utils": "0.75.439",
|
|
69
71
|
"better-dx": "^0.2.24"
|
|
70
72
|
},
|
|
71
73
|
"peerDependencies": {
|
|
72
|
-
"@stacksjs/router": "0.75.
|
|
74
|
+
"@stacksjs/router": "0.75.439"
|
|
73
75
|
},
|
|
74
76
|
"peerDependenciesMeta": {
|
|
75
77
|
"@stacksjs/router": {
|