yaxa-svelte 2.2.0 → 2.3.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 +40 -14
- package/dist/server/endpoint/guards.d.ts +5 -0
- package/dist/server/endpoint/guards.js +19 -3
- package/dist/server/endpoint/index.js +4 -0
- package/dist/server/endpoint/types.d.ts +7 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/openapi/generator.d.ts +5 -0
- package/dist/server/openapi/generator.js +317 -0
- package/dist/server/openapi/handler.d.ts +7 -0
- package/dist/server/openapi/handler.js +117 -0
- package/dist/server/openapi/index.d.ts +5 -0
- package/dist/server/openapi/index.js +4 -0
- package/dist/server/openapi/openapi.test.d.ts +1 -0
- package/dist/server/openapi/openapi.test.js +219 -0
- package/dist/server/openapi/registry.d.ts +35 -0
- package/dist/server/openapi/registry.js +51 -0
- package/dist/server/openapi/schema.d.ts +4 -0
- package/dist/server/openapi/schema.js +59 -0
- package/dist/server/openapi/types.d.ts +127 -0
- package/dist/server/openapi/types.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -274,22 +274,48 @@ export const POST = yaxaEndpoint({
|
|
|
274
274
|
|
|
275
275
|
---
|
|
276
276
|
|
|
277
|
+
## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
|
|
278
|
+
|
|
279
|
+
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/), Swagger UI, or Redoc) directly from your `yaxaEndpoint` routes:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// src/routes/api/docs/+server.ts
|
|
283
|
+
import { createApiDocsHandler } from 'yaxa-svelte/server';
|
|
284
|
+
import { POST as createProject } from '../projects/+server';
|
|
285
|
+
|
|
286
|
+
export const GET = createApiDocsHandler({
|
|
287
|
+
title: 'Acme SaaS API',
|
|
288
|
+
version: '1.0.0',
|
|
289
|
+
theme: 'scalar', // 'scalar' (default) | 'swagger' | 'redoc'
|
|
290
|
+
endpoints: {
|
|
291
|
+
'/api/projects': {
|
|
292
|
+
POST: createProject
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- **Interactive Reference UI:** Open `/api/docs` in your browser.
|
|
299
|
+
- **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
277
303
|
## 📦 Subpath Exports Architecture
|
|
278
304
|
|
|
279
|
-
| Subpath | Description
|
|
280
|
-
| :---------------------- |
|
|
281
|
-
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.)
|
|
282
|
-
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />`
|
|
283
|
-
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, RFC 7807, Universal Server Hooks, SEO endpoints, Drizzle DB, Storage & Email
|
|
284
|
-
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`)
|
|
285
|
-
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL)
|
|
286
|
-
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization
|
|
287
|
-
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback
|
|
288
|
-
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators
|
|
289
|
-
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`)
|
|
290
|
-
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`)
|
|
291
|
-
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars
|
|
292
|
-
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'`
|
|
305
|
+
| Subpath | Description |
|
|
306
|
+
| :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
307
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
|
|
308
|
+
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
309
|
+
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar/Swagger Docs, RFC 7807, Universal Server Hooks, SEO endpoints, Drizzle DB, Storage & Email |
|
|
310
|
+
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
311
|
+
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
312
|
+
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
313
|
+
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
|
|
314
|
+
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
|
|
315
|
+
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
|
|
316
|
+
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
|
|
317
|
+
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
|
|
318
|
+
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
|
|
293
319
|
|
|
294
320
|
---
|
|
295
321
|
|
|
@@ -11,6 +11,11 @@ export interface RequireAuthOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
detail?: string;
|
|
13
13
|
}
|
|
14
|
+
export interface YaxaGuardMetadata {
|
|
15
|
+
type: 'auth' | 'role' | 'rateLimit' | string;
|
|
16
|
+
roles?: string[];
|
|
17
|
+
options?: any;
|
|
18
|
+
}
|
|
14
19
|
/**
|
|
15
20
|
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
16
21
|
*/
|
|
@@ -4,7 +4,7 @@ import { problemResponse } from './problem';
|
|
|
4
4
|
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
5
5
|
*/
|
|
6
6
|
export function requireAuth(options = {}) {
|
|
7
|
-
|
|
7
|
+
const guard = (event) => {
|
|
8
8
|
const user = event.locals?.user;
|
|
9
9
|
if (!user) {
|
|
10
10
|
if (options.redirect) {
|
|
@@ -22,6 +22,11 @@ export function requireAuth(options = {}) {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
|
+
guard._yaxaGuard = {
|
|
26
|
+
type: 'auth',
|
|
27
|
+
options
|
|
28
|
+
};
|
|
29
|
+
return guard;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* Declarative Guard: Enforces that the active user/member possesses one of the allowed roles.
|
|
@@ -29,7 +34,7 @@ export function requireAuth(options = {}) {
|
|
|
29
34
|
*/
|
|
30
35
|
export function requireRole(roles, options = {}) {
|
|
31
36
|
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
|
32
|
-
|
|
37
|
+
const guard = (event) => {
|
|
33
38
|
const locals = event.locals;
|
|
34
39
|
const user = locals?.user;
|
|
35
40
|
if (!user) {
|
|
@@ -54,6 +59,12 @@ export function requireRole(roles, options = {}) {
|
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
};
|
|
62
|
+
guard._yaxaGuard = {
|
|
63
|
+
type: 'role',
|
|
64
|
+
roles: allowedRoles,
|
|
65
|
+
options
|
|
66
|
+
};
|
|
67
|
+
return guard;
|
|
57
68
|
}
|
|
58
69
|
const rateLimitStore = new Map();
|
|
59
70
|
// Periodic cleanup of stale rate-limit buckets (every 5 minutes)
|
|
@@ -77,7 +88,7 @@ if (typeof setInterval !== 'undefined') {
|
|
|
77
88
|
export function rateLimit(options = {}) {
|
|
78
89
|
const points = options.points ?? 60;
|
|
79
90
|
const windowMs = (options.windowSeconds ?? 60) * 1000;
|
|
80
|
-
|
|
91
|
+
const guard = (event) => {
|
|
81
92
|
let key;
|
|
82
93
|
if (options.keyGenerator) {
|
|
83
94
|
key = options.keyGenerator(event);
|
|
@@ -122,6 +133,11 @@ export function rateLimit(options = {}) {
|
|
|
122
133
|
}
|
|
123
134
|
bucket.tokens -= 1;
|
|
124
135
|
};
|
|
136
|
+
guard._yaxaGuard = {
|
|
137
|
+
type: 'rateLimit',
|
|
138
|
+
options: { points, windowSeconds: options.windowSeconds ?? 60 }
|
|
139
|
+
};
|
|
140
|
+
return guard;
|
|
125
141
|
}
|
|
126
142
|
/**
|
|
127
143
|
* Combines multiple guards into a single composite guard.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isProblemDetails, problemResponse } from './problem';
|
|
2
2
|
import { validateData } from './validate';
|
|
3
|
+
import { globalApiRegistry } from '../openapi/registry';
|
|
3
4
|
/**
|
|
4
5
|
* Parses URLSearchParams into a plain key-value object.
|
|
5
6
|
* Duplicate keys are parsed into arrays.
|
|
@@ -230,6 +231,9 @@ export function yaxaEndpoint(config) {
|
|
|
230
231
|
};
|
|
231
232
|
// Attach configuration metadata for runtime reflection and OpenAPI generation (Milestone 3)
|
|
232
233
|
handler._yaxaConfig = config;
|
|
234
|
+
if (config.path && config.method) {
|
|
235
|
+
globalApiRegistry.register(config.path, { [config.method]: handler });
|
|
236
|
+
}
|
|
233
237
|
return handler;
|
|
234
238
|
}
|
|
235
239
|
export * from './types';
|
|
@@ -137,6 +137,13 @@ export interface YaxaEndpointConfig<TBodySchema = any, TQuerySchema = any, TPara
|
|
|
137
137
|
description?: string;
|
|
138
138
|
tags?: string[];
|
|
139
139
|
deprecated?: boolean;
|
|
140
|
+
operationId?: string;
|
|
141
|
+
path?: string;
|
|
142
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string;
|
|
143
|
+
responses?: Record<number, {
|
|
144
|
+
description: string;
|
|
145
|
+
schema?: any;
|
|
146
|
+
}>;
|
|
140
147
|
/**
|
|
141
148
|
* Core business logic handler.
|
|
142
149
|
*/
|
package/dist/server/index.d.ts
CHANGED
|
@@ -24,3 +24,5 @@ export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './
|
|
|
24
24
|
export type { S3StorageConfig, PresignedUploadOptions, PresignedUploadResult, S3UploadHandlerOptions } from './storage/index';
|
|
25
25
|
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
|
26
26
|
export type { YaxaEndpointConfig, YaxaEndpointContext, YaxaEndpointSchemaConfig, YaxaInterceptor, Guard, GuardResult, ProblemDetails, CreateProblemOptions, RequireAuthOptions, RequireRoleOptions, RateLimitOptions, AnyValidator, InferValidatorOutput, ValidationOutcome, ValidationSuccess, ValidationFailure } from './endpoint/index';
|
|
27
|
+
export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
|
|
28
|
+
export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './openapi/index';
|
package/dist/server/index.js
CHANGED
|
@@ -20,3 +20,5 @@ export { getResendClient, sendMagicLinkEmail, sendWelcomeEmail } from './email/i
|
|
|
20
20
|
export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './storage/index';
|
|
21
21
|
// Endpoint Pipeline, Guards & RFC 7807 Validation (Milestone 1)
|
|
22
22
|
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
|
23
|
+
// Auto-Generated OpenAPI 3.1 & Interactive Docs (Milestone 3)
|
|
24
|
+
export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { convertToJsonSchema } from './schema';
|
|
2
|
+
import { globalApiRegistry } from './registry';
|
|
3
|
+
/**
|
|
4
|
+
* Standard RFC 7807 Problem Details Schema for OpenAPI Components.
|
|
5
|
+
*/
|
|
6
|
+
const PROBLEM_DETAILS_SCHEMA = {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
type: { type: 'string', format: 'uri', example: 'https://yaxa.dev/errors/validation-failed' },
|
|
10
|
+
title: { type: 'string', example: 'Unprocessable Entity' },
|
|
11
|
+
status: { type: 'integer', example: 422 },
|
|
12
|
+
detail: { type: 'string', example: 'Validation failed for 1 parameter(s).' },
|
|
13
|
+
instance: { type: 'string' },
|
|
14
|
+
invalidParams: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
name: { type: 'string', example: 'body.email' },
|
|
20
|
+
reason: { type: 'string', example: 'Invalid email address' }
|
|
21
|
+
},
|
|
22
|
+
required: ['name', 'reason']
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ['type', 'title', 'status']
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extracts parameter list from a converted JSON schema.
|
|
30
|
+
*/
|
|
31
|
+
function extractParametersFromSchema(jsonSchema, location) {
|
|
32
|
+
if (!jsonSchema || typeof jsonSchema !== 'object' || !jsonSchema.properties) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const requiredList = Array.isArray(jsonSchema.required) ? jsonSchema.required : [];
|
|
36
|
+
const parameters = [];
|
|
37
|
+
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
|
|
38
|
+
parameters.push({
|
|
39
|
+
name: key,
|
|
40
|
+
in: location,
|
|
41
|
+
required: location === 'path' ? true : requiredList.includes(key),
|
|
42
|
+
description: propSchema?.description,
|
|
43
|
+
deprecated: propSchema?.deprecated,
|
|
44
|
+
schema: propSchema
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return parameters;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolves YaxaEndpointConfig from an endpoint function or config object.
|
|
51
|
+
*/
|
|
52
|
+
function resolveConfig(endpoint) {
|
|
53
|
+
if (!endpoint)
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof endpoint === 'function' && '_yaxaConfig' in endpoint) {
|
|
56
|
+
return endpoint._yaxaConfig;
|
|
57
|
+
}
|
|
58
|
+
if (typeof endpoint === 'object' && 'handle' in endpoint) {
|
|
59
|
+
return endpoint;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Generates an OpenAPI 3.1.0 document from registered yaxaEndpoints.
|
|
65
|
+
*/
|
|
66
|
+
export function generateOpenApiSpec(options = {}) {
|
|
67
|
+
const title = options.title || 'Yaxa SvelteKit API';
|
|
68
|
+
const version = options.version || '1.0.0';
|
|
69
|
+
const description = options.description || 'Auto-generated OpenAPI 3.1 contract via yaxa-svelte.';
|
|
70
|
+
const registry = options.registry || globalApiRegistry;
|
|
71
|
+
const registeredEndpoints = registry.getEndpoints();
|
|
72
|
+
// Merge manual options.endpoints if provided
|
|
73
|
+
const mergedEndpoints = {
|
|
74
|
+
...registeredEndpoints
|
|
75
|
+
};
|
|
76
|
+
if (options.endpoints) {
|
|
77
|
+
for (const [rawPath, methods] of Object.entries(options.endpoints)) {
|
|
78
|
+
const normalizedPath = registry.normalizePath(rawPath);
|
|
79
|
+
if (!mergedEndpoints[normalizedPath]) {
|
|
80
|
+
mergedEndpoints[normalizedPath] = {};
|
|
81
|
+
}
|
|
82
|
+
for (const [method, handler] of Object.entries(methods)) {
|
|
83
|
+
mergedEndpoints[normalizedPath][method.toUpperCase()] = handler;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const paths = {};
|
|
88
|
+
const securitySchemes = {};
|
|
89
|
+
let usesSecurity = false;
|
|
90
|
+
for (const [path, methods] of Object.entries(mergedEndpoints)) {
|
|
91
|
+
paths[path] = {};
|
|
92
|
+
for (const [method, endpoint] of Object.entries(methods)) {
|
|
93
|
+
const config = resolveConfig(endpoint);
|
|
94
|
+
const methodLower = method.toLowerCase();
|
|
95
|
+
if (!config) {
|
|
96
|
+
// Fallback for raw RequestHandler
|
|
97
|
+
paths[path][methodLower] = {
|
|
98
|
+
summary: `${method.toUpperCase()} ${path}`,
|
|
99
|
+
responses: {
|
|
100
|
+
'200': { description: 'Successful response' }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const parameters = [];
|
|
106
|
+
// 1. Path Params
|
|
107
|
+
if (config.schema?.params) {
|
|
108
|
+
const paramsSchema = convertToJsonSchema(config.schema.params);
|
|
109
|
+
parameters.push(...extractParametersFromSchema(paramsSchema, 'path'));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Detect {param} placeholders in path and add basic string parameters
|
|
113
|
+
const pathParamMatches = path.match(/\{(\w+)\}/g);
|
|
114
|
+
if (pathParamMatches) {
|
|
115
|
+
for (const match of pathParamMatches) {
|
|
116
|
+
const paramName = match.slice(1, -1);
|
|
117
|
+
parameters.push({
|
|
118
|
+
name: paramName,
|
|
119
|
+
in: 'path',
|
|
120
|
+
required: true,
|
|
121
|
+
schema: { type: 'string' }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 2. Query Params
|
|
127
|
+
if (config.schema?.query) {
|
|
128
|
+
const querySchema = convertToJsonSchema(config.schema.query);
|
|
129
|
+
parameters.push(...extractParametersFromSchema(querySchema, 'query'));
|
|
130
|
+
}
|
|
131
|
+
// 3. Header Params
|
|
132
|
+
if (config.schema?.headers) {
|
|
133
|
+
const headersSchema = convertToJsonSchema(config.schema.headers);
|
|
134
|
+
parameters.push(...extractParametersFromSchema(headersSchema, 'header'));
|
|
135
|
+
}
|
|
136
|
+
// 4. Request Body
|
|
137
|
+
let requestBody = undefined;
|
|
138
|
+
if (config.schema?.body && method !== 'GET' && method !== 'HEAD') {
|
|
139
|
+
const bodySchema = convertToJsonSchema(config.schema.body);
|
|
140
|
+
requestBody = {
|
|
141
|
+
required: true,
|
|
142
|
+
content: {
|
|
143
|
+
'application/json': {
|
|
144
|
+
schema: bodySchema
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// 5. Responses
|
|
150
|
+
const responses = {};
|
|
151
|
+
const successStatus = config.status
|
|
152
|
+
? String(config.status)
|
|
153
|
+
: method === 'POST'
|
|
154
|
+
? '201'
|
|
155
|
+
: '200';
|
|
156
|
+
if (config.responses) {
|
|
157
|
+
for (const [statusCode, resp] of Object.entries(config.responses)) {
|
|
158
|
+
responses[String(statusCode)] = {
|
|
159
|
+
description: resp.description,
|
|
160
|
+
content: resp.schema
|
|
161
|
+
? {
|
|
162
|
+
'application/json': {
|
|
163
|
+
schema: convertToJsonSchema(resp.schema)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
: undefined
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!responses[successStatus]) {
|
|
171
|
+
responses[successStatus] = {
|
|
172
|
+
description: successStatus === '201'
|
|
173
|
+
? 'Created'
|
|
174
|
+
: successStatus === '204'
|
|
175
|
+
? 'No Content'
|
|
176
|
+
: 'Successful Operation',
|
|
177
|
+
content: successStatus !== '204'
|
|
178
|
+
? {
|
|
179
|
+
'application/json': {
|
|
180
|
+
schema: { type: 'object' }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
: undefined
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// 6. Guard Reflection (Security & Error Status Codes)
|
|
187
|
+
let hasAuthGuard = false;
|
|
188
|
+
let hasRoleGuard = false;
|
|
189
|
+
let roleGuardRoles = [];
|
|
190
|
+
let hasRateLimitGuard = false;
|
|
191
|
+
let rateLimitRetryAfter = 60;
|
|
192
|
+
if (config.guards && config.guards.length > 0) {
|
|
193
|
+
for (const guard of config.guards) {
|
|
194
|
+
const meta = guard._yaxaGuard;
|
|
195
|
+
if (meta) {
|
|
196
|
+
if (meta.type === 'auth') {
|
|
197
|
+
hasAuthGuard = true;
|
|
198
|
+
}
|
|
199
|
+
else if (meta.type === 'role') {
|
|
200
|
+
hasRoleGuard = true;
|
|
201
|
+
if (meta.roles)
|
|
202
|
+
roleGuardRoles = meta.roles;
|
|
203
|
+
}
|
|
204
|
+
else if (meta.type === 'rateLimit') {
|
|
205
|
+
hasRateLimitGuard = true;
|
|
206
|
+
if (meta.options?.windowSeconds) {
|
|
207
|
+
rateLimitRetryAfter = meta.options.windowSeconds;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const operationSecurity = [];
|
|
214
|
+
if (hasAuthGuard || hasRoleGuard) {
|
|
215
|
+
usesSecurity = true;
|
|
216
|
+
securitySchemes['BearerAuth'] = {
|
|
217
|
+
type: 'http',
|
|
218
|
+
scheme: 'bearer',
|
|
219
|
+
bearerFormat: 'JWT',
|
|
220
|
+
description: 'Standard Bearer Token Header (`Authorization: Bearer <token>`)'
|
|
221
|
+
};
|
|
222
|
+
securitySchemes['CookieAuth'] = {
|
|
223
|
+
type: 'apiKey',
|
|
224
|
+
in: 'cookie',
|
|
225
|
+
name: 'better-auth.session_token',
|
|
226
|
+
description: 'Session cookie via Better-Auth'
|
|
227
|
+
};
|
|
228
|
+
operationSecurity.push({ BearerAuth: [] }, { CookieAuth: [] });
|
|
229
|
+
responses['401'] = {
|
|
230
|
+
description: 'Unauthorized (RFC 7807 Problem Details)',
|
|
231
|
+
content: {
|
|
232
|
+
'application/problem+json': {
|
|
233
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (hasRoleGuard) {
|
|
239
|
+
responses['403'] = {
|
|
240
|
+
description: `Forbidden — Insufficient role permissions. Requires: [${roleGuardRoles.join(', ')}]`,
|
|
241
|
+
content: {
|
|
242
|
+
'application/problem+json': {
|
|
243
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// 7. Schema Validation Error (422)
|
|
249
|
+
if (config.schema &&
|
|
250
|
+
(config.schema.body || config.schema.query || config.schema.params || config.schema.headers)) {
|
|
251
|
+
responses['422'] = {
|
|
252
|
+
description: 'Unprocessable Entity — Schema validation failed (RFC 7807)',
|
|
253
|
+
content: {
|
|
254
|
+
'application/problem+json': {
|
|
255
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// 8. Rate Limit Error (429)
|
|
261
|
+
if (hasRateLimitGuard) {
|
|
262
|
+
responses['429'] = {
|
|
263
|
+
description: 'Too Many Requests — Rate limit exceeded (RFC 7807)',
|
|
264
|
+
headers: {
|
|
265
|
+
'Retry-After': {
|
|
266
|
+
description: 'Seconds to wait before retrying',
|
|
267
|
+
schema: { type: 'integer', example: rateLimitRetryAfter }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
content: {
|
|
271
|
+
'application/problem+json': {
|
|
272
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// 9. Standard 500 Sanitized Error
|
|
278
|
+
responses['500'] = {
|
|
279
|
+
description: 'Internal Server Error — Sanitized exception (RFC 7807)',
|
|
280
|
+
content: {
|
|
281
|
+
'application/problem+json': {
|
|
282
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const operation = {
|
|
287
|
+
summary: config.summary || `${method.toUpperCase()} ${path}`,
|
|
288
|
+
description: config.description,
|
|
289
|
+
operationId: config.operationId ||
|
|
290
|
+
`${methodLower}_${path.replace(/[/{}]+/g, '_').replace(/^_+|_+$/g, '')}`,
|
|
291
|
+
tags: config.tags || (path.split('/')[2] ? [path.split('/')[2]] : ['Default']),
|
|
292
|
+
deprecated: config.deprecated,
|
|
293
|
+
parameters: parameters.length > 0 ? parameters : undefined,
|
|
294
|
+
requestBody,
|
|
295
|
+
responses,
|
|
296
|
+
security: operationSecurity.length > 0 ? operationSecurity : undefined
|
|
297
|
+
};
|
|
298
|
+
paths[path][methodLower] = operation;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
openapi: '3.1.0',
|
|
303
|
+
info: {
|
|
304
|
+
title,
|
|
305
|
+
version,
|
|
306
|
+
description
|
|
307
|
+
},
|
|
308
|
+
servers: options.servers || [{ url: '/', description: 'Current Environment' }],
|
|
309
|
+
paths,
|
|
310
|
+
components: {
|
|
311
|
+
schemas: {
|
|
312
|
+
ProblemDetails: PROBLEM_DETAILS_SCHEMA
|
|
313
|
+
},
|
|
314
|
+
securitySchemes: usesSecurity ? securitySchemes : undefined
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import type { ApiDocsOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
5
|
+
* page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createApiDocsHandler(options?: ApiDocsOptions): RequestHandler;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { generateOpenApiSpec } from './generator';
|
|
2
|
+
function renderScalarHtml(specJson, title) {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<title>${title}</title>
|
|
9
|
+
<style>
|
|
10
|
+
body { margin: 0; background-color: #0f172a; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<script id="api-reference" data-configuration='{"darkMode":true,"layout":"modern"}' type="application/json">
|
|
15
|
+
${specJson}
|
|
16
|
+
</script>
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>`;
|
|
20
|
+
}
|
|
21
|
+
function renderSwaggerHtml(specJson, title) {
|
|
22
|
+
return `<!DOCTYPE html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8" />
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
27
|
+
<title>${title}</title>
|
|
28
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
29
|
+
<style>
|
|
30
|
+
body { margin: 0; background: #fafafa; }
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<div id="swagger-ui"></div>
|
|
35
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
36
|
+
<script>
|
|
37
|
+
window.ui = SwaggerUIBundle({
|
|
38
|
+
spec: ${specJson},
|
|
39
|
+
dom_id: '#swagger-ui',
|
|
40
|
+
deepLinking: true,
|
|
41
|
+
presets: [
|
|
42
|
+
SwaggerUIBundle.presets.apis,
|
|
43
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
44
|
+
],
|
|
45
|
+
layout: "BaseLayout"
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
function renderRedocHtml(specJson, title) {
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8" />
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
57
|
+
<title>${title}</title>
|
|
58
|
+
<style>
|
|
59
|
+
body { margin: 0; padding: 0; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div id="redoc-container"></div>
|
|
64
|
+
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
|
65
|
+
<script>
|
|
66
|
+
Redoc.init(${specJson}, {
|
|
67
|
+
scrollYOffset: 50,
|
|
68
|
+
theme: {
|
|
69
|
+
colors: { primary: { main: '#3b82f6' } }
|
|
70
|
+
}
|
|
71
|
+
}, document.getElementById('redoc-container'));
|
|
72
|
+
</script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
78
|
+
* page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
79
|
+
*/
|
|
80
|
+
export function createApiDocsHandler(options = {}) {
|
|
81
|
+
const theme = options.theme || 'scalar';
|
|
82
|
+
return async (event) => {
|
|
83
|
+
const { url, request } = event;
|
|
84
|
+
const format = url.searchParams.get('format');
|
|
85
|
+
const acceptHeader = request.headers.get('accept') || '';
|
|
86
|
+
const spec = generateOpenApiSpec(options);
|
|
87
|
+
// If caller requests JSON via query param, accept header, or path ending in .json
|
|
88
|
+
if (format === 'json' ||
|
|
89
|
+
acceptHeader.includes('application/json') ||
|
|
90
|
+
url.pathname.endsWith('/openapi.json')) {
|
|
91
|
+
return Response.json(spec, {
|
|
92
|
+
headers: {
|
|
93
|
+
'content-type': 'application/json; charset=utf-8',
|
|
94
|
+
'cache-control': 'public, max-age=60'
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Render Interactive HTML Docs
|
|
99
|
+
const specJson = JSON.stringify(spec);
|
|
100
|
+
let html;
|
|
101
|
+
if (theme === 'swagger') {
|
|
102
|
+
html = renderSwaggerHtml(specJson, spec.info.title);
|
|
103
|
+
}
|
|
104
|
+
else if (theme === 'redoc') {
|
|
105
|
+
html = renderRedocHtml(specJson, spec.info.title);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
html = renderScalarHtml(specJson, spec.info.title);
|
|
109
|
+
}
|
|
110
|
+
return new Response(html, {
|
|
111
|
+
headers: {
|
|
112
|
+
'content-type': 'text/html; charset=utf-8',
|
|
113
|
+
'cache-control': 'public, max-age=300'
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { generateOpenApiSpec } from './generator';
|
|
2
|
+
export { createApiDocsHandler } from './handler';
|
|
3
|
+
export { ApiRegistry, createApiRegistry, globalApiRegistry } from './registry';
|
|
4
|
+
export { convertToJsonSchema } from './schema';
|
|
5
|
+
export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createMockRequestEvent } from '../../testing/index.js';
|
|
4
|
+
import { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './index.js';
|
|
5
|
+
import { yaxaEndpoint } from '../endpoint/index.js';
|
|
6
|
+
import { requireAuth, requireRole, rateLimit } from '../endpoint/guards.js';
|
|
7
|
+
describe('OpenAPI 3.1 Schema Engine', () => {
|
|
8
|
+
it('converts Zod schema into compliant JSON Schema', () => {
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
title: z.string().min(3),
|
|
11
|
+
count: z.number().optional(),
|
|
12
|
+
role: z.enum(['admin', 'member'])
|
|
13
|
+
});
|
|
14
|
+
const jsonSchema = convertToJsonSchema(schema);
|
|
15
|
+
expect(jsonSchema.type).toBe('object');
|
|
16
|
+
expect(jsonSchema.properties.title.type).toBe('string');
|
|
17
|
+
expect(jsonSchema.properties.count.type).toBe('number');
|
|
18
|
+
expect(jsonSchema.required).toContain('title');
|
|
19
|
+
expect(jsonSchema.required).toContain('role');
|
|
20
|
+
expect(jsonSchema.required).not.toContain('count');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('API Route Registry', () => {
|
|
24
|
+
let registry;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
registry = createApiRegistry();
|
|
27
|
+
globalApiRegistry.clear();
|
|
28
|
+
});
|
|
29
|
+
it('normalizes SvelteKit bracket path params to OpenAPI curly braces', () => {
|
|
30
|
+
expect(registry.normalizePath('/api/items/[id]')).toBe('/api/items/{id}');
|
|
31
|
+
expect(registry.normalizePath('/api/orgs/[orgSlug]/users/[userId]')).toBe('/api/orgs/{orgSlug}/users/{userId}');
|
|
32
|
+
expect(registry.normalizePath('/api/files/[...rest]')).toBe('/api/files/{rest}');
|
|
33
|
+
});
|
|
34
|
+
it('registers endpoints and retrieves them in normalized form', () => {
|
|
35
|
+
const getHandler = yaxaEndpoint({ handle: () => [] });
|
|
36
|
+
registry.register('/api/users/[id]', { GET: getHandler });
|
|
37
|
+
const endpoints = registry.getEndpoints();
|
|
38
|
+
expect(endpoints['/api/users/{id}']).toBeDefined();
|
|
39
|
+
expect(endpoints['/api/users/{id}']['GET']).toBe(getHandler);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('generateOpenApiSpec Engine', () => {
|
|
43
|
+
const ItemParamsSchema = z.object({
|
|
44
|
+
id: z.string()
|
|
45
|
+
});
|
|
46
|
+
const ItemQuerySchema = z.object({
|
|
47
|
+
filter: z.string().optional(),
|
|
48
|
+
limit: z.number().optional()
|
|
49
|
+
});
|
|
50
|
+
const CreateItemBodySchema = z.object({
|
|
51
|
+
name: z.string().min(2),
|
|
52
|
+
price: z.number().positive()
|
|
53
|
+
});
|
|
54
|
+
it('generates complete OpenAPI 3.1 document with parameters, body and tags', () => {
|
|
55
|
+
const registry = createApiRegistry();
|
|
56
|
+
const getEndpoint = yaxaEndpoint({
|
|
57
|
+
summary: 'Get Item By ID',
|
|
58
|
+
description: 'Retrieves an item record by its unique ID',
|
|
59
|
+
tags: ['Items'],
|
|
60
|
+
schema: {
|
|
61
|
+
params: ItemParamsSchema,
|
|
62
|
+
query: ItemQuerySchema
|
|
63
|
+
},
|
|
64
|
+
handle: ({ params }) => ({ id: params.id })
|
|
65
|
+
});
|
|
66
|
+
const postEndpoint = yaxaEndpoint({
|
|
67
|
+
summary: 'Create Item',
|
|
68
|
+
tags: ['Items'],
|
|
69
|
+
schema: {
|
|
70
|
+
body: CreateItemBodySchema
|
|
71
|
+
},
|
|
72
|
+
handle: ({ body }) => body
|
|
73
|
+
});
|
|
74
|
+
registry.register('/api/items/[id]', { GET: getEndpoint });
|
|
75
|
+
registry.register('/api/items', { POST: postEndpoint });
|
|
76
|
+
const spec = generateOpenApiSpec({
|
|
77
|
+
title: 'Inventory API',
|
|
78
|
+
version: '2.0.0',
|
|
79
|
+
description: 'Test Inventory System',
|
|
80
|
+
registry
|
|
81
|
+
});
|
|
82
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
83
|
+
expect(spec.info.title).toBe('Inventory API');
|
|
84
|
+
expect(spec.info.version).toBe('2.0.0');
|
|
85
|
+
// Verify GET /api/items/{id}
|
|
86
|
+
const getOp = spec.paths['/api/items/{id}']['get'];
|
|
87
|
+
expect(getOp.summary).toBe('Get Item By ID');
|
|
88
|
+
expect(getOp.tags).toEqual(['Items']);
|
|
89
|
+
expect(getOp.parameters).toHaveLength(3); // id (path), filter (query), limit (query)
|
|
90
|
+
const idParam = getOp.parameters?.find((p) => p.name === 'id');
|
|
91
|
+
expect(idParam?.in).toBe('path');
|
|
92
|
+
expect(idParam?.required).toBe(true);
|
|
93
|
+
// Verify POST /api/items
|
|
94
|
+
const postOp = spec.paths['/api/items']['post'];
|
|
95
|
+
expect(postOp.summary).toBe('Create Item');
|
|
96
|
+
expect(postOp.requestBody).toBeDefined();
|
|
97
|
+
expect(postOp.requestBody?.content['application/json'].schema?.type).toBe('object');
|
|
98
|
+
expect(postOp.responses['201']).toBeDefined();
|
|
99
|
+
expect(postOp.responses['422']).toBeDefined(); // Schema validation failed
|
|
100
|
+
});
|
|
101
|
+
it('deduces security schemes and RFC 7807 responses from guards automatically', () => {
|
|
102
|
+
const registry = createApiRegistry();
|
|
103
|
+
const secureEndpoint = yaxaEndpoint({
|
|
104
|
+
guards: [
|
|
105
|
+
requireAuth(),
|
|
106
|
+
requireRole(['admin', 'billing_manager']),
|
|
107
|
+
rateLimit({ points: 15, windowSeconds: 60 })
|
|
108
|
+
],
|
|
109
|
+
schema: {
|
|
110
|
+
body: CreateItemBodySchema
|
|
111
|
+
},
|
|
112
|
+
handle: () => ({ ok: true })
|
|
113
|
+
});
|
|
114
|
+
registry.register('/api/secure-action', { POST: secureEndpoint });
|
|
115
|
+
const spec = generateOpenApiSpec({ registry });
|
|
116
|
+
const op = spec.paths['/api/secure-action']['post'];
|
|
117
|
+
// Security schemes attached
|
|
118
|
+
expect(spec.components?.securitySchemes?.BearerAuth).toBeDefined();
|
|
119
|
+
expect(spec.components?.securitySchemes?.CookieAuth).toBeDefined();
|
|
120
|
+
expect(op.security).toBeDefined();
|
|
121
|
+
// Guard error responses generated
|
|
122
|
+
expect(op.responses['401']).toBeDefined();
|
|
123
|
+
expect(op.responses['403']).toBeDefined();
|
|
124
|
+
expect(op.responses['403'].description).toContain('admin, billing_manager');
|
|
125
|
+
expect(op.responses['422']).toBeDefined();
|
|
126
|
+
expect(op.responses['429']).toBeDefined();
|
|
127
|
+
expect(op.responses['429'].headers?.['Retry-After']).toBeDefined();
|
|
128
|
+
expect(op.responses['500']).toBeDefined();
|
|
129
|
+
// Components schema ProblemDetails registered
|
|
130
|
+
expect(spec.components?.schemas?.ProblemDetails).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
it('supports self-registration via yaxaEndpoint path and method metadata', () => {
|
|
133
|
+
globalApiRegistry.clear();
|
|
134
|
+
yaxaEndpoint({
|
|
135
|
+
path: '/api/v1/auto-registered',
|
|
136
|
+
method: 'GET',
|
|
137
|
+
summary: 'Auto Registered Route',
|
|
138
|
+
handle: () => 'ok'
|
|
139
|
+
});
|
|
140
|
+
const spec = generateOpenApiSpec();
|
|
141
|
+
expect(spec.paths['/api/v1/auto-registered']?.['get']?.summary).toBe('Auto Registered Route');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('createApiDocsHandler Endpoint', () => {
|
|
145
|
+
it('serves OpenAPI 3.1 JSON when format=json query parameter is present', async () => {
|
|
146
|
+
const handler = createApiDocsHandler({
|
|
147
|
+
title: 'Store API',
|
|
148
|
+
version: '1.2.3'
|
|
149
|
+
});
|
|
150
|
+
const event = createMockRequestEvent({
|
|
151
|
+
url: 'http://localhost:5173/api/docs?format=json'
|
|
152
|
+
});
|
|
153
|
+
const response = await handler(event);
|
|
154
|
+
expect(response.status).toBe(200);
|
|
155
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
156
|
+
const json = await response.json();
|
|
157
|
+
expect(json.openapi).toBe('3.1.0');
|
|
158
|
+
expect(json.info.title).toBe('Store API');
|
|
159
|
+
expect(json.info.version).toBe('1.2.3');
|
|
160
|
+
});
|
|
161
|
+
it('serves OpenAPI 3.1 JSON when Accept header requests application/json', async () => {
|
|
162
|
+
const handler = createApiDocsHandler();
|
|
163
|
+
const event = createMockRequestEvent({
|
|
164
|
+
url: 'http://localhost:5173/api/docs',
|
|
165
|
+
headers: { accept: 'application/json' }
|
|
166
|
+
});
|
|
167
|
+
const response = await handler(event);
|
|
168
|
+
expect(response.status).toBe(200);
|
|
169
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
170
|
+
});
|
|
171
|
+
it('renders Scalar HTML documentation by default', async () => {
|
|
172
|
+
const handler = createApiDocsHandler({
|
|
173
|
+
title: 'Scalar Docs Test',
|
|
174
|
+
theme: 'scalar'
|
|
175
|
+
});
|
|
176
|
+
const event = createMockRequestEvent({
|
|
177
|
+
url: 'http://localhost:5173/api/docs'
|
|
178
|
+
});
|
|
179
|
+
const response = await handler(event);
|
|
180
|
+
expect(response.status).toBe(200);
|
|
181
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
182
|
+
const html = await response.text();
|
|
183
|
+
expect(html).toContain('<title>Scalar Docs Test</title>');
|
|
184
|
+
expect(html).toContain('@scalar/api-reference');
|
|
185
|
+
expect(html).toContain('id="api-reference"');
|
|
186
|
+
});
|
|
187
|
+
it('renders Swagger UI HTML when theme is swagger', async () => {
|
|
188
|
+
const handler = createApiDocsHandler({
|
|
189
|
+
title: 'Swagger Docs Test',
|
|
190
|
+
theme: 'swagger'
|
|
191
|
+
});
|
|
192
|
+
const event = createMockRequestEvent({
|
|
193
|
+
url: 'http://localhost:5173/api/docs'
|
|
194
|
+
});
|
|
195
|
+
const response = await handler(event);
|
|
196
|
+
expect(response.status).toBe(200);
|
|
197
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
198
|
+
const html = await response.text();
|
|
199
|
+
expect(html).toContain('<title>Swagger Docs Test</title>');
|
|
200
|
+
expect(html).toContain('swagger-ui-dist');
|
|
201
|
+
expect(html).toContain('SwaggerUIBundle');
|
|
202
|
+
});
|
|
203
|
+
it('renders Redoc HTML when theme is redoc', async () => {
|
|
204
|
+
const handler = createApiDocsHandler({
|
|
205
|
+
title: 'Redoc Docs Test',
|
|
206
|
+
theme: 'redoc'
|
|
207
|
+
});
|
|
208
|
+
const event = createMockRequestEvent({
|
|
209
|
+
url: 'http://localhost:5173/api/docs'
|
|
210
|
+
});
|
|
211
|
+
const response = await handler(event);
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
214
|
+
const html = await response.text();
|
|
215
|
+
expect(html).toContain('<title>Redoc Docs Test</title>');
|
|
216
|
+
expect(html).toContain('redoc.standalone.js');
|
|
217
|
+
expect(html).toContain('redoc-container');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EndpointLike, EndpointMap } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Route & Endpoint Registry for OpenAPI discovery and introspection.
|
|
4
|
+
*/
|
|
5
|
+
export declare class ApiRegistry {
|
|
6
|
+
private endpoints;
|
|
7
|
+
/**
|
|
8
|
+
* Registers endpoints for a given path.
|
|
9
|
+
* @param path SvelteKit route path (e.g. '/api/projects' or '/api/projects/[id]')
|
|
10
|
+
* @param methods Map of HTTP methods to handlers or yaxaEndpoint configs
|
|
11
|
+
*/
|
|
12
|
+
register(path: string, methods: Record<string, EndpointLike>): this;
|
|
13
|
+
/**
|
|
14
|
+
* Retrieves all registered endpoints.
|
|
15
|
+
*/
|
|
16
|
+
getEndpoints(): EndpointMap;
|
|
17
|
+
/**
|
|
18
|
+
* Clears registered endpoints (useful in testing).
|
|
19
|
+
*/
|
|
20
|
+
clear(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes SvelteKit path parameters to OpenAPI format:
|
|
23
|
+
* '/api/projects/[id]' -> '/api/projects/{id}'
|
|
24
|
+
* '/api/teams/[orgSlug]/members/[userId]' -> '/api/teams/{orgSlug}/members/{userId}'
|
|
25
|
+
*/
|
|
26
|
+
normalizePath(path: string): string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Global singleton API registry for zero-config route registration.
|
|
30
|
+
*/
|
|
31
|
+
export declare const globalApiRegistry: ApiRegistry;
|
|
32
|
+
/**
|
|
33
|
+
* Creates a new isolated API registry instance.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createApiRegistry(): ApiRegistry;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route & Endpoint Registry for OpenAPI discovery and introspection.
|
|
3
|
+
*/
|
|
4
|
+
export class ApiRegistry {
|
|
5
|
+
endpoints = {};
|
|
6
|
+
/**
|
|
7
|
+
* Registers endpoints for a given path.
|
|
8
|
+
* @param path SvelteKit route path (e.g. '/api/projects' or '/api/projects/[id]')
|
|
9
|
+
* @param methods Map of HTTP methods to handlers or yaxaEndpoint configs
|
|
10
|
+
*/
|
|
11
|
+
register(path, methods) {
|
|
12
|
+
const normalizedPath = this.normalizePath(path);
|
|
13
|
+
if (!this.endpoints[normalizedPath]) {
|
|
14
|
+
this.endpoints[normalizedPath] = {};
|
|
15
|
+
}
|
|
16
|
+
for (const [method, handler] of Object.entries(methods)) {
|
|
17
|
+
this.endpoints[normalizedPath][method.toUpperCase()] = handler;
|
|
18
|
+
}
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Retrieves all registered endpoints.
|
|
23
|
+
*/
|
|
24
|
+
getEndpoints() {
|
|
25
|
+
return this.endpoints;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Clears registered endpoints (useful in testing).
|
|
29
|
+
*/
|
|
30
|
+
clear() {
|
|
31
|
+
this.endpoints = {};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes SvelteKit path parameters to OpenAPI format:
|
|
35
|
+
* '/api/projects/[id]' -> '/api/projects/{id}'
|
|
36
|
+
* '/api/teams/[orgSlug]/members/[userId]' -> '/api/teams/{orgSlug}/members/{userId}'
|
|
37
|
+
*/
|
|
38
|
+
normalizePath(path) {
|
|
39
|
+
return path.replace(/\[\.\.\.(\w+)\]/g, '{$1}').replace(/\[(\w+)\]/g, '{$1}');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Global singleton API registry for zero-config route registration.
|
|
44
|
+
*/
|
|
45
|
+
export const globalApiRegistry = new ApiRegistry();
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new isolated API registry instance.
|
|
48
|
+
*/
|
|
49
|
+
export function createApiRegistry() {
|
|
50
|
+
return new ApiRegistry();
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Converts a Zod or Standard Schema validator into a compliant OpenAPI 3.1 JSON Schema.
|
|
4
|
+
*/
|
|
5
|
+
export function convertToJsonSchema(validator) {
|
|
6
|
+
if (!validator) {
|
|
7
|
+
return { type: 'object' };
|
|
8
|
+
}
|
|
9
|
+
// 1. Zod native JSON schema converter
|
|
10
|
+
if (typeof z?.toJSONSchema === 'function') {
|
|
11
|
+
try {
|
|
12
|
+
const jsonSchema = z.toJSONSchema(validator);
|
|
13
|
+
if (jsonSchema && typeof jsonSchema === 'object') {
|
|
14
|
+
const cleanSchema = { ...jsonSchema };
|
|
15
|
+
delete cleanSchema.$schema;
|
|
16
|
+
return cleanSchema;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Fall through to other strategies
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// 2. Check if validator has built-in jsonSchema method/property
|
|
24
|
+
if (typeof validator.jsonSchema === 'function') {
|
|
25
|
+
try {
|
|
26
|
+
const res = validator.jsonSchema();
|
|
27
|
+
const cleanSchema = { ...res };
|
|
28
|
+
delete cleanSchema.$schema;
|
|
29
|
+
return cleanSchema;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Fall through
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (typeof validator.jsonSchema === 'object' && validator.jsonSchema !== null) {
|
|
36
|
+
const cleanSchema = { ...validator.jsonSchema };
|
|
37
|
+
delete cleanSchema.$schema;
|
|
38
|
+
return cleanSchema;
|
|
39
|
+
}
|
|
40
|
+
// 3. Heuristic for common object schema shapes
|
|
41
|
+
if (typeof validator === 'object' && validator !== null) {
|
|
42
|
+
if ('shape' in validator && typeof validator.shape === 'object') {
|
|
43
|
+
const properties = {};
|
|
44
|
+
const required = [];
|
|
45
|
+
for (const [key, val] of Object.entries(validator.shape)) {
|
|
46
|
+
properties[key] = convertToJsonSchema(val);
|
|
47
|
+
if (!val?.isOptional?.()) {
|
|
48
|
+
required.push(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties,
|
|
54
|
+
...(required.length > 0 ? { required } : {})
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { type: 'string' };
|
|
59
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import type { YaxaEndpointConfig } from '../endpoint/types';
|
|
3
|
+
/**
|
|
4
|
+
* OpenAPI 3.1 Parameter Object
|
|
5
|
+
*/
|
|
6
|
+
export interface OpenApiParameter {
|
|
7
|
+
name: string;
|
|
8
|
+
in: 'query' | 'header' | 'path' | 'cookie';
|
|
9
|
+
description?: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
deprecated?: boolean;
|
|
12
|
+
schema?: Record<string, any>;
|
|
13
|
+
example?: any;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* OpenAPI 3.1 Request Body Object
|
|
17
|
+
*/
|
|
18
|
+
export interface OpenApiRequestBody {
|
|
19
|
+
description?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
content: {
|
|
22
|
+
[mediaType: string]: {
|
|
23
|
+
schema?: Record<string, any>;
|
|
24
|
+
example?: any;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* OpenAPI 3.1 Response Object
|
|
30
|
+
*/
|
|
31
|
+
export interface OpenApiResponse {
|
|
32
|
+
description: string;
|
|
33
|
+
headers?: Record<string, any>;
|
|
34
|
+
content?: {
|
|
35
|
+
[mediaType: string]: {
|
|
36
|
+
schema?: Record<string, any>;
|
|
37
|
+
example?: any;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* OpenAPI 3.1 Operation Object
|
|
43
|
+
*/
|
|
44
|
+
export interface OpenApiOperation {
|
|
45
|
+
summary?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
operationId?: string;
|
|
48
|
+
tags?: string[];
|
|
49
|
+
parameters?: OpenApiParameter[];
|
|
50
|
+
requestBody?: OpenApiRequestBody;
|
|
51
|
+
responses: Record<string, OpenApiResponse>;
|
|
52
|
+
deprecated?: boolean;
|
|
53
|
+
security?: Array<Record<string, string[]>>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* OpenAPI 3.1 Specification Object
|
|
57
|
+
*/
|
|
58
|
+
export interface OpenApiSpec31 {
|
|
59
|
+
openapi: '3.1.0';
|
|
60
|
+
info: {
|
|
61
|
+
title: string;
|
|
62
|
+
version: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
termsOfService?: string;
|
|
65
|
+
contact?: {
|
|
66
|
+
name?: string;
|
|
67
|
+
url?: string;
|
|
68
|
+
email?: string;
|
|
69
|
+
};
|
|
70
|
+
license?: {
|
|
71
|
+
name: string;
|
|
72
|
+
url?: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
servers?: Array<{
|
|
76
|
+
url: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
}>;
|
|
79
|
+
paths: {
|
|
80
|
+
[path: string]: {
|
|
81
|
+
[method: string]: OpenApiOperation;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
components?: {
|
|
85
|
+
schemas?: Record<string, any>;
|
|
86
|
+
securitySchemes?: Record<string, any>;
|
|
87
|
+
responses?: Record<string, OpenApiResponse>;
|
|
88
|
+
parameters?: Record<string, OpenApiParameter>;
|
|
89
|
+
};
|
|
90
|
+
tags?: Array<{
|
|
91
|
+
name: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
}>;
|
|
94
|
+
}
|
|
95
|
+
export type EndpointLike = (RequestHandler & {
|
|
96
|
+
_yaxaConfig?: YaxaEndpointConfig;
|
|
97
|
+
}) | YaxaEndpointConfig;
|
|
98
|
+
export type EndpointMap = Record<string, Record<string, EndpointLike>>;
|
|
99
|
+
export interface GenerateOpenApiOptions {
|
|
100
|
+
title?: string;
|
|
101
|
+
version?: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
servers?: Array<{
|
|
104
|
+
url: string;
|
|
105
|
+
description?: string;
|
|
106
|
+
}>;
|
|
107
|
+
endpoints?: EndpointMap;
|
|
108
|
+
registry?: any;
|
|
109
|
+
}
|
|
110
|
+
export type ApiDocsTheme = 'scalar' | 'swagger' | 'redoc';
|
|
111
|
+
export interface ApiDocsOptions extends GenerateOpenApiOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Interactive UI theme:
|
|
114
|
+
* - 'scalar' (default): Modern, dark-mode native, high contrast developer reference.
|
|
115
|
+
* - 'swagger': Classic Swagger UI with interactive Try-It-Out console.
|
|
116
|
+
* - 'redoc': Clean 3-panel technical documentation format.
|
|
117
|
+
*/
|
|
118
|
+
theme?: ApiDocsTheme;
|
|
119
|
+
/**
|
|
120
|
+
* Mount path of documentation endpoint (default: '/api/docs').
|
|
121
|
+
*/
|
|
122
|
+
docsPath?: string;
|
|
123
|
+
/**
|
|
124
|
+
* CDN version/URL overrides if needed.
|
|
125
|
+
*/
|
|
126
|
+
cdnUrl?: string;
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaxa-svelte",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|