astro-routify 1.2.1 → 1.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 +221 -4
- package/dist/core/RouteTrie.d.ts +4 -3
- package/dist/core/RouteTrie.js +101 -32
- package/dist/core/RouterBuilder.d.ts +96 -13
- package/dist/core/RouterBuilder.js +210 -21
- package/dist/core/decorators.d.ts +29 -0
- package/dist/core/decorators.js +49 -0
- package/dist/core/defineGroup.d.ts +32 -17
- package/dist/core/defineGroup.js +65 -23
- package/dist/core/defineHandler.d.ts +18 -8
- package/dist/core/defineHandler.js +1 -12
- package/dist/core/defineRoute.d.ts +27 -3
- package/dist/core/defineRoute.js +31 -9
- package/dist/core/defineRouter.d.ts +11 -1
- package/dist/core/defineRouter.js +63 -16
- package/dist/core/internal/createJsonStreamRoute.js +12 -4
- package/dist/core/middlewares.d.ts +47 -0
- package/dist/core/middlewares.js +110 -0
- package/dist/core/openapi.d.ts +17 -0
- package/dist/core/openapi.js +57 -0
- package/dist/core/registry.d.ts +15 -0
- package/dist/core/registry.js +26 -0
- package/dist/core/responseHelpers.d.ts +17 -0
- package/dist/core/responseHelpers.js +36 -6
- package/dist/core/stream.js +14 -7
- package/dist/index.d.ts +310 -43
- package/dist/index.js +5 -1
- package/package.json +5 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { internalError, toAstroResponse } from './responseHelpers';
|
|
1
|
+
import { internalError, toAstroResponse, isReadableStream } from './responseHelpers';
|
|
2
2
|
/**
|
|
3
3
|
* Logs the incoming request method and path to the console.
|
|
4
4
|
*/
|
|
@@ -57,14 +57,3 @@ export function defineHandler(handler) {
|
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Type guard to detect ReadableStreams, used for streamed/binary responses.
|
|
62
|
-
*
|
|
63
|
-
* @param value - Any value to test
|
|
64
|
-
* @returns True if it looks like a ReadableStream
|
|
65
|
-
*/
|
|
66
|
-
export function isReadableStream(value) {
|
|
67
|
-
return (typeof value === 'object' &&
|
|
68
|
-
value !== null &&
|
|
69
|
-
typeof value.getReader === 'function');
|
|
70
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpMethod } from './HttpMethod';
|
|
2
|
-
import type { Handler } from './defineHandler';
|
|
2
|
+
import type { Handler, Middleware } from './defineHandler';
|
|
3
3
|
/**
|
|
4
4
|
* Represents a single route definition.
|
|
5
5
|
*/
|
|
@@ -16,6 +16,14 @@ export interface Route {
|
|
|
16
16
|
* The function that handles the request when matched.
|
|
17
17
|
*/
|
|
18
18
|
handler: Handler;
|
|
19
|
+
/**
|
|
20
|
+
* Optional array of middlewares to run before the handler.
|
|
21
|
+
*/
|
|
22
|
+
middlewares?: Middleware[];
|
|
23
|
+
/**
|
|
24
|
+
* Optional metadata for the route (e.g., for OpenAPI generation).
|
|
25
|
+
*/
|
|
26
|
+
metadata?: Record<string, any>;
|
|
19
27
|
}
|
|
20
28
|
/**
|
|
21
29
|
* Defines a route using a `Route` object.
|
|
@@ -24,9 +32,10 @@ export interface Route {
|
|
|
24
32
|
* defineRoute({ method: 'GET', path: '/users', handler });
|
|
25
33
|
*
|
|
26
34
|
* @param route - A fully constructed route object
|
|
35
|
+
* @param autoRegister - If true, registers the route to the global registry
|
|
27
36
|
* @returns The validated Route object
|
|
28
37
|
*/
|
|
29
|
-
export declare function defineRoute(route: Route): Route;
|
|
38
|
+
export declare function defineRoute(route: Route, autoRegister?: boolean): Route;
|
|
30
39
|
/**
|
|
31
40
|
* Defines a route by specifying its method, path, and handler explicitly.
|
|
32
41
|
*
|
|
@@ -36,6 +45,21 @@ export declare function defineRoute(route: Route): Route;
|
|
|
36
45
|
* @param method - HTTP method to match
|
|
37
46
|
* @param path - Route path (must start with `/`)
|
|
38
47
|
* @param handler - Function to handle the matched request
|
|
48
|
+
* @param autoRegister - If true, registers the route to the global registry
|
|
39
49
|
* @returns The validated Route object
|
|
40
50
|
*/
|
|
41
|
-
export declare function defineRoute(method: HttpMethod, path: string, handler: Handler): Route;
|
|
51
|
+
export declare function defineRoute(method: HttpMethod, path: string, handler: Handler, autoRegister?: boolean): Route;
|
|
52
|
+
/**
|
|
53
|
+
* Ensures the route is properly formed and uses a valid method + path format.
|
|
54
|
+
*
|
|
55
|
+
* @param route - Route to validate
|
|
56
|
+
* @throws If method is unsupported or path doesn't start with `/`
|
|
57
|
+
*/
|
|
58
|
+
export declare function validateRoute({ method, path }: Route): void;
|
|
59
|
+
/**
|
|
60
|
+
* Checks if an object implements the `Route` interface.
|
|
61
|
+
*
|
|
62
|
+
* @param obj - The object to check
|
|
63
|
+
* @returns True if the object is a valid Route
|
|
64
|
+
*/
|
|
65
|
+
export declare function isRoute(obj: any): obj is Route;
|
package/dist/core/defineRoute.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { ALLOWED_HTTP_METHODS } from './HttpMethod';
|
|
2
|
+
import { globalRegistry } from './registry';
|
|
2
3
|
/**
|
|
3
4
|
* Internal route definition logic that supports both overloads.
|
|
4
5
|
*/
|
|
5
|
-
export function defineRoute(methodOrRoute,
|
|
6
|
+
export function defineRoute(methodOrRoute, maybePathOrAutoRegister, maybeHandler, maybeAutoRegister) {
|
|
7
|
+
let autoRegister = false;
|
|
8
|
+
let route;
|
|
6
9
|
if (typeof methodOrRoute === 'object') {
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
route = methodOrRoute;
|
|
11
|
+
autoRegister = !!maybePathOrAutoRegister;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
route = {
|
|
15
|
+
method: methodOrRoute,
|
|
16
|
+
path: maybePathOrAutoRegister,
|
|
17
|
+
handler: maybeHandler,
|
|
18
|
+
};
|
|
19
|
+
autoRegister = !!maybeAutoRegister;
|
|
9
20
|
}
|
|
10
|
-
const route = {
|
|
11
|
-
method: methodOrRoute,
|
|
12
|
-
path: maybePath,
|
|
13
|
-
handler: maybeHandler,
|
|
14
|
-
};
|
|
15
21
|
validateRoute(route);
|
|
22
|
+
if (autoRegister) {
|
|
23
|
+
globalRegistry.register(route);
|
|
24
|
+
}
|
|
16
25
|
return route;
|
|
17
26
|
}
|
|
18
27
|
/**
|
|
@@ -21,7 +30,7 @@ export function defineRoute(methodOrRoute, maybePath, maybeHandler) {
|
|
|
21
30
|
* @param route - Route to validate
|
|
22
31
|
* @throws If method is unsupported or path doesn't start with `/`
|
|
23
32
|
*/
|
|
24
|
-
function validateRoute({ method, path }) {
|
|
33
|
+
export function validateRoute({ method, path }) {
|
|
25
34
|
if (!path.startsWith('/')) {
|
|
26
35
|
throw new Error(`Route path must start with '/': ${path}`);
|
|
27
36
|
}
|
|
@@ -29,3 +38,16 @@ function validateRoute({ method, path }) {
|
|
|
29
38
|
throw new Error(`Unsupported HTTP method in route: ${method}`);
|
|
30
39
|
}
|
|
31
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Checks if an object implements the `Route` interface.
|
|
43
|
+
*
|
|
44
|
+
* @param obj - The object to check
|
|
45
|
+
* @returns True if the object is a valid Route
|
|
46
|
+
*/
|
|
47
|
+
export function isRoute(obj) {
|
|
48
|
+
return (obj &&
|
|
49
|
+
typeof obj === 'object' &&
|
|
50
|
+
typeof obj.method === 'string' &&
|
|
51
|
+
typeof obj.path === 'string' &&
|
|
52
|
+
typeof obj.handler === 'function');
|
|
53
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
-
import {
|
|
2
|
+
import { type RoutifyContext } from './defineHandler';
|
|
3
|
+
import { internalError, notFound } from './responseHelpers';
|
|
3
4
|
import type { Route } from './defineRoute';
|
|
4
5
|
/**
|
|
5
6
|
* Optional configuration for the router instance.
|
|
@@ -14,6 +15,15 @@ export interface RouterOptions {
|
|
|
14
15
|
* Custom handler to return when no route is matched (404).
|
|
15
16
|
*/
|
|
16
17
|
onNotFound?: () => ReturnType<typeof notFound>;
|
|
18
|
+
/**
|
|
19
|
+
* If true, enables debug logging for route matching.
|
|
20
|
+
* Useful during development to trace which route is being matched.
|
|
21
|
+
*/
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Custom error handler for the router.
|
|
25
|
+
*/
|
|
26
|
+
onError?: (error: unknown, ctx: RoutifyContext) => ReturnType<typeof internalError> | Response;
|
|
17
27
|
}
|
|
18
28
|
/**
|
|
19
29
|
* Defines a router that dynamically matches registered routes based on method and path.
|
|
@@ -27,19 +27,40 @@ import { normalizeMethod } from './HttpMethod';
|
|
|
27
27
|
export function defineRouter(routes, options = {}) {
|
|
28
28
|
const trie = new RouteTrie();
|
|
29
29
|
for (const route of routes) {
|
|
30
|
-
trie.insert(route
|
|
30
|
+
trie.insert(route);
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
let basePath = options.basePath ?? '/api';
|
|
33
|
+
if (basePath !== '' && !basePath.startsWith('/')) {
|
|
34
|
+
basePath = '/' + basePath;
|
|
35
|
+
}
|
|
36
|
+
if (basePath.endsWith('/') && basePath !== '/') {
|
|
37
|
+
basePath = basePath.slice(0, -1);
|
|
38
|
+
}
|
|
39
|
+
if (basePath === '/') {
|
|
40
|
+
basePath = '';
|
|
41
|
+
}
|
|
42
|
+
const handler = defineHandler(async (routifyCtx) => {
|
|
43
|
+
const url = new URL(routifyCtx.request.url);
|
|
44
|
+
const pathname = url.pathname;
|
|
45
|
+
routifyCtx.query = Object.fromEntries(url.searchParams.entries());
|
|
46
|
+
routifyCtx.data = {};
|
|
47
|
+
let path = pathname;
|
|
48
|
+
if (basePath !== '') {
|
|
49
|
+
if (!pathname.startsWith(basePath)) {
|
|
50
|
+
return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
|
|
51
|
+
}
|
|
52
|
+
const nextChar = pathname.charAt(basePath.length);
|
|
53
|
+
if (nextChar !== '' && nextChar !== '/') {
|
|
54
|
+
return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
|
|
55
|
+
}
|
|
56
|
+
path = pathname.slice(basePath.length);
|
|
37
57
|
}
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
const method = normalizeMethod(routifyCtx.request.method);
|
|
59
|
+
const { route, allowed, params } = trie.find(path, method);
|
|
60
|
+
if (options.debug) {
|
|
61
|
+
console.log(`[RouterBuilder] ${method} ${path} -> ${route ? 'matched' : allowed && allowed.length ? '405' : '404'}`);
|
|
62
|
+
}
|
|
63
|
+
if (!route) {
|
|
43
64
|
// Method exists but not allowed for this route
|
|
44
65
|
if (allowed && allowed.length) {
|
|
45
66
|
return toAstroResponse(methodNotAllowed('Method Not Allowed', {
|
|
@@ -47,10 +68,36 @@ export function defineRouter(routes, options = {}) {
|
|
|
47
68
|
}));
|
|
48
69
|
}
|
|
49
70
|
// No route matched at all → 404
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
return toAstroResponse(options.onNotFound ? options.onNotFound() : notFound('Not Found'));
|
|
72
|
+
}
|
|
73
|
+
// Match found → delegate to handler with middlewares
|
|
74
|
+
routifyCtx.params = { ...routifyCtx.params, ...params };
|
|
75
|
+
try {
|
|
76
|
+
const middlewares = route.middlewares || [];
|
|
77
|
+
let index = -1;
|
|
78
|
+
const next = async () => {
|
|
79
|
+
index++;
|
|
80
|
+
if (index < middlewares.length) {
|
|
81
|
+
const mw = middlewares[index];
|
|
82
|
+
const res = await mw(routifyCtx, next);
|
|
83
|
+
return res instanceof Response ? res : toAstroResponse(res);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const result = await route.handler(routifyCtx);
|
|
87
|
+
return result instanceof Response ? result : toAstroResponse(result);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return await next();
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (options.onError) {
|
|
94
|
+
const errorRes = options.onError(err, routifyCtx);
|
|
95
|
+
return errorRes instanceof Response ? errorRes : toAstroResponse(errorRes);
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
52
98
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
99
|
+
});
|
|
100
|
+
handler.routes = routes;
|
|
101
|
+
handler.options = options;
|
|
102
|
+
return handler;
|
|
56
103
|
}
|
|
@@ -69,15 +69,23 @@ export function createJsonStreamRoute(path, handler, options) {
|
|
|
69
69
|
const body = new ReadableStream({
|
|
70
70
|
start(controller) {
|
|
71
71
|
controllerRef = controller;
|
|
72
|
-
|
|
72
|
+
const onAbort = () => {
|
|
73
73
|
closed = true;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
try {
|
|
75
|
+
controllerRef?.close();
|
|
76
|
+
}
|
|
77
|
+
catch { /* noop */ }
|
|
78
|
+
};
|
|
79
|
+
ctx.request.signal.addEventListener('abort', onAbort, { once: true });
|
|
80
|
+
Promise.resolve(handler({ ...ctx, response: writer }))
|
|
81
|
+
.catch((err) => {
|
|
77
82
|
try {
|
|
78
83
|
controller.error(err);
|
|
79
84
|
}
|
|
80
85
|
catch { /* noop */ }
|
|
86
|
+
})
|
|
87
|
+
.finally(() => {
|
|
88
|
+
ctx.request.signal.removeEventListener('abort', onAbort);
|
|
81
89
|
});
|
|
82
90
|
},
|
|
83
91
|
cancel() {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Middleware } from './defineHandler';
|
|
2
|
+
/**
|
|
3
|
+
* CORS options.
|
|
4
|
+
*/
|
|
5
|
+
export interface CorsOptions {
|
|
6
|
+
origin?: string | string[] | ((origin: string) => boolean | string);
|
|
7
|
+
methods?: string[];
|
|
8
|
+
allowedHeaders?: string[];
|
|
9
|
+
exposedHeaders?: string[];
|
|
10
|
+
credentials?: boolean;
|
|
11
|
+
maxAge?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Middleware to enable Cross-Origin Resource Sharing (CORS).
|
|
15
|
+
*/
|
|
16
|
+
export declare function cors(options?: CorsOptions): Middleware;
|
|
17
|
+
/**
|
|
18
|
+
* Middleware to add common security headers (Helmet-like).
|
|
19
|
+
*/
|
|
20
|
+
export declare function securityHeaders(): Middleware;
|
|
21
|
+
/**
|
|
22
|
+
* Interface for schemas compatible with Zod, Valibot, etc.
|
|
23
|
+
*/
|
|
24
|
+
export interface Validatable {
|
|
25
|
+
safeParse: (data: any) => {
|
|
26
|
+
success: true;
|
|
27
|
+
data: any;
|
|
28
|
+
} | {
|
|
29
|
+
success: false;
|
|
30
|
+
error: any;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validation schema for request components.
|
|
35
|
+
*/
|
|
36
|
+
export interface ValidationSchema {
|
|
37
|
+
body?: Validatable;
|
|
38
|
+
query?: Validatable;
|
|
39
|
+
params?: Validatable;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Middleware for request validation.
|
|
43
|
+
* Supports any schema library with a `safeParse` method (like Zod).
|
|
44
|
+
*
|
|
45
|
+
* Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validate(schema: ValidationSchema): Middleware;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { badRequest, toAstroResponse } from './responseHelpers';
|
|
2
|
+
/**
|
|
3
|
+
* Middleware to enable Cross-Origin Resource Sharing (CORS).
|
|
4
|
+
*/
|
|
5
|
+
export function cors(options = {}) {
|
|
6
|
+
return async (ctx, next) => {
|
|
7
|
+
const origin = ctx.request.headers.get('Origin');
|
|
8
|
+
// Handle preflight
|
|
9
|
+
if (ctx.request.method === 'OPTIONS') {
|
|
10
|
+
const headers = new Headers();
|
|
11
|
+
if (origin) {
|
|
12
|
+
headers.set('Access-Control-Allow-Origin', origin);
|
|
13
|
+
}
|
|
14
|
+
if (options.methods) {
|
|
15
|
+
headers.set('Access-Control-Allow-Methods', options.methods.join(','));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
19
|
+
}
|
|
20
|
+
if (options.allowedHeaders) {
|
|
21
|
+
headers.set('Access-Control-Allow-Headers', options.allowedHeaders.join(','));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const requestedHeaders = ctx.request.headers.get('Access-Control-Request-Headers');
|
|
25
|
+
if (requestedHeaders)
|
|
26
|
+
headers.set('Access-Control-Allow-Headers', requestedHeaders);
|
|
27
|
+
}
|
|
28
|
+
if (options.credentials) {
|
|
29
|
+
headers.set('Access-Control-Allow-Credentials', 'true');
|
|
30
|
+
}
|
|
31
|
+
if (options.maxAge) {
|
|
32
|
+
headers.set('Access-Control-Max-Age', String(options.maxAge));
|
|
33
|
+
}
|
|
34
|
+
return new Response(null, { status: 204, headers });
|
|
35
|
+
}
|
|
36
|
+
const res = await next();
|
|
37
|
+
const newHeaders = new Headers(res.headers);
|
|
38
|
+
if (origin) {
|
|
39
|
+
newHeaders.set('Access-Control-Allow-Origin', origin);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
newHeaders.set('Access-Control-Allow-Origin', '*');
|
|
43
|
+
}
|
|
44
|
+
if (options.credentials) {
|
|
45
|
+
newHeaders.set('Access-Control-Allow-Credentials', 'true');
|
|
46
|
+
}
|
|
47
|
+
if (options.exposedHeaders) {
|
|
48
|
+
newHeaders.set('Access-Control-Expose-Headers', options.exposedHeaders.join(','));
|
|
49
|
+
}
|
|
50
|
+
return new Response(res.body, {
|
|
51
|
+
status: res.status,
|
|
52
|
+
statusText: res.statusText,
|
|
53
|
+
headers: newHeaders
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Middleware to add common security headers (Helmet-like).
|
|
59
|
+
*/
|
|
60
|
+
export function securityHeaders() {
|
|
61
|
+
return async (ctx, next) => {
|
|
62
|
+
const res = await next();
|
|
63
|
+
const headers = new Headers(res.headers);
|
|
64
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
65
|
+
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
|
66
|
+
headers.set('X-XSS-Protection', '1; mode=block');
|
|
67
|
+
headers.set('Referrer-Policy', 'no-referrer-when-downgrade');
|
|
68
|
+
headers.set('Content-Security-Policy', "default-src 'self'");
|
|
69
|
+
return new Response(res.body, {
|
|
70
|
+
status: res.status,
|
|
71
|
+
statusText: res.statusText,
|
|
72
|
+
headers
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Middleware for request validation.
|
|
78
|
+
* Supports any schema library with a `safeParse` method (like Zod).
|
|
79
|
+
*
|
|
80
|
+
* Validated data is stored in `ctx.data.body`, `ctx.data.query`, etc.
|
|
81
|
+
*/
|
|
82
|
+
export function validate(schema) {
|
|
83
|
+
return async (ctx, next) => {
|
|
84
|
+
if (schema.params) {
|
|
85
|
+
const result = schema.params.safeParse(ctx.params);
|
|
86
|
+
if (!result.success)
|
|
87
|
+
return toAstroResponse(badRequest({ error: 'Invalid parameters', details: result.error }));
|
|
88
|
+
ctx.data.params = result.data;
|
|
89
|
+
}
|
|
90
|
+
if (schema.query) {
|
|
91
|
+
const result = schema.query.safeParse(ctx.query);
|
|
92
|
+
if (!result.success)
|
|
93
|
+
return toAstroResponse(badRequest({ error: 'Invalid query string', details: result.error }));
|
|
94
|
+
ctx.data.query = result.data;
|
|
95
|
+
}
|
|
96
|
+
if (schema.body) {
|
|
97
|
+
try {
|
|
98
|
+
const body = await ctx.request.clone().json();
|
|
99
|
+
const result = schema.body.safeParse(body);
|
|
100
|
+
if (!result.success)
|
|
101
|
+
return toAstroResponse(badRequest({ error: 'Invalid request body', details: result.error }));
|
|
102
|
+
ctx.data.body = result.data;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return toAstroResponse(badRequest({ error: 'Invalid JSON body' }));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return next();
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic options for OpenAPI generation.
|
|
3
|
+
*/
|
|
4
|
+
export interface OpenAPIOptions {
|
|
5
|
+
title: string;
|
|
6
|
+
version: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
basePath?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generates an OpenAPI 3.0.0 specification from a list of Routify routes.
|
|
12
|
+
*
|
|
13
|
+
* @param router - The router handler function (returned by builder.build() or createRouter())
|
|
14
|
+
* @param options - Basic info for the OpenAPI spec
|
|
15
|
+
* @returns A JSON object representing the OpenAPI specification
|
|
16
|
+
*/
|
|
17
|
+
export declare function generateOpenAPI(router: any, options: OpenAPIOptions): any;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an OpenAPI 3.0.0 specification from a list of Routify routes.
|
|
3
|
+
*
|
|
4
|
+
* @param router - The router handler function (returned by builder.build() or createRouter())
|
|
5
|
+
* @param options - Basic info for the OpenAPI spec
|
|
6
|
+
* @returns A JSON object representing the OpenAPI specification
|
|
7
|
+
*/
|
|
8
|
+
export function generateOpenAPI(router, options) {
|
|
9
|
+
const routes = router.routes || [];
|
|
10
|
+
const routerOptions = router.options || {};
|
|
11
|
+
const basePath = options.basePath ?? routerOptions.basePath ?? '/api';
|
|
12
|
+
const spec = {
|
|
13
|
+
openapi: '3.0.0',
|
|
14
|
+
info: {
|
|
15
|
+
title: options.title,
|
|
16
|
+
version: options.version,
|
|
17
|
+
description: options.description
|
|
18
|
+
},
|
|
19
|
+
servers: [
|
|
20
|
+
{ url: basePath }
|
|
21
|
+
],
|
|
22
|
+
paths: {}
|
|
23
|
+
};
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
const path = route.path;
|
|
26
|
+
// OpenAPI paths must start with / and should not include the basePath if it's in servers
|
|
27
|
+
// Convert :param or :param(regex) to {param}
|
|
28
|
+
const openApiPath = path.replace(/:([a-zA-Z0-9_]+)(\(.*?\))?/g, '{$1}');
|
|
29
|
+
if (!spec.paths[openApiPath])
|
|
30
|
+
spec.paths[openApiPath] = {};
|
|
31
|
+
const method = route.method.toLowerCase();
|
|
32
|
+
const metadata = route.metadata || {};
|
|
33
|
+
spec.paths[openApiPath][method] = {
|
|
34
|
+
summary: metadata.summary || `${route.method} ${path}`,
|
|
35
|
+
description: metadata.description,
|
|
36
|
+
tags: metadata.tags,
|
|
37
|
+
parameters: [],
|
|
38
|
+
responses: {
|
|
39
|
+
'200': {
|
|
40
|
+
description: 'Successful response'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
// Extract path parameters
|
|
45
|
+
const pathParamMatches = path.matchAll(/:([a-zA-Z0-9_]+)/g);
|
|
46
|
+
for (const match of pathParamMatches) {
|
|
47
|
+
const name = match[1];
|
|
48
|
+
spec.paths[openApiPath][method].parameters.push({
|
|
49
|
+
name,
|
|
50
|
+
in: 'path',
|
|
51
|
+
required: true,
|
|
52
|
+
schema: { type: 'string' }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return spec;
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A global registry for routes and groups to support "agnostic" auto-registration.
|
|
3
|
+
* This allows routes to be defined anywhere in the project and automatically
|
|
4
|
+
* picked up by the router.
|
|
5
|
+
*/
|
|
6
|
+
export declare class InternalRegistry {
|
|
7
|
+
private static instance;
|
|
8
|
+
private items;
|
|
9
|
+
private constructor();
|
|
10
|
+
static getInstance(): InternalRegistry;
|
|
11
|
+
register(item: any): void;
|
|
12
|
+
getItems(): any[];
|
|
13
|
+
clear(): void;
|
|
14
|
+
}
|
|
15
|
+
export declare const globalRegistry: InternalRegistry;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A global registry for routes and groups to support "agnostic" auto-registration.
|
|
3
|
+
* This allows routes to be defined anywhere in the project and automatically
|
|
4
|
+
* picked up by the router.
|
|
5
|
+
*/
|
|
6
|
+
export class InternalRegistry {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.items = [];
|
|
9
|
+
}
|
|
10
|
+
static getInstance() {
|
|
11
|
+
if (!InternalRegistry.instance) {
|
|
12
|
+
InternalRegistry.instance = new InternalRegistry();
|
|
13
|
+
}
|
|
14
|
+
return InternalRegistry.instance;
|
|
15
|
+
}
|
|
16
|
+
register(item) {
|
|
17
|
+
this.items.push(item);
|
|
18
|
+
}
|
|
19
|
+
getItems() {
|
|
20
|
+
return [...this.items];
|
|
21
|
+
}
|
|
22
|
+
clear() {
|
|
23
|
+
this.items = [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const globalRegistry = InternalRegistry.getInstance();
|
|
@@ -53,8 +53,18 @@ export declare const notFound: <T = string>(body?: T, headers?: HeadersInit) =>
|
|
|
53
53
|
* 405 Method Not Allowed
|
|
54
54
|
*/
|
|
55
55
|
export declare const methodNotAllowed: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
56
|
+
/**
|
|
57
|
+
* 429 Too Many Requests
|
|
58
|
+
*/
|
|
59
|
+
export declare const tooManyRequests: <T = string>(body?: T, headers?: HeadersInit) => ResultResponse<T>;
|
|
60
|
+
/**
|
|
61
|
+
* Returns a response with JSON body and specified status.
|
|
62
|
+
*/
|
|
63
|
+
export declare const json: (body: any, status?: number, headers?: HeadersInit) => ResultResponse<any>;
|
|
56
64
|
/**
|
|
57
65
|
* 500 Internal Server Error
|
|
66
|
+
*
|
|
67
|
+
* In production, you might want to avoid leaking error details.
|
|
58
68
|
*/
|
|
59
69
|
export declare const internalError: (err: unknown, headers?: HeadersInit) => ResultResponse<string>;
|
|
60
70
|
/**
|
|
@@ -67,6 +77,13 @@ export declare const internalError: (err: unknown, headers?: HeadersInit) => Res
|
|
|
67
77
|
* @returns A ResultResponse for download-ready content
|
|
68
78
|
*/
|
|
69
79
|
export declare const fileResponse: (content: Blob | ArrayBuffer | ReadableStream<Uint8Array>, contentType: string, fileName?: string, headers?: HeadersInit) => ResultResponse<BodyInit>;
|
|
80
|
+
/**
|
|
81
|
+
* Type guard to detect ReadableStreams, used for streamed/binary responses.
|
|
82
|
+
*
|
|
83
|
+
* @param value - Any value to test
|
|
84
|
+
* @returns True if it looks like a ReadableStream
|
|
85
|
+
*/
|
|
86
|
+
export declare function isReadableStream(value: unknown): value is ReadableStream<Uint8Array>;
|
|
70
87
|
/**
|
|
71
88
|
* Converts an internal `ResultResponse` into a native `Response` object
|
|
72
89
|
* for use inside Astro API routes.
|
|
@@ -43,10 +43,23 @@ export const notFound = (body = 'Not Found', headers) => createResponse(404, bod
|
|
|
43
43
|
* 405 Method Not Allowed
|
|
44
44
|
*/
|
|
45
45
|
export const methodNotAllowed = (body = 'Method Not Allowed', headers) => createResponse(405, body, headers);
|
|
46
|
+
/**
|
|
47
|
+
* 429 Too Many Requests
|
|
48
|
+
*/
|
|
49
|
+
export const tooManyRequests = (body = 'Too Many Requests', headers) => createResponse(429, body, headers);
|
|
50
|
+
/**
|
|
51
|
+
* Returns a response with JSON body and specified status.
|
|
52
|
+
*/
|
|
53
|
+
export const json = (body, status = 200, headers) => createResponse(status, body, headers);
|
|
46
54
|
/**
|
|
47
55
|
* 500 Internal Server Error
|
|
56
|
+
*
|
|
57
|
+
* In production, you might want to avoid leaking error details.
|
|
48
58
|
*/
|
|
49
|
-
export const internalError = (err, headers) =>
|
|
59
|
+
export const internalError = (err, headers) => {
|
|
60
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
61
|
+
return createResponse(500, message, headers);
|
|
62
|
+
};
|
|
50
63
|
/**
|
|
51
64
|
* Sends a binary or stream-based file response with optional content-disposition.
|
|
52
65
|
*
|
|
@@ -57,8 +70,9 @@ export const internalError = (err, headers) => createResponse(500, err instanceo
|
|
|
57
70
|
* @returns A ResultResponse for download-ready content
|
|
58
71
|
*/
|
|
59
72
|
export const fileResponse = (content, contentType, fileName, headers) => {
|
|
60
|
-
const
|
|
61
|
-
|
|
73
|
+
const sanitizedFileName = fileName?.replace(/"/g, '\\"');
|
|
74
|
+
const disposition = sanitizedFileName
|
|
75
|
+
? { 'Content-Disposition': `attachment; filename="${sanitizedFileName}"` }
|
|
62
76
|
: {};
|
|
63
77
|
return {
|
|
64
78
|
status: 200,
|
|
@@ -70,6 +84,22 @@ export const fileResponse = (content, contentType, fileName, headers) => {
|
|
|
70
84
|
},
|
|
71
85
|
};
|
|
72
86
|
};
|
|
87
|
+
function isPlainObject(value) {
|
|
88
|
+
return (typeof value === 'object' &&
|
|
89
|
+
value !== null &&
|
|
90
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Type guard to detect ReadableStreams, used for streamed/binary responses.
|
|
94
|
+
*
|
|
95
|
+
* @param value - Any value to test
|
|
96
|
+
* @returns True if it looks like a ReadableStream
|
|
97
|
+
*/
|
|
98
|
+
export function isReadableStream(value) {
|
|
99
|
+
return (typeof value === 'object' &&
|
|
100
|
+
value !== null &&
|
|
101
|
+
typeof value.getReader === 'function');
|
|
102
|
+
}
|
|
73
103
|
/**
|
|
74
104
|
* Converts an internal `ResultResponse` into a native `Response` object
|
|
75
105
|
* for use inside Astro API routes.
|
|
@@ -86,12 +116,12 @@ export function toAstroResponse(result) {
|
|
|
86
116
|
if (body === undefined || body === null) {
|
|
87
117
|
return new Response(null, { status, headers });
|
|
88
118
|
}
|
|
89
|
-
const
|
|
119
|
+
const isJson = isPlainObject(body) || Array.isArray(body);
|
|
90
120
|
const finalHeaders = {
|
|
91
121
|
...(headers ?? {}),
|
|
92
|
-
...(
|
|
122
|
+
...(isJson ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
|
|
93
123
|
};
|
|
94
|
-
return new Response(
|
|
124
|
+
return new Response(isJson ? JSON.stringify(body) : body, {
|
|
95
125
|
status,
|
|
96
126
|
headers: finalHeaders,
|
|
97
127
|
});
|