@stacksjs/api 0.75.432 → 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 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
- - 📦 Zero dependencies (uses native fetch)
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
- ok: boolean // Whether the request was successful
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,2 @@
1
+ /** Embedded verbatim into the dependency-free generated client; exercised by consumer compilation and HTTP tests. */
2
+ export declare const clientRuntime: string;
@@ -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
+ `;
@@ -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={...this.customHeaders,...headers};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(){return{...this.defaultHeaders,...this.customHeaders}}addQueryParams(url){if(!this.queryParams)return url;const searchParams=new URLSearchParams;Object.entries(this.queryParams).forEach(([key,value])=>{if(value!=null)searchParams.append(key,String(value))});const queryString=searchParams.toString();if(!queryString)return url;const hasParams=url.includes("?");return url+(hasParams?"&":"?")+queryString}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["Content-Type"]="application/json"}async handleDigestAuth(response,url,method,body){if(response.status!==401||!this.digestAuth)return response;const authHeader=response.headers.get("WWW-Authenticate");if(!authHeader?.startsWith("Digest "))return response;const challenge=authHeader.substring(7).split(",").reduce((acc,part)=>{const[key,value]=part.trim().split("=");if(key)acc[key]=value?.replace(/["']/g,"")??"";return acc},{}),ha1=await this.generateMD5(`${this.digestAuth.username}:${challenge.realm}:${this.digestAuth.password}`),ha2=await this.generateMD5(`${method}:${url}`),nonceCount="00000001",cnonce=await this.generateMD5(Date.now().toString()).then((hash)=>hash.substring(0,8)),response_value=await this.generateMD5(`${ha1}:${challenge.nonce}:${nonceCount}:${cnonce}:${challenge.qop}:${ha2}`),digestResponse={username:`"${this.digestAuth.username}"`,realm:`"${challenge.realm}"`,nonce:`"${challenge.nonce}"`,uri:`"${url}"`,algorithm:challenge.algorithm?`"${challenge.algorithm}"`:void 0,qop:`"${challenge.qop}"`,nc:nonceCount,cnonce:`"${cnonce}"`,response:`"${response_value}"`,opaque:challenge.opaque?`"${challenge.opaque}"`:void 0},authValue=Object.entries(digestResponse).filter(([_,value])=>value!==void 0).map(([key,value])=>`${key}=${value}`).join(", ");this.customHeaders.Authorization=`Digest ${authValue}`;return fetch(url,{method,headers:this.getHeaders(),body:body?await this.formatBody(body):void 0})}async generateMD5(str){const data=new TextEncoder().encode(str),hashBuffer=await crypto.subtle.digest("SHA-256",data);return Array.from(new Uint8Array(hashBuffer)).map((b)=>b.toString(16).padStart(2,"0")).join("")}async request(url,method,body){const urlWithParams=this.addQueryParams(url),formattedBody=await this.formatBody(body);let response=await fetch(urlWithParams,{method,headers:this.getHeaders(),body:formattedBody});if(this.digestAuth)response=await this.handleDigestAuth(response,urlWithParams,method,body);const data=await response.json();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;
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;
@@ -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 responses=operation.responses??{},status=Object.keys(responses).find((code)=>code.startsWith("2"));if(!status)return"unknown";const content=responses[status]?.content,json=content?.["application/json"]??Object.values(content??{})[0];return json?.schema?schemaType(json.schema):"unknown"}function comment(lines,indent=" "){const kept=lines.filter(Boolean);if(!kept.length)return"";return`${indent}/**
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
- `}function renderOperation(route,method,operation,name){const parameters=operation.parameters??[],pathParams=parameters.filter((parameter)=>parameter.in==="path"),queryParams=parameters.filter((parameter)=>parameter.in==="query"),bodySchema=operation.requestBody?.content?.["application/json"]?.schema,bodyRequired=Boolean(operation.requestBody?.required),fields=[];for(const parameter of[...pathParams,...queryParams]){const required=parameter.in==="path"||parameter.required;fields.push(`${JSON.stringify(parameter.name)}${required?"":"?"}: ${schemaType(parameter.schema)}`)}if(bodySchema)fields.push(`body${bodyRequired?"":"?"}: ${schemaType(bodySchema)}`);const everyFieldOptional=[...pathParams,...queryParams].every((p)=>p.in!=="path"&&!p.required)&&(!bodySchema||!bodyRequired),argument=fields.length?`input${everyFieldOptional?"?":""}: { ${fields.join("; ")} }, `:"",queryList=queryParams.length?`[${queryParams.map((parameter)=>JSON.stringify(parameter.name)).join(", ")}]`:"[]";return`${comment([operation.summary&&operation.summary!==route?operation.summary:`${method.toUpperCase()} ${route}`,operation.description??""])} ${name}(${argument}options?: RequestOptions): Promise<ApiResult<${successType(operation)}>> {
6
- return request(config, ${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(route)}, ${fields.length?"input ?? {}":"{}"}, ${queryList}, ${bodySchema?"true":"false"}, options)
7
- },`}export function renderApiClient(document,options={}){const title=options.name??"API",used=new Map,methods=[];for(const[route,item]of Object.entries(document.paths??{}))for(const method of METHODS){const operation=item[method];if(!operation)continue;const preferred=methodName(operation.operationId??`${method}_${route}`),seen=used.get(preferred)??0;used.set(preferred,seen+1);methods.push(renderOperation(route,method,operation,seen===0?preferred:`${preferred}${seen+1}`))}return`/* eslint-disable */
8
- /**
9
- * ${title} client, generated from the OpenAPI document.
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}`)}
@@ -1,27 +1,70 @@
1
- export declare function schemaType(schema?: SchemaObject): string;
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 OpenApiParameter {
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 OpenApiOperation {
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
- parameters?: OpenApiParameter[]
21
- requestBody?: { required?: boolean, content?: Record<string, { schema?: SchemaObject }> }
22
- responses?: Record<string, { content?: Record<string, { schema?: SchemaObject }> }>
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, Record<string, OpenApiOperation>>
26
- components?: { schemas?: Record<string, SchemaObject> }
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
  }
@@ -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 typeof value==="string"?JSON.stringify(value):String(value)}export function schemaType(schema){if(!schema)return"unknown";if(schema.enum?.length)return schema.enum.map(literal).join(" | ");if(schema.type==="string")return"string";if(schema.type==="number"||schema.type==="integer")return"number";if(schema.type==="boolean")return"boolean";if(schema.type==="null")return"null";if(schema.type==="array")return`Array<${schemaType(schema.items)}>`;if(schema.type==="object"||schema.properties||schema.additionalProperties){const required=new Set(schema.required||[]),fields=Object.entries(schema.properties||{}).map(([name,value])=>`${JSON.stringify(name)}${required.has(name)?"":"?"}: ${schemaType(value)}`);if(schema.additionalProperties){const value=schema.additionalProperties===!0?"unknown":schemaType(schema.additionalProperties);fields.push(`[key: string]: ${value}`)}return fields.length?`{ ${fields.join("; ")} }`:"Record<string, unknown>"}return"unknown"}function parameterGroups(parameters=[]){return` parameters: {
2
- ${["query","header","path","cookie"].map((location)=>{const entries=parameters.filter((parameter)=>parameter.in===location);if(!entries.length)return` ${location}?: never`;const fields=entries.map((parameter)=>`${JSON.stringify(parameter.name)}${parameter.required?"":"?"}: ${schemaType(parameter.schema)}`);return` ${location}${entries.every((parameter)=>!parameter.required)?"?":""}: { ${fields.join("; ")} }`}).join(`
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 requestBody=operation.requestBody?`
5
- requestBody${operation.requestBody.required?"":"?"}: { content: ${contentTypes(operation.requestBody.content)} }`:`
6
- requestBody?: never`,responses=Object.entries(operation.responses||{}).map(([status,response])=>` ${JSON.stringify(status)}: { content: ${contentTypes(response.content)} }`);return`{
7
- ${parameterGroups(operation.parameters)}${requestBody}
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){const paths=Object.entries(document.paths||{}).map(([route,item])=>{const operations=methods.map((method)=>{const operation=item[method];return operation?` ${method}: ${renderOperation(operation)}`:` ${method}?: never`});return` ${JSON.stringify(route)}: {
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.join(`
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}`)}
@@ -0,0 +1,2 @@
1
+ /** Add query values without moving them into a URL fragment. Relative paths stay relative. */
2
+ export declare function withQuery(url: string, values: Record<string, unknown>, replace?: boolean): string;
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}
@@ -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>): MergeValue;
103
- protected mergeWhen(condition: boolean | (() => boolean), data: Record<string, any> | (() => Record<string, any>)): MergeValue | MissingValue;
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){const data=this.toArray(request||this.request);return this.filterAndResolve(data)}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){return new MergeValue(data)}mergeWhen(condition,data){if(!(typeof condition==="function"?condition():condition))return MissingValue.instance;const resolvedData=typeof data==="function"?data():data;return new MergeValue(resolvedData)}static MAX_RESOLVE_DEPTH=20;filterAndResolve(data,depth=0){if(depth>=JsonResource.MAX_RESOLVE_DEPTH)return data;const result={};for(const[key,value]of Object.entries(data)){if(value instanceof MissingValue)continue;if(value instanceof MergeValue){Object.assign(result,this.filterAndResolve(value.data,depth+1));continue}if(value instanceof ConditionalValue){const resolved=value.resolve();if(!(resolved instanceof MissingValue))result[key]=resolved;continue}if(value!==null&&typeof value==="object"&&!Array.isArray(value)&&!(value instanceof Date)){result[key]=this.filterAndResolve(value,depth+1);continue}if(Array.isArray(value)){result[key]=value.filter((item)=>!(item instanceof MissingValue)).map((item)=>{if(item instanceof MergeValue)return item.data;if(item!==null&&typeof item==="object"&&!(item instanceof Date))return this.filterAndResolve(item,depth+1);return item});continue}result[key]=value}return result}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){const req=request||this.request;return this.resources.map((resource)=>{const instance=new this.ResourceClass(resource);if(req)instance.withRequest(req);return instance.resolve(req)})}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))}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,lastPage=Math.ceil(total/perPage),from=total>0?(currentPage-1)*perPage+1:null,to=total>0?Math.min(currentPage*perPage,total):null;return new PaginatedResourceCollection({data,meta:{current_page:currentPage,from,last_page:lastPage,per_page:perPage,to,total},links:{first:`${baseUrl}?page=1`,last:`${baseUrl}?page=${lastPage}`,prev:currentPage>1?`${baseUrl}?page=${currentPage-1}`:null,next:currentPage<lastPage?`${baseUrl}?page=${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})}}
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.432",
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.432",
65
- "@stacksjs/path": "0.75.432"
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.432",
70
+ "@stacksjs/utils": "0.75.439",
69
71
  "better-dx": "^0.2.24"
70
72
  },
71
73
  "peerDependencies": {
72
- "@stacksjs/router": "0.75.432"
74
+ "@stacksjs/router": "0.75.439"
73
75
  },
74
76
  "peerDependenciesMeta": {
75
77
  "@stacksjs/router": {