yaxa-svelte 2.2.0 → 2.4.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 +85 -14
- package/dist/server/db/schema-pg.d.ts +262 -0
- package/dist/server/db/schema-pg.js +20 -0
- package/dist/server/db/schema-sqlite.d.ts +276 -0
- package/dist/server/db/schema-sqlite.js +20 -0
- package/dist/server/db/types.d.ts +18 -0
- 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 +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/jobs/events.d.ts +29 -0
- package/dist/server/jobs/events.js +74 -0
- package/dist/server/jobs/index.d.ts +4 -0
- package/dist/server/jobs/index.js +3 -0
- package/dist/server/jobs/jobs.test.d.ts +1 -0
- package/dist/server/jobs/jobs.test.js +189 -0
- package/dist/server/jobs/queue.d.ts +14 -0
- package/dist/server/jobs/queue.js +90 -0
- package/dist/server/jobs/runner.d.ts +12 -0
- package/dist/server/jobs/runner.js +176 -0
- package/dist/server/jobs/types.d.ts +136 -0
- package/dist/server/jobs/types.js +1 -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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { memoryJobStore } from './queue';
|
|
2
|
+
/**
|
|
3
|
+
* Declares a typed worker handler for a specific job name.
|
|
4
|
+
*/
|
|
5
|
+
export function defineWorker(name, handler, options = {}) {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
handler,
|
|
9
|
+
queue: options.queue || 'default'
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Calculates exponential backoff delay with jitter.
|
|
14
|
+
*/
|
|
15
|
+
function calculateBackoffDelay(attempt) {
|
|
16
|
+
const baseDelay = Math.min(3600000, Math.pow(2, attempt) * 1000);
|
|
17
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
18
|
+
return baseDelay + jitter;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates an embedded job runner capable of executing pending jobs either on-demand
|
|
22
|
+
* (for serverless cron invocations) or continuously in the background (for Node.js servers).
|
|
23
|
+
*/
|
|
24
|
+
export function createJobRunner(options) {
|
|
25
|
+
const queue = options.queue || 'default';
|
|
26
|
+
const batchSize = options.batchSize || 10;
|
|
27
|
+
const pollIntervalMs = options.pollIntervalMs || 3000;
|
|
28
|
+
const lockTimeoutMs = options.lockTimeoutMs || 5 * 60 * 1000;
|
|
29
|
+
const workerId = options.workerId || `worker_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
30
|
+
// Index worker handlers by name
|
|
31
|
+
const workerMap = new Map();
|
|
32
|
+
for (const worker of options.workers) {
|
|
33
|
+
workerMap.set(worker.name, worker.handler);
|
|
34
|
+
}
|
|
35
|
+
let activeTimer = null;
|
|
36
|
+
let isRunning = false;
|
|
37
|
+
let isProcessing = false;
|
|
38
|
+
/**
|
|
39
|
+
* Leases next batch of jobs from memory or database.
|
|
40
|
+
*/
|
|
41
|
+
const leaseNextBatch = async () => {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const leased = [];
|
|
44
|
+
// 1. Process from memory store
|
|
45
|
+
const candidates = Array.from(memoryJobStore.values()).filter((job) => {
|
|
46
|
+
if (job.queue !== queue)
|
|
47
|
+
return false;
|
|
48
|
+
const isReady = job.runAt.getTime() <= now;
|
|
49
|
+
const isPending = job.status === 'pending' && isReady;
|
|
50
|
+
const isStaleLocked = job.status === 'processing' && job.lockedAt && now - job.lockedAt.getTime() > lockTimeoutMs;
|
|
51
|
+
return isPending || isStaleLocked;
|
|
52
|
+
});
|
|
53
|
+
// Sort by priority DESC, runAt ASC
|
|
54
|
+
candidates.sort((a, b) => {
|
|
55
|
+
if (b.priority !== a.priority)
|
|
56
|
+
return b.priority - a.priority;
|
|
57
|
+
return a.runAt.getTime() - b.runAt.getTime();
|
|
58
|
+
});
|
|
59
|
+
const batch = candidates.slice(0, batchSize);
|
|
60
|
+
for (const job of batch) {
|
|
61
|
+
job.status = 'processing';
|
|
62
|
+
job.lockedAt = new Date(now);
|
|
63
|
+
job.lockedBy = workerId;
|
|
64
|
+
job.updatedAt = new Date(now);
|
|
65
|
+
leased.push(job);
|
|
66
|
+
}
|
|
67
|
+
return leased;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Processes the next batch of pending jobs.
|
|
71
|
+
*/
|
|
72
|
+
const processNextBatch = async () => {
|
|
73
|
+
if (isProcessing) {
|
|
74
|
+
return { processed: 0, succeeded: 0, failed: 0 };
|
|
75
|
+
}
|
|
76
|
+
isProcessing = true;
|
|
77
|
+
let succeeded = 0;
|
|
78
|
+
let failed = 0;
|
|
79
|
+
try {
|
|
80
|
+
const batch = await leaseNextBatch();
|
|
81
|
+
for (const job of batch) {
|
|
82
|
+
const handler = workerMap.get(job.name);
|
|
83
|
+
if (!handler) {
|
|
84
|
+
console.warn(`[JobRunner] No worker handler registered for job "${job.name}" (${job.id}). Leaving pending.`);
|
|
85
|
+
job.status = 'pending';
|
|
86
|
+
job.lockedAt = null;
|
|
87
|
+
job.lockedBy = null;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const logger = {
|
|
91
|
+
info: (msg, ...args) => console.log(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args),
|
|
92
|
+
warn: (msg, ...args) => console.warn(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args),
|
|
93
|
+
error: (msg, ...args) => console.error(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args)
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await handler(job.payload, { job, logger });
|
|
97
|
+
// Mark completed
|
|
98
|
+
const now = new Date();
|
|
99
|
+
job.status = 'completed';
|
|
100
|
+
job.completedAt = now;
|
|
101
|
+
job.updatedAt = now;
|
|
102
|
+
job.lockedAt = null;
|
|
103
|
+
job.lockedBy = null;
|
|
104
|
+
succeeded++;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const now = new Date();
|
|
108
|
+
job.attempts += 1;
|
|
109
|
+
job.lockedAt = null;
|
|
110
|
+
job.lockedBy = null;
|
|
111
|
+
job.failedReason = err?.message || String(err);
|
|
112
|
+
job.updatedAt = now;
|
|
113
|
+
if (job.attempts < job.maxAttempts) {
|
|
114
|
+
// Exponential backoff retry
|
|
115
|
+
const delayMs = calculateBackoffDelay(job.attempts);
|
|
116
|
+
job.status = 'pending';
|
|
117
|
+
job.runAt = new Date(Date.now() + delayMs);
|
|
118
|
+
logger.warn(`Job execution failed (Attempt ${job.attempts}/${job.maxAttempts}). Retrying in ${Math.round(delayMs / 1000)}s: ${job.failedReason}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Exceeded max attempts: Route to Dead Letter Queue (DLQ)
|
|
122
|
+
job.status = 'failed';
|
|
123
|
+
failed++;
|
|
124
|
+
logger.error(`Job failed permanently after ${job.attempts} attempts. Routed to Dead Letter Queue: ${job.failedReason}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
processed: batch.length,
|
|
130
|
+
succeeded,
|
|
131
|
+
failed
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
isProcessing = false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Continuous background polling loop.
|
|
140
|
+
*/
|
|
141
|
+
const poll = async () => {
|
|
142
|
+
if (!isRunning)
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
await processNextBatch();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error('[JobRunner] Unexpected error during polling cycle:', err);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
if (isRunning) {
|
|
152
|
+
activeTimer = setTimeout(poll, pollIntervalMs);
|
|
153
|
+
if (activeTimer && typeof activeTimer === 'object' && 'unref' in activeTimer) {
|
|
154
|
+
activeTimer.unref();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
processNextBatch,
|
|
161
|
+
start: () => {
|
|
162
|
+
if (isRunning)
|
|
163
|
+
return;
|
|
164
|
+
isRunning = true;
|
|
165
|
+
poll();
|
|
166
|
+
},
|
|
167
|
+
stop: () => {
|
|
168
|
+
isRunning = false;
|
|
169
|
+
if (activeTimer) {
|
|
170
|
+
clearTimeout(activeTimer);
|
|
171
|
+
activeTimer = null;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
isRunning: () => isRunning
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { AnyDb } from '../db';
|
|
2
|
+
import type { JobStatus } from '../db/types';
|
|
3
|
+
/**
|
|
4
|
+
* Persisted Job record structure.
|
|
5
|
+
*/
|
|
6
|
+
export interface JobRecord<TPayload = any> {
|
|
7
|
+
id: string;
|
|
8
|
+
queue: string;
|
|
9
|
+
name: string;
|
|
10
|
+
payload: TPayload;
|
|
11
|
+
status: JobStatus;
|
|
12
|
+
priority: number;
|
|
13
|
+
attempts: number;
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
runAt: Date;
|
|
16
|
+
lockedAt?: Date | null;
|
|
17
|
+
lockedBy?: string | null;
|
|
18
|
+
failedReason?: string | null;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
updatedAt: Date;
|
|
21
|
+
completedAt?: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Delay specifier:
|
|
25
|
+
* - String format: '30s', '5m', '2h', '1d'
|
|
26
|
+
* - Number: delay in milliseconds
|
|
27
|
+
* - Date: absolute execution timestamp
|
|
28
|
+
*/
|
|
29
|
+
export type JobDelay = string | number | Date;
|
|
30
|
+
export interface EnqueueJobOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Queue partition name (default: 'default').
|
|
33
|
+
*/
|
|
34
|
+
queue?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Execution delay or scheduled Date.
|
|
37
|
+
*/
|
|
38
|
+
delay?: JobDelay;
|
|
39
|
+
/**
|
|
40
|
+
* Job execution priority (higher number = higher priority). Default: 0.
|
|
41
|
+
*/
|
|
42
|
+
priority?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum retry attempts before routing to Dead Letter Queue (DLQ). Default: 3.
|
|
45
|
+
*/
|
|
46
|
+
maxAttempts?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Optional Drizzle transaction object for the Transactional Outbox pattern.
|
|
49
|
+
*/
|
|
50
|
+
tx?: any;
|
|
51
|
+
/**
|
|
52
|
+
* Optional custom Drizzle DB client instance.
|
|
53
|
+
*/
|
|
54
|
+
db?: AnyDb;
|
|
55
|
+
}
|
|
56
|
+
export interface WorkerContext<TPayload = any> {
|
|
57
|
+
job: JobRecord<TPayload>;
|
|
58
|
+
logger: {
|
|
59
|
+
info: (msg: string, ...args: any[]) => void;
|
|
60
|
+
warn: (msg: string, ...args: any[]) => void;
|
|
61
|
+
error: (msg: string, ...args: any[]) => void;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export type WorkerHandler<TPayload = any> = (payload: TPayload, context: WorkerContext<TPayload>) => Promise<void> | void;
|
|
65
|
+
export interface WorkerDefinition<TPayload = any> {
|
|
66
|
+
name: string;
|
|
67
|
+
handler: WorkerHandler<TPayload>;
|
|
68
|
+
queue?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface JobRunnerOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Drizzle database instance (Neon or Turso).
|
|
73
|
+
*/
|
|
74
|
+
db?: AnyDb;
|
|
75
|
+
/**
|
|
76
|
+
* Database driver ('neon' | 'turso' | 'sqlite').
|
|
77
|
+
*/
|
|
78
|
+
driver?: 'neon' | 'turso' | 'sqlite';
|
|
79
|
+
/**
|
|
80
|
+
* List of registered worker handlers.
|
|
81
|
+
*/
|
|
82
|
+
workers: WorkerDefinition<any>[];
|
|
83
|
+
/**
|
|
84
|
+
* Target queue name to poll (default: 'default').
|
|
85
|
+
*/
|
|
86
|
+
queue?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum jobs to lease and process per batch (default: 10).
|
|
89
|
+
*/
|
|
90
|
+
batchSize?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Polling interval in milliseconds for daemon mode (default: 3000ms).
|
|
93
|
+
*/
|
|
94
|
+
pollIntervalMs?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Lock timeout in milliseconds before a processing job is considered stale (default: 5 minutes).
|
|
97
|
+
*/
|
|
98
|
+
lockTimeoutMs?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Worker instance identifier (default: generated worker id).
|
|
101
|
+
*/
|
|
102
|
+
workerId?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface JobBatchResult {
|
|
105
|
+
processed: number;
|
|
106
|
+
succeeded: number;
|
|
107
|
+
failed: number;
|
|
108
|
+
}
|
|
109
|
+
export interface JobRunner {
|
|
110
|
+
/**
|
|
111
|
+
* Processes the next batch of pending jobs. Perfect for serverless cron endpoints (/api/cron/jobs).
|
|
112
|
+
*/
|
|
113
|
+
processNextBatch: () => Promise<JobBatchResult>;
|
|
114
|
+
/**
|
|
115
|
+
* Starts the continuous background polling loop (for long-lived Node.js servers).
|
|
116
|
+
*/
|
|
117
|
+
start: () => void;
|
|
118
|
+
/**
|
|
119
|
+
* Stops the background polling loop.
|
|
120
|
+
*/
|
|
121
|
+
stop: () => void;
|
|
122
|
+
/**
|
|
123
|
+
* Returns true if the polling runner is currently active.
|
|
124
|
+
*/
|
|
125
|
+
isRunning: () => boolean;
|
|
126
|
+
}
|
|
127
|
+
export type EventHandler<TData = any> = (data: TData) => Promise<void> | void;
|
|
128
|
+
export interface EventSubscription {
|
|
129
|
+
unsubscribe: () => void;
|
|
130
|
+
}
|
|
131
|
+
export interface IEventBus {
|
|
132
|
+
publish: <TData = any>(eventName: string, data: TData) => Promise<void>;
|
|
133
|
+
subscribe: <TData = any>(eventName: string, handler: EventHandler<TData>) => EventSubscription;
|
|
134
|
+
once: <TData = any>(eventName: string, handler: EventHandler<TData>) => EventSubscription;
|
|
135
|
+
clear: () => void;
|
|
136
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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;
|