yaxa-svelte 2.5.0 → 2.6.0
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 +50 -1
- package/dist/client/client.d.ts +27 -0
- package/dist/client/client.js +239 -0
- package/dist/client/client.test.d.ts +1 -0
- package/dist/client/client.test.js +197 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/types.d.ts +222 -0
- package/dist/client/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -392,11 +392,60 @@ await broadcast(`org:${orgId}`, 'invoice.settled', {
|
|
|
392
392
|
|
|
393
393
|
---
|
|
394
394
|
|
|
395
|
+
## 🔗 End-to-End Type-Safe Client RPC (`createApiClient`)
|
|
396
|
+
|
|
397
|
+
Zero-codegen end-to-end type safety bridging your server `yaxaEndpoint` routes directly into Svelte 5 components and universal `load` functions. Adds less than 500 bytes gzipped to your client bundle:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// 1. Define Route Type Map (src/lib/api.ts)
|
|
401
|
+
export type AppApi = {
|
|
402
|
+
'/api/projects': typeof import('../routes/api/projects/+server');
|
|
403
|
+
'/api/projects/[id]': typeof import('../routes/api/projects/[id]/+server');
|
|
404
|
+
};
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
```svelte
|
|
408
|
+
<!-- 2. Type-Safe Client in Svelte 5 (+page.svelte) -->
|
|
409
|
+
<script lang="ts">
|
|
410
|
+
import { createApiClient } from 'yaxa-svelte';
|
|
411
|
+
import type { AppApi } from '$lib/api';
|
|
412
|
+
|
|
413
|
+
const api = createApiClient<AppApi>();
|
|
414
|
+
|
|
415
|
+
async function createProject() {
|
|
416
|
+
// ✨ Autocompleted URL, body, query, and response!
|
|
417
|
+
const { data, error, ok } = await api['/api/projects'].post({
|
|
418
|
+
body: { name: 'Apollo SaaS' },
|
|
419
|
+
query: { notify: true }
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (error) {
|
|
423
|
+
// 🛡️ Strongly typed RFC 7807 ProblemDetails
|
|
424
|
+
toast.error(error.title, error.detail);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
toast.success(`Created ${data.name} (#${data.id})`);
|
|
429
|
+
}
|
|
430
|
+
</script>
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// 3. Universal SSR Load Function with SvelteKit fetch (src/routes/projects/+page.ts)
|
|
435
|
+
export const load = async ({ fetch }) => {
|
|
436
|
+
const api = createApiClient<AppApi>({ fetch });
|
|
437
|
+
const { data: projects } = await api['/api/projects'].get();
|
|
438
|
+
return { projects: projects ?? [] };
|
|
439
|
+
};
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
395
444
|
## 📦 Subpath Exports Architecture
|
|
396
445
|
|
|
397
446
|
| Subpath | Description |
|
|
398
447
|
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
399
|
-
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.)
|
|
448
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
|
|
400
449
|
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
401
450
|
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
|
|
402
451
|
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApiClient, ApiClientConfig } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Serializes a query object into a URL search string.
|
|
4
|
+
* Handles arrays, booleans, numbers, and strings while ignoring undefined/null.
|
|
5
|
+
*/
|
|
6
|
+
export declare function serializeQueryParams(query?: Record<string, any>): string;
|
|
7
|
+
/**
|
|
8
|
+
* Interpolates dynamic path segments (e.g. `[id]`, `[...slug]`, or `:id`).
|
|
9
|
+
*/
|
|
10
|
+
export declare function interpolatePath(path: string, params?: Record<string, any>): string;
|
|
11
|
+
/**
|
|
12
|
+
* Creates an end-to-end type-safe API client for yaxaEndpoint routes.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { createApiClient } from 'yaxa-svelte';
|
|
17
|
+
* import type { AppApi } from '$lib/api';
|
|
18
|
+
*
|
|
19
|
+
* const api = createApiClient<AppApi>();
|
|
20
|
+
*
|
|
21
|
+
* // Strong autocompletion and type-safety
|
|
22
|
+
* const { data, error } = await api['/api/projects'].post({
|
|
23
|
+
* body: { name: 'Apollo SaaS' }
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function createApiClient<TRoutes = Record<string, any>>(config?: ApiClientConfig): ApiClient<TRoutes>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes a query object into a URL search string.
|
|
3
|
+
* Handles arrays, booleans, numbers, and strings while ignoring undefined/null.
|
|
4
|
+
*/
|
|
5
|
+
export function serializeQueryParams(query) {
|
|
6
|
+
if (!query || typeof query !== 'object')
|
|
7
|
+
return '';
|
|
8
|
+
const searchParams = new URLSearchParams();
|
|
9
|
+
for (const [key, value] of Object.entries(query)) {
|
|
10
|
+
if (value === undefined || value === null)
|
|
11
|
+
continue;
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
if (item !== undefined && item !== null) {
|
|
15
|
+
searchParams.append(key, String(item));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
searchParams.set(key, String(value));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const queryString = searchParams.toString();
|
|
24
|
+
return queryString ? `?${queryString}` : '';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Interpolates dynamic path segments (e.g. `[id]`, `[...slug]`, or `:id`).
|
|
28
|
+
*/
|
|
29
|
+
export function interpolatePath(path, params) {
|
|
30
|
+
if (!params || typeof params !== 'object')
|
|
31
|
+
return path;
|
|
32
|
+
let result = path;
|
|
33
|
+
for (const [key, value] of Object.entries(params)) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
continue;
|
|
36
|
+
const encoded = encodeURIComponent(String(value));
|
|
37
|
+
// SvelteKit bracket syntax: [id] and [...rest]
|
|
38
|
+
result = result.replace(new RegExp(`\\[\\.\\.\\.${key}\\]`, 'g'), String(value));
|
|
39
|
+
result = result.replace(new RegExp(`\\[${key}\\]`, 'g'), encoded);
|
|
40
|
+
// Colon syntax: :id
|
|
41
|
+
result = result.replace(new RegExp(`:${key}\\b`, 'g'), encoded);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Core HTTP execution pipeline for apiClient.
|
|
47
|
+
*/
|
|
48
|
+
async function executeRequest(config, method, rawPath, options) {
|
|
49
|
+
const params = options?.params;
|
|
50
|
+
const interpolated = interpolatePath(rawPath, params);
|
|
51
|
+
const queryString = serializeQueryParams(options?.query);
|
|
52
|
+
const baseUrl = config.baseUrl ? config.baseUrl.replace(/\/+$/, '') : '';
|
|
53
|
+
const cleanPath = interpolated.startsWith('/') ? interpolated : `/${interpolated}`;
|
|
54
|
+
const fullUrl = `${baseUrl}${cleanPath}${queryString}`;
|
|
55
|
+
// Resolve default headers
|
|
56
|
+
let defaultHeaders = {};
|
|
57
|
+
if (typeof config.headers === 'function') {
|
|
58
|
+
const resolved = await config.headers();
|
|
59
|
+
defaultHeaders = new Headers(resolved);
|
|
60
|
+
}
|
|
61
|
+
else if (config.headers) {
|
|
62
|
+
defaultHeaders = new Headers(config.headers);
|
|
63
|
+
}
|
|
64
|
+
const headers = new Headers(defaultHeaders);
|
|
65
|
+
if (options?.headers) {
|
|
66
|
+
const reqHeaders = new Headers(options.headers);
|
|
67
|
+
reqHeaders.forEach((val, key) => headers.set(key, val));
|
|
68
|
+
}
|
|
69
|
+
// Body serialization
|
|
70
|
+
let bodyPayload;
|
|
71
|
+
if (options?.body !== undefined && options?.body !== null) {
|
|
72
|
+
const rawBody = options.body;
|
|
73
|
+
if (typeof rawBody === 'string' ||
|
|
74
|
+
rawBody instanceof FormData ||
|
|
75
|
+
rawBody instanceof Blob ||
|
|
76
|
+
rawBody instanceof URLSearchParams ||
|
|
77
|
+
rawBody instanceof ArrayBuffer) {
|
|
78
|
+
bodyPayload = rawBody;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
if (!headers.has('content-type')) {
|
|
82
|
+
headers.set('content-type', 'application/json');
|
|
83
|
+
}
|
|
84
|
+
bodyPayload = JSON.stringify(rawBody);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const fetchFn = options?.fetch || config.fetch || globalThis.fetch;
|
|
88
|
+
const requestInit = {
|
|
89
|
+
method: method.toUpperCase(),
|
|
90
|
+
headers,
|
|
91
|
+
body: bodyPayload,
|
|
92
|
+
signal: options?.signal
|
|
93
|
+
};
|
|
94
|
+
if (config.onRequest) {
|
|
95
|
+
await config.onRequest({ url: fullUrl, options: requestInit });
|
|
96
|
+
}
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await fetchFn(fullUrl, requestInit);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const networkProblem = {
|
|
103
|
+
type: 'https://yaxa.dev/errors/network-error',
|
|
104
|
+
title: 'Network Request Failed',
|
|
105
|
+
status: 0,
|
|
106
|
+
detail: err?.message || 'Could not connect to server.'
|
|
107
|
+
};
|
|
108
|
+
const fallbackResponse = typeof Response.error === 'function'
|
|
109
|
+
? Response.error()
|
|
110
|
+
: new Response(null, { status: 503, statusText: 'Network Error' });
|
|
111
|
+
return {
|
|
112
|
+
data: null,
|
|
113
|
+
error: networkProblem,
|
|
114
|
+
response: fallbackResponse,
|
|
115
|
+
status: 0,
|
|
116
|
+
ok: false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Parse response payload
|
|
120
|
+
let parsedData = null;
|
|
121
|
+
const contentType = response.headers.get('content-type') || '';
|
|
122
|
+
if (response.status !== 204) {
|
|
123
|
+
if (contentType.includes('application/json') || contentType.includes('problem+json')) {
|
|
124
|
+
try {
|
|
125
|
+
parsedData = await response.json();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
parsedData = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
try {
|
|
133
|
+
parsedData = await response.text();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
parsedData = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (response.ok) {
|
|
141
|
+
if (config.onResponse) {
|
|
142
|
+
await config.onResponse({ response, data: parsedData, error: null });
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
data: parsedData,
|
|
146
|
+
error: null,
|
|
147
|
+
response,
|
|
148
|
+
status: response.status,
|
|
149
|
+
ok: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Format RFC 7807 problem details
|
|
153
|
+
let errorProblem;
|
|
154
|
+
if (parsedData &&
|
|
155
|
+
typeof parsedData === 'object' &&
|
|
156
|
+
(parsedData.title || parsedData.type || parsedData.status)) {
|
|
157
|
+
errorProblem = {
|
|
158
|
+
type: parsedData.type || `https://yaxa.dev/errors/http-${response.status}`,
|
|
159
|
+
title: parsedData.title || response.statusText || 'Request Error',
|
|
160
|
+
status: parsedData.status || response.status,
|
|
161
|
+
detail: parsedData.detail,
|
|
162
|
+
instance: parsedData.instance,
|
|
163
|
+
invalidParams: parsedData.invalidParams,
|
|
164
|
+
...parsedData
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
errorProblem = {
|
|
169
|
+
type: `https://yaxa.dev/errors/http-${response.status}`,
|
|
170
|
+
title: response.statusText || 'HTTP Error',
|
|
171
|
+
status: response.status,
|
|
172
|
+
detail: typeof parsedData === 'string' ? parsedData : undefined
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (config.onResponse) {
|
|
176
|
+
await config.onResponse({ response, data: null, error: errorProblem });
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
data: null,
|
|
180
|
+
error: errorProblem,
|
|
181
|
+
response,
|
|
182
|
+
status: response.status,
|
|
183
|
+
ok: false
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Creates a route-level proxy that exposes HTTP method callers (`get`, `post`, etc.).
|
|
188
|
+
*/
|
|
189
|
+
function createRouteProxy(config, path) {
|
|
190
|
+
const methods = ['get', 'post', 'put', 'delete', 'patch'];
|
|
191
|
+
const routeHandler = {};
|
|
192
|
+
for (const method of methods) {
|
|
193
|
+
routeHandler[method] = (options) => executeRequest(config, method, path, options);
|
|
194
|
+
}
|
|
195
|
+
// Allows calling the route proxy directly as a function to interpolate params:
|
|
196
|
+
// e.g. api['/api/projects/[id]']({ id: '123' }).get()
|
|
197
|
+
const callableProxy = Object.assign((params) => {
|
|
198
|
+
const interpolated = interpolatePath(path, params);
|
|
199
|
+
return createRouteProxy(config, interpolated);
|
|
200
|
+
}, routeHandler);
|
|
201
|
+
return callableProxy;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Creates an end-to-end type-safe API client for yaxaEndpoint routes.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```ts
|
|
208
|
+
* import { createApiClient } from 'yaxa-svelte';
|
|
209
|
+
* import type { AppApi } from '$lib/api';
|
|
210
|
+
*
|
|
211
|
+
* const api = createApiClient<AppApi>();
|
|
212
|
+
*
|
|
213
|
+
* // Strong autocompletion and type-safety
|
|
214
|
+
* const { data, error } = await api['/api/projects'].post({
|
|
215
|
+
* body: { name: 'Apollo SaaS' }
|
|
216
|
+
* });
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export function createApiClient(config = {}) {
|
|
220
|
+
const rootTarget = {
|
|
221
|
+
get: (path, options) => executeRequest(config, 'get', path, options),
|
|
222
|
+
post: (path, options) => executeRequest(config, 'post', path, options),
|
|
223
|
+
put: (path, options) => executeRequest(config, 'put', path, options),
|
|
224
|
+
delete: (path, options) => executeRequest(config, 'delete', path, options),
|
|
225
|
+
patch: (path, options) => executeRequest(config, 'patch', path, options)
|
|
226
|
+
};
|
|
227
|
+
return new Proxy(rootTarget, {
|
|
228
|
+
get(target, prop, receiver) {
|
|
229
|
+
if (typeof prop === 'string') {
|
|
230
|
+
if (prop in target) {
|
|
231
|
+
return Reflect.get(target, prop, receiver);
|
|
232
|
+
}
|
|
233
|
+
// Route path access, e.g. api['/api/projects']
|
|
234
|
+
return createRouteProxy(config, prop);
|
|
235
|
+
}
|
|
236
|
+
return Reflect.get(target, prop, receiver);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createApiClient, serializeQueryParams, interpolatePath } from './client';
|
|
3
|
+
describe('serializeQueryParams', () => {
|
|
4
|
+
it('returns empty string for null or empty object', () => {
|
|
5
|
+
expect(serializeQueryParams()).toBe('');
|
|
6
|
+
expect(serializeQueryParams({})).toBe('');
|
|
7
|
+
expect(serializeQueryParams(null)).toBe('');
|
|
8
|
+
});
|
|
9
|
+
it('serializes primitives correctly', () => {
|
|
10
|
+
const res = serializeQueryParams({
|
|
11
|
+
page: 1,
|
|
12
|
+
search: 'hello world',
|
|
13
|
+
active: true
|
|
14
|
+
});
|
|
15
|
+
expect(res).toBe('?page=1&search=hello+world&active=true');
|
|
16
|
+
});
|
|
17
|
+
it('ignores undefined and null values', () => {
|
|
18
|
+
const res = serializeQueryParams({
|
|
19
|
+
query: 'test',
|
|
20
|
+
filter: undefined,
|
|
21
|
+
extra: null
|
|
22
|
+
});
|
|
23
|
+
expect(res).toBe('?query=test');
|
|
24
|
+
});
|
|
25
|
+
it('serializes array parameters into multi-keys', () => {
|
|
26
|
+
const res = serializeQueryParams({
|
|
27
|
+
tags: ['svelte', 'tailwind']
|
|
28
|
+
});
|
|
29
|
+
expect(res).toBe('?tags=svelte&tags=tailwind');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('interpolatePath', () => {
|
|
33
|
+
it('leaves static paths unchanged', () => {
|
|
34
|
+
expect(interpolatePath('/api/projects')).toBe('/api/projects');
|
|
35
|
+
});
|
|
36
|
+
it('interpolates SvelteKit bracket syntax [param]', () => {
|
|
37
|
+
expect(interpolatePath('/api/projects/[id]', { id: 'proj_123' })).toBe('/api/projects/proj_123');
|
|
38
|
+
});
|
|
39
|
+
it('interpolates colon syntax :param', () => {
|
|
40
|
+
expect(interpolatePath('/api/orgs/:orgId/users/:userId', { orgId: 'org_1', userId: 'usr_2' })).toBe('/api/orgs/org_1/users/usr_2');
|
|
41
|
+
});
|
|
42
|
+
it('interpolates rest parameters [...rest]', () => {
|
|
43
|
+
expect(interpolatePath('/api/docs/[...slug]', { slug: 'getting-started/install' })).toBe('/api/docs/getting-started/install');
|
|
44
|
+
});
|
|
45
|
+
it('url-encodes special characters', () => {
|
|
46
|
+
expect(interpolatePath('/api/search/[query]', { query: 'svelte & kit' })).toBe('/api/search/svelte%20%26%20kit');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('createApiClient', () => {
|
|
50
|
+
it('performs successful GET request and returns data', async () => {
|
|
51
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ id: '1', name: 'Project 1' }]), {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: { 'content-type': 'application/json' }
|
|
54
|
+
}));
|
|
55
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
56
|
+
const { data, error, status, ok } = await api['/api/projects'].get({
|
|
57
|
+
query: { limit: 10 }
|
|
58
|
+
});
|
|
59
|
+
expect(mockFetch).toHaveBeenCalledWith('/api/projects?limit=10', expect.objectContaining({
|
|
60
|
+
method: 'GET'
|
|
61
|
+
}));
|
|
62
|
+
expect(ok).toBe(true);
|
|
63
|
+
expect(status).toBe(200);
|
|
64
|
+
expect(error).toBeNull();
|
|
65
|
+
expect(data).toEqual([{ id: '1', name: 'Project 1' }]);
|
|
66
|
+
});
|
|
67
|
+
it('performs POST request with JSON body serialization', async () => {
|
|
68
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'proj_new', name: 'New Team' }), {
|
|
69
|
+
status: 201,
|
|
70
|
+
headers: { 'content-type': 'application/json' }
|
|
71
|
+
}));
|
|
72
|
+
const api = createApiClient({ baseUrl: 'https://api.yaxa.dev', fetch: mockFetch });
|
|
73
|
+
const result = await api['/api/projects'].post({
|
|
74
|
+
body: { name: 'New Team' }
|
|
75
|
+
});
|
|
76
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.yaxa.dev/api/projects', expect.objectContaining({
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: JSON.stringify({ name: 'New Team' })
|
|
79
|
+
}));
|
|
80
|
+
expect(result.ok).toBe(true);
|
|
81
|
+
expect(result.status).toBe(201);
|
|
82
|
+
expect(result.data).toEqual({ id: 'proj_new', name: 'New Team' });
|
|
83
|
+
});
|
|
84
|
+
it('interpolates path parameters via options or callable route', async () => {
|
|
85
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: '42' }), {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: { 'content-type': 'application/json' }
|
|
88
|
+
}));
|
|
89
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
90
|
+
// Pattern 1: via params option
|
|
91
|
+
await api['/api/projects/[id]'].get({
|
|
92
|
+
params: { id: '42' }
|
|
93
|
+
});
|
|
94
|
+
expect(mockFetch).toHaveBeenLastCalledWith('/api/projects/42', expect.objectContaining({ method: 'GET' }));
|
|
95
|
+
// Pattern 2: callable route
|
|
96
|
+
await api['/api/projects/[id]']({ id: '99' }).get();
|
|
97
|
+
expect(mockFetch).toHaveBeenLastCalledWith('/api/projects/99', expect.objectContaining({ method: 'GET' }));
|
|
98
|
+
});
|
|
99
|
+
it('handles 204 No Content successfully with null data', async () => {
|
|
100
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
101
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
102
|
+
const result = await api['/api/projects/[id]'].delete({ params: { id: '1' } });
|
|
103
|
+
expect(result.ok).toBe(true);
|
|
104
|
+
expect(result.status).toBe(204);
|
|
105
|
+
expect(result.data).toBeNull();
|
|
106
|
+
expect(result.error).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
it('parses and discriminates RFC 7807 problem details on error', async () => {
|
|
109
|
+
const problem = {
|
|
110
|
+
type: 'https://yaxa.dev/errors/validation-failed',
|
|
111
|
+
title: 'Validation Failed',
|
|
112
|
+
status: 422,
|
|
113
|
+
detail: 'Field "name" is required',
|
|
114
|
+
invalidParams: [{ name: 'name', reason: 'Required' }]
|
|
115
|
+
};
|
|
116
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(problem), {
|
|
117
|
+
status: 422,
|
|
118
|
+
headers: { 'content-type': 'application/problem+json' }
|
|
119
|
+
}));
|
|
120
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
121
|
+
const { data, error, ok, status } = await api['/api/projects'].post({
|
|
122
|
+
body: {}
|
|
123
|
+
});
|
|
124
|
+
expect(ok).toBe(false);
|
|
125
|
+
expect(status).toBe(422);
|
|
126
|
+
expect(data).toBeNull();
|
|
127
|
+
expect(error).toBeDefined();
|
|
128
|
+
expect(error?.title).toBe('Validation Failed');
|
|
129
|
+
expect(error?.invalidParams?.[0].name).toBe('name');
|
|
130
|
+
});
|
|
131
|
+
it('generates fallback RFC 7807 ProblemDetails for generic 500 error', async () => {
|
|
132
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response('Internal Server Crash', {
|
|
133
|
+
status: 500,
|
|
134
|
+
statusText: 'Internal Error',
|
|
135
|
+
headers: { 'content-type': 'text/plain' }
|
|
136
|
+
}));
|
|
137
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
138
|
+
const result = await api['/api/broken'].get();
|
|
139
|
+
expect(result.ok).toBe(false);
|
|
140
|
+
expect(result.status).toBe(500);
|
|
141
|
+
expect(result.error?.status).toBe(500);
|
|
142
|
+
expect(result.error?.title).toBe('Internal Error');
|
|
143
|
+
expect(result.error?.detail).toBe('Internal Server Crash');
|
|
144
|
+
});
|
|
145
|
+
it('gracefully handles network failures with status 0 RFC 7807 problem', async () => {
|
|
146
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
|
|
147
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
148
|
+
const result = await api['/api/offline'].get();
|
|
149
|
+
expect(result.ok).toBe(false);
|
|
150
|
+
expect(result.status).toBe(0);
|
|
151
|
+
expect(result.error?.type).toBe('https://yaxa.dev/errors/network-error');
|
|
152
|
+
expect(result.error?.detail).toBe('Failed to fetch');
|
|
153
|
+
});
|
|
154
|
+
it('executes onRequest and onResponse interceptor lifecycle hooks', async () => {
|
|
155
|
+
const onRequest = vi.fn();
|
|
156
|
+
const onResponse = vi.fn();
|
|
157
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
|
|
158
|
+
status: 200,
|
|
159
|
+
headers: { 'content-type': 'application/json' }
|
|
160
|
+
}));
|
|
161
|
+
const api = createApiClient({
|
|
162
|
+
fetch: mockFetch,
|
|
163
|
+
onRequest,
|
|
164
|
+
onResponse
|
|
165
|
+
});
|
|
166
|
+
await api['/api/hooked'].get();
|
|
167
|
+
expect(onRequest).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/hooked' }));
|
|
168
|
+
expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ error: null }));
|
|
169
|
+
});
|
|
170
|
+
it('supports top-level direct methods: api.get, api.post, etc.', async () => {
|
|
171
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ping: 'pong' }), {
|
|
172
|
+
status: 200,
|
|
173
|
+
headers: { 'content-type': 'application/json' }
|
|
174
|
+
}));
|
|
175
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
176
|
+
const res = await api.get('/api/health');
|
|
177
|
+
expect(mockFetch).toHaveBeenCalledWith('/api/health', expect.objectContaining({ method: 'GET' }));
|
|
178
|
+
expect(res.data).toEqual({ ping: 'pong' });
|
|
179
|
+
});
|
|
180
|
+
it('integrates with yaxaEndpoint route types seamlessly', async () => {
|
|
181
|
+
const mockFetch = vi.fn().mockImplementation(async (url, init) => {
|
|
182
|
+
const body = JSON.parse(init.body);
|
|
183
|
+
return new Response(JSON.stringify({ id: 'proj_abc', name: body.name, count: 1 }), {
|
|
184
|
+
status: 201,
|
|
185
|
+
headers: { 'content-type': 'application/json' }
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
const api = createApiClient({ fetch: mockFetch });
|
|
189
|
+
const { data, ok, status } = await api['/api/projects'].post({
|
|
190
|
+
body: { name: 'Apollo SaaS' }
|
|
191
|
+
});
|
|
192
|
+
expect(ok).toBe(true);
|
|
193
|
+
expect(status).toBe(201);
|
|
194
|
+
expect(data?.name).toBe('Apollo SaaS');
|
|
195
|
+
expect(data?.id).toBe('proj_abc');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard RFC 7807 Problem Details representation.
|
|
3
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7807
|
|
4
|
+
*/
|
|
5
|
+
export interface ProblemDetails {
|
|
6
|
+
type: string;
|
|
7
|
+
title: string;
|
|
8
|
+
status: number;
|
|
9
|
+
detail?: string;
|
|
10
|
+
instance?: string;
|
|
11
|
+
invalidParams?: Array<{
|
|
12
|
+
name: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}>;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Standard Schema or validator representation.
|
|
19
|
+
*/
|
|
20
|
+
export type AnyValidator<T = any> = {
|
|
21
|
+
'~standard': {
|
|
22
|
+
validate: (value: unknown) => Promise<{
|
|
23
|
+
value?: T;
|
|
24
|
+
issues?: ReadonlyArray<any>;
|
|
25
|
+
}> | {
|
|
26
|
+
value?: T;
|
|
27
|
+
issues?: ReadonlyArray<any>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
} | {
|
|
31
|
+
safeParse: (value: unknown) => {
|
|
32
|
+
success: true;
|
|
33
|
+
data: T;
|
|
34
|
+
} | {
|
|
35
|
+
success: false;
|
|
36
|
+
error: any;
|
|
37
|
+
};
|
|
38
|
+
} | {
|
|
39
|
+
parse: (value: unknown) => T;
|
|
40
|
+
} | ((value: unknown) => {
|
|
41
|
+
success: boolean;
|
|
42
|
+
data?: T;
|
|
43
|
+
error?: any;
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Infer output type from a schema validator.
|
|
47
|
+
*/
|
|
48
|
+
export type InferValidatorOutput<V> = V extends {
|
|
49
|
+
'~standard': {
|
|
50
|
+
validate: (...args: any[]) => Promise<{
|
|
51
|
+
value?: infer T;
|
|
52
|
+
}> | {
|
|
53
|
+
value?: infer T;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
} ? T : V extends {
|
|
57
|
+
safeParse: (...args: any[]) => {
|
|
58
|
+
success: true;
|
|
59
|
+
data: infer T;
|
|
60
|
+
} | {
|
|
61
|
+
success: false;
|
|
62
|
+
error: any;
|
|
63
|
+
};
|
|
64
|
+
} ? T : V extends {
|
|
65
|
+
parse: (...args: any[]) => infer T;
|
|
66
|
+
} ? T : V extends (value: unknown) => {
|
|
67
|
+
success: true;
|
|
68
|
+
data: infer T;
|
|
69
|
+
} ? T : any;
|
|
70
|
+
/**
|
|
71
|
+
* Infer request payload and response types from a yaxaEndpoint handler or config.
|
|
72
|
+
*/
|
|
73
|
+
export type InferEndpoint<T> = T extends {
|
|
74
|
+
_yaxaConfig: {
|
|
75
|
+
schema?: {
|
|
76
|
+
body?: infer B;
|
|
77
|
+
query?: infer Q;
|
|
78
|
+
params?: infer P;
|
|
79
|
+
headers?: infer H;
|
|
80
|
+
};
|
|
81
|
+
handle: (...args: any[]) => infer R;
|
|
82
|
+
};
|
|
83
|
+
} ? {
|
|
84
|
+
body: [B] extends [never] ? undefined : InferValidatorOutput<B>;
|
|
85
|
+
query: [Q] extends [never] ? undefined : InferValidatorOutput<Q>;
|
|
86
|
+
params: [P] extends [never] ? undefined : InferValidatorOutput<P>;
|
|
87
|
+
headers: [H] extends [never] ? undefined : InferValidatorOutput<H>;
|
|
88
|
+
response: Awaited<R> extends Response ? unknown : Awaited<R>;
|
|
89
|
+
} : T extends (...args: any[]) => Promise<infer R> | infer R ? {
|
|
90
|
+
body: any;
|
|
91
|
+
query: any;
|
|
92
|
+
params: any;
|
|
93
|
+
headers: any;
|
|
94
|
+
response: Awaited<R> extends Response ? unknown : Awaited<R>;
|
|
95
|
+
} : {
|
|
96
|
+
body: any;
|
|
97
|
+
query: any;
|
|
98
|
+
params: any;
|
|
99
|
+
headers: any;
|
|
100
|
+
response: any;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Strong discriminated union result returned by apiClient calls.
|
|
104
|
+
*/
|
|
105
|
+
export type ClientResponse<TData> = {
|
|
106
|
+
data: TData;
|
|
107
|
+
error: null;
|
|
108
|
+
response: Response;
|
|
109
|
+
status: number;
|
|
110
|
+
ok: true;
|
|
111
|
+
} | {
|
|
112
|
+
data: null;
|
|
113
|
+
error: ProblemDetails;
|
|
114
|
+
response: Response;
|
|
115
|
+
status: number;
|
|
116
|
+
ok: false;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Client request options passed to an endpoint method.
|
|
120
|
+
*/
|
|
121
|
+
export interface ClientRequestOptions<TBody = unknown, TQuery = Record<string, any>, TParams = Record<string, any>> {
|
|
122
|
+
body?: TBody;
|
|
123
|
+
query?: TQuery;
|
|
124
|
+
params?: TParams;
|
|
125
|
+
headers?: HeadersInit;
|
|
126
|
+
signal?: AbortSignal;
|
|
127
|
+
fetch?: typeof fetch;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Helper to determine if an option object is required based on schema properties.
|
|
131
|
+
*/
|
|
132
|
+
export type RequiredKeys<T> = {
|
|
133
|
+
[K in keyof T]-?: Record<string, never> extends Pick<T, K> ? never : K;
|
|
134
|
+
}[keyof T];
|
|
135
|
+
export type ClientMethodFn<TEndpoint> = TEndpoint extends never ? (options?: ClientRequestOptions) => Promise<ClientResponse<any>> : InferEndpoint<TEndpoint> extends infer Inferred ? Inferred extends {
|
|
136
|
+
body: infer B;
|
|
137
|
+
query: infer Q;
|
|
138
|
+
params: infer P;
|
|
139
|
+
response: infer R;
|
|
140
|
+
} ? (options?: {
|
|
141
|
+
body?: B;
|
|
142
|
+
query?: Q;
|
|
143
|
+
params?: P;
|
|
144
|
+
headers?: HeadersInit;
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
fetch?: typeof fetch;
|
|
147
|
+
}) => Promise<ClientResponse<R>> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
148
|
+
/**
|
|
149
|
+
* HTTP methods supported by a route definition.
|
|
150
|
+
*/
|
|
151
|
+
export type RouteMethods<TRoute> = {
|
|
152
|
+
get: TRoute extends {
|
|
153
|
+
GET: infer H;
|
|
154
|
+
} ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
155
|
+
post: TRoute extends {
|
|
156
|
+
POST: infer H;
|
|
157
|
+
} ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
158
|
+
put: TRoute extends {
|
|
159
|
+
PUT: infer H;
|
|
160
|
+
} ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
161
|
+
delete: TRoute extends {
|
|
162
|
+
DELETE: infer H;
|
|
163
|
+
} ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
164
|
+
patch: TRoute extends {
|
|
165
|
+
PATCH: infer H;
|
|
166
|
+
} ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Callable route proxy supporting path parameter interpolation.
|
|
170
|
+
* e.g. api['/api/projects/[id]']({ id: '123' }).get()
|
|
171
|
+
*/
|
|
172
|
+
export type RouteProxy<TRoute> = RouteMethods<TRoute> & {
|
|
173
|
+
(params: Record<string, any>): RouteMethods<TRoute>;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Global configuration options for creating an API client.
|
|
177
|
+
*/
|
|
178
|
+
export interface ApiClientConfig {
|
|
179
|
+
/**
|
|
180
|
+
* Base URL prefix for all requests (e.g. 'https://api.example.com' or '').
|
|
181
|
+
* Defaults to empty string (relative to current origin).
|
|
182
|
+
*/
|
|
183
|
+
baseUrl?: string;
|
|
184
|
+
/**
|
|
185
|
+
* Custom fetch implementation (e.g. SvelteKit's event.fetch in load functions).
|
|
186
|
+
* Defaults to globalThis.fetch.
|
|
187
|
+
*/
|
|
188
|
+
fetch?: typeof fetch;
|
|
189
|
+
/**
|
|
190
|
+
* Default headers to attach to every request.
|
|
191
|
+
*/
|
|
192
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
193
|
+
/**
|
|
194
|
+
* Interceptor hook called before every request.
|
|
195
|
+
*/
|
|
196
|
+
onRequest?: (context: {
|
|
197
|
+
url: string;
|
|
198
|
+
options: RequestInit;
|
|
199
|
+
}) => void | Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Interceptor hook called on every response.
|
|
202
|
+
*/
|
|
203
|
+
onResponse?: (context: {
|
|
204
|
+
response: Response;
|
|
205
|
+
data: any;
|
|
206
|
+
error: ProblemDetails | null;
|
|
207
|
+
}) => void | Promise<void>;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Dynamic Proxy type mapping paths to methods.
|
|
211
|
+
*/
|
|
212
|
+
export type ApiClient<TRoutes = Record<string, any>> = {
|
|
213
|
+
[K in keyof TRoutes]: RouteProxy<TRoutes[K]>;
|
|
214
|
+
} & {
|
|
215
|
+
[path: string]: RouteProxy<any>;
|
|
216
|
+
} & {
|
|
217
|
+
get: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
|
|
218
|
+
post: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
|
|
219
|
+
put: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
|
|
220
|
+
delete: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
|
|
221
|
+
patch: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
|
|
222
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -207,5 +207,7 @@ export { default as FeatureGrid, featureGridVariants } from './components/blocks
|
|
|
207
207
|
export type { FeatureGridProps, FeatureItem } from './components/blocks/FeatureGrid.svelte';
|
|
208
208
|
export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
|
|
209
209
|
export type { YaxaTestOptions } from './testing';
|
|
210
|
+
export { createApiClient, interpolatePath, serializeQueryParams } from './client';
|
|
211
|
+
export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails } from './client';
|
|
210
212
|
export { cn, tv } from './utils/cn';
|
|
211
213
|
export type { VariantProps } from './utils/cn';
|
package/dist/index.js
CHANGED
|
@@ -138,5 +138,7 @@ export { default as FAQ, faqVariants } from './components/blocks/FAQ.svelte';
|
|
|
138
138
|
export { default as FeatureGrid, featureGridVariants } from './components/blocks/FeatureGrid.svelte';
|
|
139
139
|
// Testing Utilities
|
|
140
140
|
export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
|
|
141
|
+
// Type-Safe Client RPC
|
|
142
|
+
export { createApiClient, interpolatePath, serializeQueryParams } from './client';
|
|
141
143
|
// Utilities
|
|
142
144
|
export { cn, tv } from './utils/cn';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaxa-svelte",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://yaxa.vercel.app",
|