yaxa-svelte 2.5.0 → 2.7.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 +133 -18
- 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 +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/mutation.svelte.d.ts +37 -0
- package/dist/client/mutation.svelte.js +87 -0
- package/dist/client/query-cache.svelte.d.ts +51 -0
- package/dist/client/query-cache.svelte.js +172 -0
- package/dist/client/query.svelte.d.ts +71 -0
- package/dist/client/query.svelte.js +213 -0
- package/dist/client/query.test.d.ts +1 -0
- package/dist/client/query.test.js +242 -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/dist/server/openapi/handler.d.ts +1 -1
- package/dist/server/openapi/handler.js +41 -41
- package/dist/server/openapi/openapi.test.js +6 -7
- package/dist/server/openapi/types.d.ts +2 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**The Intuitive Svelte UI & Solo SaaS Library**
|
|
6
6
|
_Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-in SEO & SaaS Parity._
|
|
7
7
|
|
|
8
|
+
[](https://yaxa.vercel.app)
|
|
8
9
|
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
9
10
|
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
10
11
|
[](https://svelte.dev)
|
|
@@ -15,7 +16,13 @@ _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-i
|
|
|
15
16
|
[](SECURITY.md)
|
|
16
17
|
[](CONTRIBUTING.md)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
<br />
|
|
20
|
+
|
|
21
|
+
[**🚀 Live Demo**](https://yaxa.vercel.app) •
|
|
22
|
+
[**📚 Documentation**](https://yaxa.vercel.app/docs) •
|
|
23
|
+
[**🎨 Theme Studio**](https://yaxa.vercel.app/theme) •
|
|
24
|
+
[**💼 SaaS Showcase**](https://yaxa.vercel.app/saas) •
|
|
25
|
+
[**⚡ Scalar API Reference**](https://yaxa.vercel.app/api/docs)
|
|
19
26
|
|
|
20
27
|
</div>
|
|
21
28
|
|
|
@@ -276,7 +283,7 @@ export const POST = yaxaEndpoint({
|
|
|
276
283
|
|
|
277
284
|
## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
|
|
278
285
|
|
|
279
|
-
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/)
|
|
286
|
+
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/) or Swagger UI) directly from your `yaxaEndpoint` routes:
|
|
280
287
|
|
|
281
288
|
```ts
|
|
282
289
|
// src/routes/api/docs/+server.ts
|
|
@@ -286,7 +293,7 @@ import { POST as createProject } from '../projects/+server';
|
|
|
286
293
|
export const GET = createApiDocsHandler({
|
|
287
294
|
title: 'Acme SaaS API',
|
|
288
295
|
version: '1.0.0',
|
|
289
|
-
theme: 'scalar', // 'scalar' (default) | 'swagger'
|
|
296
|
+
theme: 'scalar', // 'scalar' (default) | 'swagger'
|
|
290
297
|
endpoints: {
|
|
291
298
|
'/api/projects': {
|
|
292
299
|
POST: createProject
|
|
@@ -295,7 +302,7 @@ export const GET = createApiDocsHandler({
|
|
|
295
302
|
});
|
|
296
303
|
```
|
|
297
304
|
|
|
298
|
-
- **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default)
|
|
305
|
+
- **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default) or `?theme=swagger`).
|
|
299
306
|
- **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
|
|
300
307
|
- **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
|
|
301
308
|
|
|
@@ -392,22 +399,130 @@ await broadcast(`org:${orgId}`, 'invoice.settled', {
|
|
|
392
399
|
|
|
393
400
|
---
|
|
394
401
|
|
|
402
|
+
## 🔗 End-to-End Type-Safe Client RPC (`createApiClient`)
|
|
403
|
+
|
|
404
|
+
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:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// 1. Define Route Type Map (src/lib/api.ts)
|
|
408
|
+
export type AppApi = {
|
|
409
|
+
'/api/projects': typeof import('../routes/api/projects/+server');
|
|
410
|
+
'/api/projects/[id]': typeof import('../routes/api/projects/[id]/+server');
|
|
411
|
+
};
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```svelte
|
|
415
|
+
<!-- 2. Type-Safe Client in Svelte 5 (+page.svelte) -->
|
|
416
|
+
<script lang="ts">
|
|
417
|
+
import { createApiClient } from 'yaxa-svelte';
|
|
418
|
+
import type { AppApi } from '$lib/api';
|
|
419
|
+
|
|
420
|
+
const api = createApiClient<AppApi>();
|
|
421
|
+
|
|
422
|
+
async function createProject() {
|
|
423
|
+
// ✨ Autocompleted URL, body, query, and response!
|
|
424
|
+
const { data, error, ok } = await api['/api/projects'].post({
|
|
425
|
+
body: { name: 'Apollo SaaS' },
|
|
426
|
+
query: { notify: true }
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (error) {
|
|
430
|
+
// 🛡️ Strongly typed RFC 7807 ProblemDetails
|
|
431
|
+
toast.error(error.title, error.detail);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
toast.success(`Created ${data.name} (#${data.id})`);
|
|
436
|
+
}
|
|
437
|
+
</script>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
// 3. Universal SSR Load Function with SvelteKit fetch (src/routes/projects/+page.ts)
|
|
442
|
+
export const load = async ({ fetch }) => {
|
|
443
|
+
const api = createApiClient<AppApi>({ fetch });
|
|
444
|
+
const { data: projects } = await api['/api/projects'].get();
|
|
445
|
+
return { projects: projects ?? [] };
|
|
446
|
+
};
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## ⚡ Runes-Native Reactive Query & Realtime RPC (`createQuery`, `createMutation`)
|
|
452
|
+
|
|
453
|
+
A zero-overhead, runes-native query and mutation cache built specifically for Svelte 5. Features request coalescing, stale-while-revalidate, window focus revalidation, optimistic updates with automatic rollback, and live SSE cache invalidation:
|
|
454
|
+
|
|
455
|
+
```svelte
|
|
456
|
+
<!-- src/routes/projects/+page.svelte -->
|
|
457
|
+
<script lang="ts">
|
|
458
|
+
import { api } from '$lib/api';
|
|
459
|
+
import { createQuery, createMutation } from 'yaxa-svelte';
|
|
460
|
+
|
|
461
|
+
let activeOrg = $state('org_123');
|
|
462
|
+
|
|
463
|
+
// 1. Reactive Query: auto-refetches when activeOrg changes
|
|
464
|
+
const projects = createQuery(() => api['/api/projects'].get({ query: { orgId: activeOrg } }), {
|
|
465
|
+
staleTimeMs: 15_000,
|
|
466
|
+
refetchOnWindowFocus: true,
|
|
467
|
+
subscribeToSse: `org:${activeOrg}/projects` // ⚡ Live SSE invalidation
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// 2. Optimistic Mutation with Automatic Rollback on RFC 7807 Error
|
|
471
|
+
const createProject = createMutation(
|
|
472
|
+
(name: string) => api['/api/projects'].post({ body: { name, orgId: activeOrg } }),
|
|
473
|
+
{
|
|
474
|
+
onMutate: (name) => {
|
|
475
|
+
const previous = projects.data;
|
|
476
|
+
projects.optimisticUpdate((curr) => [
|
|
477
|
+
{ id: 'temp-' + Date.now(), name, status: 'creating' },
|
|
478
|
+
...(curr ?? [])
|
|
479
|
+
]);
|
|
480
|
+
return { previous }; // Snapshot for rollback
|
|
481
|
+
},
|
|
482
|
+
onError: (err, _name, context) => {
|
|
483
|
+
if (context?.previous) projects.setData(context.previous);
|
|
484
|
+
toast.error(err.title, err.detail);
|
|
485
|
+
},
|
|
486
|
+
onSettled: () => projects.invalidate()
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
</script>
|
|
490
|
+
|
|
491
|
+
{#if projects.isLoading}
|
|
492
|
+
<Skeleton count={3} class="h-10 w-full" />
|
|
493
|
+
{:else if projects.data}
|
|
494
|
+
<ul>
|
|
495
|
+
{#each projects.data as project}
|
|
496
|
+
<li class={project.id.startsWith('temp-') ? 'opacity-50' : ''}>
|
|
497
|
+
{project.name}
|
|
498
|
+
</li>
|
|
499
|
+
{/each}
|
|
500
|
+
</ul>
|
|
501
|
+
{/if}
|
|
502
|
+
|
|
503
|
+
<Button onclick={() => createProject.mutate('New Project')} loading={createProject.isPending}>
|
|
504
|
+
Create Project
|
|
505
|
+
</Button>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
395
510
|
## 📦 Subpath Exports Architecture
|
|
396
511
|
|
|
397
|
-
| Subpath | Description
|
|
398
|
-
| :---------------------- |
|
|
399
|
-
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.)
|
|
400
|
-
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />`
|
|
401
|
-
| `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
|
-
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`)
|
|
403
|
-
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL)
|
|
404
|
-
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization
|
|
405
|
-
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback
|
|
406
|
-
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators
|
|
407
|
-
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`)
|
|
408
|
-
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`)
|
|
409
|
-
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars
|
|
410
|
-
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'`
|
|
512
|
+
| Subpath | Description |
|
|
513
|
+
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
514
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Runes Query Engine (`createQuery`, `createMutation`, `QueryClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
|
|
515
|
+
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
516
|
+
| `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 |
|
|
517
|
+
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
518
|
+
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
519
|
+
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
520
|
+
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
|
|
521
|
+
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
|
|
522
|
+
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
|
|
523
|
+
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
|
|
524
|
+
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
|
|
525
|
+
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
|
|
411
526
|
|
|
412
527
|
---
|
|
413
528
|
|
|
@@ -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
|
+
});
|