@xemahq/api-client-generator 0.1.3 → 0.1.4

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.
@@ -1,2 +1,2 @@
1
- export declare const CANONICAL_CUSTOM_FETCH = "/**\n * Configurable fetch wrapper for Orval-generated clients.\n *\n * Consumers must call `configureClient()` before using any endpoint function.\n * The baseUrl is prepended to the relative paths generated by Orval.\n */\n\nexport interface ClientConfig {\n /** Static base URL (e.g. 'http://localhost:3140') \u2014 no trailing slash. */\n baseUrl?: string;\n /** Per-request base-URL resolver (service-registry-safe). */\n baseUrlResolver?: () => string | Promise<string>;\n /** Optional async callback to get an auth token. */\n getAuthToken?: () => Promise<string>;\n /** Optional callback returning headers to inject on every request. */\n getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;\n /** Optional callback invoked on 401 before a single retry. */\n onUnauthorized?: () => Promise<void>;\n /** Maximum retry attempts for transient failures (default: 3). */\n maxRetries?: number;\n}\n\nexport class ClientError extends Error {\n constructor(\n public readonly status: number,\n public readonly url: string,\n public readonly body: unknown,\n ) {\n super(`HTTP ${status} from ${url}`);\n this.name = 'ClientError';\n }\n}\n\nlet clientConfig: ClientConfig | null = null;\n\nexport function configureClient(config: ClientConfig): void {\n clientConfig = config;\n}\n\nexport function getClientConfig(): ClientConfig {\n if (!clientConfig) {\n throw new Error(\n 'Client not configured. Call configureClient({ baseUrl }) before using endpoint functions.',\n );\n }\n return clientConfig;\n}\n\nconst RETRYABLE_STATUSES = [429, 502, 503, 504];\n\nasync function buildHeaders(\n config: ClientConfig,\n callerHeaders: RequestInit['headers'],\n): Promise<Headers> {\n const headers = new Headers(callerHeaders);\n if (config.getHeaders) {\n const globalHeaders = await Promise.resolve(config.getHeaders());\n for (const [key, value] of Object.entries(globalHeaders)) {\n if (!headers.has(key)) {\n headers.set(key, value);\n }\n }\n }\n if (config.getAuthToken && !headers.has('Authorization')) {\n const token = await config.getAuthToken();\n headers.set('Authorization', `Bearer ${token}`);\n }\n return headers;\n}\n\nexport const customFetch = async <T>(\n url: string,\n options: RequestInit,\n): Promise<T> => {\n const config = getClientConfig();\n const base = config.baseUrlResolver\n ? await config.baseUrlResolver()\n : config.baseUrl;\n if (base === undefined) {\n throw new Error(\n 'Client not configured: set baseUrl or baseUrlResolver via configureClient().',\n );\n }\n const fullUrl = `${base}${url}`;\n const maxRetries = config.maxRetries ?? 3;\n\n const headers = await buildHeaders(config, options.headers);\n const requestInit: RequestInit = { ...options, headers };\n\n let delay = 1000;\n let lastError: Error | undefined;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n const response = await fetch(fullUrl, requestInit);\n\n if (response.status === 401 && config.onUnauthorized && attempt === 0) {\n await config.onUnauthorized();\n const refreshedHeaders = await buildHeaders(config, options.headers);\n const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });\n const retryBody = await parseBody(retryResponse);\n if (retryResponse.status >= 400) {\n throw new ClientError(retryResponse.status, fullUrl, retryBody);\n }\n return retryBody as T;\n }\n\n if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {\n const body = await parseBody(response);\n if (response.status >= 400) {\n throw new ClientError(response.status, fullUrl, body);\n }\n return body as T;\n }\n\n const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));\n const waitMs = retryAfter ?? addJitter(delay);\n await sleep(waitMs);\n delay = Math.min(delay * 2, 30_000);\n } catch (error) {\n if (error instanceof ClientError) throw error;\n lastError = error instanceof Error ? error : new Error(String(error));\n if (attempt >= maxRetries) throw lastError;\n const waitMs = addJitter(delay);\n await sleep(waitMs);\n delay = Math.min(delay * 2, 30_000);\n }\n }\n\n throw lastError ?? new Error(`All retries exhausted for ${fullUrl}`);\n};\n\nasync function parseBody(response: Response): Promise<unknown> {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n return response.json();\n }\n if (response.status === 204) {\n return undefined;\n }\n return response.text();\n}\n\nfunction parseRetryAfter(value: string | null): number | undefined {\n if (!value) return undefined;\n const seconds = Number(value);\n if (!isNaN(seconds) && seconds >= 0) return seconds * 1000;\n const date = new Date(value);\n if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());\n return undefined;\n}\n\nfunction addJitter(delay: number): number {\n return delay + (Math.random() * 2 - 1) * delay * 0.25;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nexport default customFetch;\n";
1
+ export declare const CANONICAL_CUSTOM_FETCH = "/**\n * Configurable fetch wrapper for Orval-generated clients.\n *\n * Consumers must call `configureClient()` before using any endpoint function.\n * The baseUrl is prepended to the relative paths generated by Orval.\n */\n\nexport interface ClientConfig {\n /** Static base URL (e.g. 'http://localhost:3140') \u2014 no trailing slash. */\n baseUrl?: string;\n /** Per-request base-URL resolver (service-registry-safe). */\n baseUrlResolver?: () => string | Promise<string>;\n /** Optional async callback to get an auth token. */\n getAuthToken?: () => Promise<string>;\n /** Optional callback returning headers to inject on every request. */\n getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;\n /** Optional callback invoked on 401 before a single retry. */\n onUnauthorized?: () => Promise<void>;\n /** Maximum retry attempts for transient failures (default: 3). */\n maxRetries?: number;\n}\n\nexport class ClientError extends Error {\n constructor(\n public readonly status: number,\n public readonly url: string,\n public readonly body: unknown,\n ) {\n super(`HTTP ${status} from ${url}`);\n this.name = 'ClientError';\n }\n}\n\nlet clientConfig: ClientConfig | null = null;\n\nexport function configureClient(config: ClientConfig): void {\n clientConfig = config;\n}\n\nexport function getClientConfig(): ClientConfig {\n if (!clientConfig) {\n throw new Error(\n 'Client not configured. Call configureClient({ baseUrl }) before using endpoint functions.',\n );\n }\n return clientConfig;\n}\n\nconst RETRYABLE_STATUSES = [429, 502, 503, 504];\n\nasync function buildHeaders(\n config: ClientConfig,\n callerHeaders: RequestInit['headers'],\n): Promise<Headers> {\n const headers = new Headers(callerHeaders);\n if (config.getHeaders) {\n const globalHeaders = await Promise.resolve(config.getHeaders());\n for (const [key, value] of Object.entries(globalHeaders)) {\n if (!headers.has(key)) {\n headers.set(key, value);\n }\n }\n }\n if (config.getAuthToken && !headers.has('Authorization')) {\n const token = await config.getAuthToken();\n headers.set('Authorization', `Bearer ${token}`);\n }\n return headers;\n}\n\nexport const customFetch = async <T>(\n url: string,\n options: RequestInit,\n): Promise<T> => {\n const config = getClientConfig();\n const base = config.baseUrlResolver\n ? await config.baseUrlResolver()\n : config.baseUrl;\n if (base === undefined) {\n throw new Error(\n 'Client not configured: set baseUrl or baseUrlResolver via configureClient().',\n );\n }\n const fullUrl = `${base}${url}`;\n const maxRetries = config.maxRetries ?? 3;\n\n const headers = await buildHeaders(config, options.headers);\n const requestInit: RequestInit = { ...options, headers };\n\n let delay = 1000;\n let lastError: Error | undefined;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n const response = await fetch(fullUrl, requestInit);\n\n if (response.status === 401 && config.onUnauthorized && attempt === 0) {\n await config.onUnauthorized();\n const refreshedHeaders = await buildHeaders(config, options.headers);\n const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });\n const retryBody = await parseBody(retryResponse);\n if (retryResponse.status >= 400) {\n throw new ClientError(retryResponse.status, fullUrl, retryBody);\n }\n return unwrapEnvelope(retryBody) as T;\n }\n\n if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {\n const body = await parseBody(response);\n if (response.status >= 400) {\n throw new ClientError(response.status, fullUrl, body);\n }\n return unwrapEnvelope(body) as T;\n }\n\n const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));\n const waitMs = retryAfter ?? addJitter(delay);\n await sleep(waitMs);\n delay = Math.min(delay * 2, 30_000);\n } catch (error) {\n if (error instanceof ClientError) throw error;\n lastError = error instanceof Error ? error : new Error(String(error));\n if (attempt >= maxRetries) throw lastError;\n const waitMs = addJitter(delay);\n await sleep(waitMs);\n delay = Math.min(delay * 2, 30_000);\n }\n }\n\n throw lastError ?? new Error(`All retries exhausted for ${fullUrl}`);\n};\n\n/**\n * Xema platform services wrap every 2xx payload in a { data: T } envelope via\n * the global ResponseEnvelopeInterceptor (platform-common). Generated types\n * describe the INNER T (controllers declare their return type as T, not\n * { data: T }), so the client must peel data to keep the static types and the\n * wire shape consistent \u2014 otherwise consumers read fields off the envelope and\n * get undefined.\n *\n * Only 2xx bodies reach this helper: non-2xx responses are surfaced as a thrown\n * ClientError before unwrap, and error envelopes are emitted by the\n * GlobalExceptionFilter, never wrapped in { data }. Paginated lists arrive as\n * { data: T[], pagination: {...} }; consumers read both fields, so that envelope\n * is kept intact (detected by the presence of pagination). Any body without a\n * top-level data field is returned as-is.\n */\nfunction unwrapEnvelope(body: unknown): unknown {\n if (\n body === null ||\n typeof body !== 'object' ||\n Array.isArray(body) ||\n !('data' in body)\n ) {\n return body;\n }\n const envelope = body as { data: unknown; pagination?: unknown };\n if ('pagination' in envelope) {\n return envelope;\n }\n return envelope.data;\n}\n\nasync function parseBody(response: Response): Promise<unknown> {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n return response.json();\n }\n if (response.status === 204) {\n return undefined;\n }\n return response.text();\n}\n\nfunction parseRetryAfter(value: string | null): number | undefined {\n if (!value) return undefined;\n const seconds = Number(value);\n if (!isNaN(seconds) && seconds >= 0) return seconds * 1000;\n const date = new Date(value);\n if (!isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now());\n return undefined;\n}\n\nfunction addJitter(delay: number): number {\n return delay + (Math.random() * 2 - 1) * delay * 0.25;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nexport default customFetch;\n";
2
2
  //# sourceMappingURL=custom-fetch-template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"custom-fetch-template.d.ts","sourceRoot":"","sources":["../../src/lib/custom-fetch-template.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,mvKAiKlC,CAAC"}
1
+ {"version":3,"file":"custom-fetch-template.d.ts","sourceRoot":"","sources":["../../src/lib/custom-fetch-template.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,69MAgMlC,CAAC"}
@@ -105,7 +105,7 @@ export const customFetch = async <T>(
105
105
  if (retryResponse.status >= 400) {
106
106
  throw new ClientError(retryResponse.status, fullUrl, retryBody);
107
107
  }
108
- return retryBody as T;
108
+ return unwrapEnvelope(retryBody) as T;
109
109
  }
110
110
 
111
111
  if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {
@@ -113,7 +113,7 @@ export const customFetch = async <T>(
113
113
  if (response.status >= 400) {
114
114
  throw new ClientError(response.status, fullUrl, body);
115
115
  }
116
- return body as T;
116
+ return unwrapEnvelope(body) as T;
117
117
  }
118
118
 
119
119
  const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
@@ -133,6 +133,37 @@ export const customFetch = async <T>(
133
133
  throw lastError ?? new Error(\`All retries exhausted for \${fullUrl}\`);
134
134
  };
135
135
 
136
+ /**
137
+ * Xema platform services wrap every 2xx payload in a { data: T } envelope via
138
+ * the global ResponseEnvelopeInterceptor (platform-common). Generated types
139
+ * describe the INNER T (controllers declare their return type as T, not
140
+ * { data: T }), so the client must peel data to keep the static types and the
141
+ * wire shape consistent — otherwise consumers read fields off the envelope and
142
+ * get undefined.
143
+ *
144
+ * Only 2xx bodies reach this helper: non-2xx responses are surfaced as a thrown
145
+ * ClientError before unwrap, and error envelopes are emitted by the
146
+ * GlobalExceptionFilter, never wrapped in { data }. Paginated lists arrive as
147
+ * { data: T[], pagination: {...} }; consumers read both fields, so that envelope
148
+ * is kept intact (detected by the presence of pagination). Any body without a
149
+ * top-level data field is returned as-is.
150
+ */
151
+ function unwrapEnvelope(body: unknown): unknown {
152
+ if (
153
+ body === null ||
154
+ typeof body !== 'object' ||
155
+ Array.isArray(body) ||
156
+ !('data' in body)
157
+ ) {
158
+ return body;
159
+ }
160
+ const envelope = body as { data: unknown; pagination?: unknown };
161
+ if ('pagination' in envelope) {
162
+ return envelope;
163
+ }
164
+ return envelope.data;
165
+ }
166
+
136
167
  async function parseBody(response: Response): Promise<unknown> {
137
168
  const contentType = response.headers.get('content-type');
138
169
  if (contentType?.includes('application/json')) {
@@ -1 +1 @@
1
- {"version":3,"file":"custom-fetch-template.js","sourceRoot":"","sources":["../../src/lib/custom-fetch-template.ts"],"names":[],"mappings":";;;AAOa,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiKrC,CAAC"}
1
+ {"version":3,"file":"custom-fetch-template.js","sourceRoot":"","sources":["../../src/lib/custom-fetch-template.ts"],"names":[],"mappings":";;;AAOa,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgMrC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/api-client-generator",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Layer 1 — the single, central OpenAPI + ORVAL client generator for every Xema NestJS service. Replaces the per-service orval.config.ts / scripts/extract-openapi.ts / scripts/build-client-index.js trio with two bins (xema-openapi, xema-client-generate). Extracts one OpenAPI document per ApiSurface (public/internal/operator) using the @XemaApiSurface decorator metadata, generates one ORVAL client package per non-empty surface (all sharing the owning service's version), and stamps deterministic `xema` provenance metadata into each generated client package.json. No heuristics: a route ships in a surface only if it (or its controller) is explicitly classified.",
5
5
  "keywords": [
6
6
  "xema",
@@ -54,7 +54,7 @@
54
54
  "prettier": "3.6.2",
55
55
  "reflect-metadata": "0.2.2",
56
56
  "typescript": "5.9.3",
57
- "@xemahq/xema-decorators": "0.2.0"
57
+ "@xemahq/xema-decorators": "0.2.2"
58
58
  },
59
59
  "exports": {
60
60
  ".": {