@forinda/kickjs-http 0.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/LICENSE +21 -0
- package/dist/application.d.ts +89 -0
- package/dist/application.js +10 -0
- package/dist/application.js.map +1 -0
- package/dist/bootstrap.d.ts +27 -0
- package/dist/bootstrap.js +11 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/chunk-75Z5FSZN.js +88 -0
- package/dist/chunk-75Z5FSZN.js.map +1 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-BNWCVQQH.js +54 -0
- package/dist/chunk-BNWCVQQH.js.map +1 -0
- package/dist/chunk-I6UNTOQD.js +52 -0
- package/dist/chunk-I6UNTOQD.js.map +1 -0
- package/dist/chunk-JD2RKDKH.js +61 -0
- package/dist/chunk-JD2RKDKH.js.map +1 -0
- package/dist/chunk-JM7X7SAD.js +133 -0
- package/dist/chunk-JM7X7SAD.js.map +1 -0
- package/dist/chunk-KAWXFLFS.js +50 -0
- package/dist/chunk-KAWXFLFS.js.map +1 -0
- package/dist/chunk-P3YCN5LK.js +196 -0
- package/dist/chunk-P3YCN5LK.js.map +1 -0
- package/dist/chunk-RZUH6NBM.js +110 -0
- package/dist/chunk-RZUH6NBM.js.map +1 -0
- package/dist/chunk-U2JYL2NW.js +62 -0
- package/dist/chunk-U2JYL2NW.js.map +1 -0
- package/dist/chunk-ZI52TGQ4.js +22 -0
- package/dist/chunk-ZI52TGQ4.js.map +1 -0
- package/dist/context.d.ts +55 -0
- package/dist/context.js +9 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/csrf.d.ts +49 -0
- package/dist/middleware/csrf.js +8 -0
- package/dist/middleware/csrf.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +8 -0
- package/dist/middleware/error-handler.js +10 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/request-id.d.ts +7 -0
- package/dist/middleware/request-id.js +10 -0
- package/dist/middleware/request-id.js.map +1 -0
- package/dist/middleware/upload.d.ts +57 -0
- package/dist/middleware/upload.js +10 -0
- package/dist/middleware/upload.js.map +1 -0
- package/dist/middleware/validate.d.ts +15 -0
- package/dist/middleware/validate.js +8 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/query/index.d.ts +47 -0
- package/dist/query/index.js +20 -0
- package/dist/query/index.js.map +1 -0
- package/dist/router-builder.d.ts +12 -0
- package/dist/router-builder.js +13 -0
- package/dist/router-builder.js.map +1 -0
- package/dist/types-Doz6f3AB.d.ts +72 -0
- package/package.json +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Felix Orinda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { RequestHandler, Express } from 'express';
|
|
3
|
+
import { AppModuleClass, AppAdapter } from '@forinda/kickjs-core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A middleware entry in the declarative pipeline.
|
|
7
|
+
* Can be a bare handler or an object with path scoping.
|
|
8
|
+
*/
|
|
9
|
+
type MiddlewareEntry = RequestHandler | {
|
|
10
|
+
path: string;
|
|
11
|
+
handler: RequestHandler;
|
|
12
|
+
};
|
|
13
|
+
interface ApplicationOptions {
|
|
14
|
+
/** Feature modules to load */
|
|
15
|
+
modules: AppModuleClass[];
|
|
16
|
+
/** Adapters that hook into the lifecycle (DB, Redis, Swagger, etc.) */
|
|
17
|
+
adapters?: AppAdapter[];
|
|
18
|
+
/** Server port (falls back to PORT env var, then 3000) */
|
|
19
|
+
port?: number;
|
|
20
|
+
/** Global API prefix (default: '/api') */
|
|
21
|
+
apiPrefix?: string;
|
|
22
|
+
/** Default API version (default: 1) — routes become /{prefix}/v{version}/{path} */
|
|
23
|
+
defaultVersion?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Global middleware pipeline. Declared in order.
|
|
26
|
+
* Replaces the hardcoded middleware stack — you control exactly what runs.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* bootstrap({
|
|
31
|
+
* modules,
|
|
32
|
+
* middleware: [
|
|
33
|
+
* helmet(),
|
|
34
|
+
* cors(),
|
|
35
|
+
* compression(),
|
|
36
|
+
* morgan('dev'),
|
|
37
|
+
* express.json({ limit: '1mb' }),
|
|
38
|
+
* ],
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* If omitted, a sensible default is applied:
|
|
43
|
+
* requestId(), express.json({ limit: '100kb' })
|
|
44
|
+
*/
|
|
45
|
+
middleware?: MiddlewareEntry[];
|
|
46
|
+
/** Express `trust proxy` setting */
|
|
47
|
+
trustProxy?: boolean | number | string | ((ip: string, hopIndex: number) => boolean);
|
|
48
|
+
/** Maximum JSON body size (only used when middleware is not provided) */
|
|
49
|
+
jsonLimit?: string | number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The main application class. Wires together Express, the DI container,
|
|
53
|
+
* feature modules, adapters, and the middleware pipeline.
|
|
54
|
+
*/
|
|
55
|
+
declare class Application {
|
|
56
|
+
private readonly options;
|
|
57
|
+
private app;
|
|
58
|
+
private container;
|
|
59
|
+
private httpServer;
|
|
60
|
+
private adapters;
|
|
61
|
+
constructor(options: ApplicationOptions);
|
|
62
|
+
/**
|
|
63
|
+
* Full setup pipeline:
|
|
64
|
+
* 1. Adapter beforeMount hooks (early routes — docs, health)
|
|
65
|
+
* 2. Adapter middleware (phase: beforeGlobal)
|
|
66
|
+
* 3. Global middleware (user-declared or defaults)
|
|
67
|
+
* 4. Adapter middleware (phase: afterGlobal)
|
|
68
|
+
* 5. Module registration + DI bootstrap
|
|
69
|
+
* 6. Adapter middleware (phase: beforeRoutes)
|
|
70
|
+
* 7. Module route mounting
|
|
71
|
+
* 8. Adapter middleware (phase: afterRoutes)
|
|
72
|
+
* 9. Error handlers (notFound + global)
|
|
73
|
+
* 10. Adapter beforeStart hooks
|
|
74
|
+
*/
|
|
75
|
+
setup(): void;
|
|
76
|
+
/** Start the HTTP server, retrying up to 3 times on port conflict */
|
|
77
|
+
start(): void;
|
|
78
|
+
/** HMR rebuild: swap Express handler without restarting the server */
|
|
79
|
+
rebuild(): void;
|
|
80
|
+
/** Graceful shutdown — runs all adapter shutdowns in parallel, resilient to failures */
|
|
81
|
+
shutdown(): Promise<void>;
|
|
82
|
+
getExpressApp(): Express;
|
|
83
|
+
getHttpServer(): http.Server | null;
|
|
84
|
+
private collectAdapterMiddleware;
|
|
85
|
+
private mountMiddlewareList;
|
|
86
|
+
private mountMiddlewareEntry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { Application, type ApplicationOptions, type MiddlewareEntry };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApplicationOptions } from './application.js';
|
|
2
|
+
import 'node:http';
|
|
3
|
+
import 'express';
|
|
4
|
+
import '@forinda/kickjs-core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bootstrap a KickJS application with zero boilerplate.
|
|
8
|
+
*
|
|
9
|
+
* Handles:
|
|
10
|
+
* - Vite HMR (hot-swaps Express handler without restarting the server)
|
|
11
|
+
* - Graceful shutdown on SIGINT / SIGTERM
|
|
12
|
+
* - Global uncaughtException / unhandledRejection handlers
|
|
13
|
+
* - globalThis app storage for HMR rebuild
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // src/index.ts — that's it, the whole file
|
|
18
|
+
* import 'reflect-metadata'
|
|
19
|
+
* import { bootstrap } from '@forinda/kickjs-http'
|
|
20
|
+
* import { modules } from './modules'
|
|
21
|
+
*
|
|
22
|
+
* bootstrap({ modules })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
declare function bootstrap(options: ApplicationOptions): void;
|
|
26
|
+
|
|
27
|
+
export { bootstrap };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/middleware/upload.ts
|
|
6
|
+
import { unlink } from "fs/promises";
|
|
7
|
+
import multer from "multer";
|
|
8
|
+
function single(fieldName, options = {}) {
|
|
9
|
+
const m = createMulter(options);
|
|
10
|
+
return m.single(fieldName);
|
|
11
|
+
}
|
|
12
|
+
__name(single, "single");
|
|
13
|
+
function array(fieldName, maxCount = 10, options = {}) {
|
|
14
|
+
const m = createMulter(options);
|
|
15
|
+
return m.array(fieldName, maxCount);
|
|
16
|
+
}
|
|
17
|
+
__name(array, "array");
|
|
18
|
+
function none(options = {}) {
|
|
19
|
+
const m = createMulter(options);
|
|
20
|
+
return m.none();
|
|
21
|
+
}
|
|
22
|
+
__name(none, "none");
|
|
23
|
+
function createMulter(options) {
|
|
24
|
+
const limits = {
|
|
25
|
+
fileSize: options.maxSize ?? 5 * 1024 * 1024
|
|
26
|
+
};
|
|
27
|
+
const fileFilter = options.allowedTypes ? (_req, file, cb) => {
|
|
28
|
+
const allowed = options.allowedTypes.some((type) => {
|
|
29
|
+
if (type.endsWith("/*")) {
|
|
30
|
+
return file.mimetype.startsWith(type.replace("/*", "/"));
|
|
31
|
+
}
|
|
32
|
+
return file.mimetype === type;
|
|
33
|
+
});
|
|
34
|
+
if (allowed) {
|
|
35
|
+
cb(null, true);
|
|
36
|
+
} else {
|
|
37
|
+
cb(new Error(`File type ${file.mimetype} is not allowed`));
|
|
38
|
+
}
|
|
39
|
+
} : void 0;
|
|
40
|
+
const multerOptions = {
|
|
41
|
+
limits,
|
|
42
|
+
...fileFilter ? {
|
|
43
|
+
fileFilter
|
|
44
|
+
} : {},
|
|
45
|
+
...options.storage ? {
|
|
46
|
+
storage: options.storage
|
|
47
|
+
} : {},
|
|
48
|
+
...options.dest ? {
|
|
49
|
+
dest: options.dest
|
|
50
|
+
} : {}
|
|
51
|
+
};
|
|
52
|
+
return multer(multerOptions);
|
|
53
|
+
}
|
|
54
|
+
__name(createMulter, "createMulter");
|
|
55
|
+
function cleanupFiles() {
|
|
56
|
+
return (req, res, next) => {
|
|
57
|
+
res.on("finish", async () => {
|
|
58
|
+
const files = [];
|
|
59
|
+
if (req.file?.path) {
|
|
60
|
+
files.push(req.file);
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(req.files)) {
|
|
63
|
+
for (const f of req.files) {
|
|
64
|
+
if (f?.path) files.push(f);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
try {
|
|
69
|
+
await unlink(file.path);
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
next();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
__name(cleanupFiles, "cleanupFiles");
|
|
78
|
+
var upload = {
|
|
79
|
+
single,
|
|
80
|
+
array,
|
|
81
|
+
none
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
cleanupFiles,
|
|
86
|
+
upload
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=chunk-75Z5FSZN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware/upload.ts"],"sourcesContent":["import { unlink } from 'node:fs/promises'\nimport type { Request, Response, NextFunction, RequestHandler } from 'express'\nimport multer, { type Options as MulterOptions } from 'multer'\n\nexport interface UploadOptions {\n /** Max file size in bytes (default: 5MB) */\n maxSize?: number\n /** Allowed MIME types (default: all) */\n allowedTypes?: string[]\n /** Multer storage config (default: memory storage) */\n storage?: MulterOptions['storage']\n /** Multer dest for disk storage shorthand */\n dest?: string\n}\n\n/**\n * Single file upload middleware. Attaches the file to `req.file`.\n *\n * @example\n * ```ts\n * @Post('/avatar')\n * @Middleware(upload.single('avatar', { maxSize: 2 * 1024 * 1024, allowedTypes: ['image/*'] }))\n * async uploadAvatar(ctx: RequestContext) {\n * ctx.json({ filename: ctx.file.originalname })\n * }\n * ```\n */\nfunction single(fieldName: string, options: UploadOptions = {}): RequestHandler {\n const m = createMulter(options)\n return m.single(fieldName) as RequestHandler\n}\n\n/**\n * Multiple file upload middleware. Attaches files to `req.files`.\n */\nfunction array(fieldName: string, maxCount = 10, options: UploadOptions = {}): RequestHandler {\n const m = createMulter(options)\n return m.array(fieldName, maxCount) as RequestHandler\n}\n\n/**\n * No file upload — just parse multipart form data without file fields.\n */\nfunction none(options: UploadOptions = {}): RequestHandler {\n const m = createMulter(options)\n return m.none() as RequestHandler\n}\n\nfunction createMulter(options: UploadOptions) {\n const limits: MulterOptions['limits'] = {\n fileSize: options.maxSize ?? 5 * 1024 * 1024,\n }\n\n const fileFilter: MulterOptions['fileFilter'] = options.allowedTypes\n ? (_req, file, cb) => {\n const allowed = options.allowedTypes!.some((type) => {\n if (type.endsWith('/*')) {\n return file.mimetype.startsWith(type.replace('/*', '/'))\n }\n return file.mimetype === type\n })\n if (allowed) {\n cb(null, true)\n } else {\n cb(new Error(`File type ${file.mimetype} is not allowed`))\n }\n }\n : undefined\n\n const multerOptions: MulterOptions = {\n limits,\n ...(fileFilter ? { fileFilter } : {}),\n ...(options.storage ? { storage: options.storage } : {}),\n ...(options.dest ? { dest: options.dest } : {}),\n }\n\n return multer(multerOptions)\n}\n\n/**\n * Middleware that automatically cleans up uploaded files after the response\n * is sent. Attach this AFTER your upload middleware.\n *\n * Only cleans up disk-stored files (files with a `path` property).\n *\n * @example\n * ```ts\n * middleware: [\n * upload.single('file', { dest: '/tmp/uploads' }),\n * cleanupFiles(),\n * ]\n * ```\n */\nexport function cleanupFiles() {\n return (req: Request, res: Response, next: NextFunction) => {\n res.on('finish', async () => {\n const files: any[] = []\n\n if ((req as any).file?.path) {\n files.push((req as any).file)\n }\n if (Array.isArray((req as any).files)) {\n for (const f of (req as any).files) {\n if (f?.path) files.push(f)\n }\n }\n\n for (const file of files) {\n try {\n await unlink(file.path)\n } catch {\n // File may already be moved/deleted by the handler — ignore\n }\n }\n })\n\n next()\n }\n}\n\n/** Upload middleware factory with `.single()`, `.array()`, `.none()` methods */\nexport const upload = { single, array, none }\n"],"mappings":";;;;;AAAA,SAASA,cAAc;AAEvB,OAAOC,YAA+C;AAyBtD,SAASC,OAAOC,WAAmBC,UAAyB,CAAC,GAAC;AAC5D,QAAMC,IAAIC,aAAaF,OAAAA;AACvB,SAAOC,EAAEH,OAAOC,SAAAA;AAClB;AAHSD;AAQT,SAASK,MAAMJ,WAAmBK,WAAW,IAAIJ,UAAyB,CAAC,GAAC;AAC1E,QAAMC,IAAIC,aAAaF,OAAAA;AACvB,SAAOC,EAAEE,MAAMJ,WAAWK,QAAAA;AAC5B;AAHSD;AAQT,SAASE,KAAKL,UAAyB,CAAC,GAAC;AACvC,QAAMC,IAAIC,aAAaF,OAAAA;AACvB,SAAOC,EAAEI,KAAI;AACf;AAHSA;AAKT,SAASH,aAAaF,SAAsB;AAC1C,QAAMM,SAAkC;IACtCC,UAAUP,QAAQQ,WAAW,IAAI,OAAO;EAC1C;AAEA,QAAMC,aAA0CT,QAAQU,eACpD,CAACC,MAAMC,MAAMC,OAAAA;AACX,UAAMC,UAAUd,QAAQU,aAAcK,KAAK,CAACC,SAAAA;AAC1C,UAAIA,KAAKC,SAAS,IAAA,GAAO;AACvB,eAAOL,KAAKM,SAASC,WAAWH,KAAKI,QAAQ,MAAM,GAAA,CAAA;MACrD;AACA,aAAOR,KAAKM,aAAaF;IAC3B,CAAA;AACA,QAAIF,SAAS;AACXD,SAAG,MAAM,IAAA;IACX,OAAO;AACLA,SAAG,IAAIQ,MAAM,aAAaT,KAAKM,QAAQ,iBAAiB,CAAA;IAC1D;EACF,IACAI;AAEJ,QAAMC,gBAA+B;IACnCjB;IACA,GAAIG,aAAa;MAAEA;IAAW,IAAI,CAAC;IACnC,GAAIT,QAAQwB,UAAU;MAAEA,SAASxB,QAAQwB;IAAQ,IAAI,CAAC;IACtD,GAAIxB,QAAQyB,OAAO;MAAEA,MAAMzB,QAAQyB;IAAK,IAAI,CAAC;EAC/C;AAEA,SAAOC,OAAOH,aAAAA;AAChB;AA7BSrB;AA6CF,SAASyB,eAAAA;AACd,SAAO,CAACC,KAAcC,KAAeC,SAAAA;AACnCD,QAAIE,GAAG,UAAU,YAAA;AACf,YAAMC,QAAe,CAAA;AAErB,UAAKJ,IAAYhB,MAAMqB,MAAM;AAC3BD,cAAME,KAAMN,IAAYhB,IAAI;MAC9B;AACA,UAAIuB,MAAMC,QAASR,IAAYI,KAAK,GAAG;AACrC,mBAAWK,KAAMT,IAAYI,OAAO;AAClC,cAAIK,GAAGJ,KAAMD,OAAME,KAAKG,CAAAA;QAC1B;MACF;AAEA,iBAAWzB,QAAQoB,OAAO;AACxB,YAAI;AACF,gBAAMM,OAAO1B,KAAKqB,IAAI;QACxB,QAAQ;QAER;MACF;IACF,CAAA;AAEAH,SAAAA;EACF;AACF;AAzBgBH;AA4BT,IAAMY,SAAS;EAAEzC;EAAQK;EAAOE;AAAK;","names":["unlink","multer","single","fieldName","options","m","createMulter","array","maxCount","none","limits","fileSize","maxSize","fileFilter","allowedTypes","_req","file","cb","allowed","some","type","endsWith","mimetype","startsWith","replace","Error","undefined","multerOptions","storage","dest","multer","cleanupFiles","req","res","next","on","files","path","push","Array","isArray","f","unlink","upload"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/middleware/error-handler.ts
|
|
6
|
+
import { HttpException, createLogger } from "@forinda/kickjs-core";
|
|
7
|
+
var log = createLogger("ErrorHandler");
|
|
8
|
+
function notFoundHandler() {
|
|
9
|
+
return (_req, res, _next) => {
|
|
10
|
+
res.status(404).json({
|
|
11
|
+
message: "Not Found"
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
__name(notFoundHandler, "notFoundHandler");
|
|
16
|
+
function errorHandler() {
|
|
17
|
+
return (err, req, res, _next) => {
|
|
18
|
+
if (res.headersSent) {
|
|
19
|
+
log.warn(`Error after headers sent: ${err?.message || "Unknown"}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (err?.name === "ZodError") {
|
|
23
|
+
const firstIssue = err.issues?.[0];
|
|
24
|
+
return res.status(422).json({
|
|
25
|
+
message: firstIssue?.message || "Validation failed",
|
|
26
|
+
errors: err.issues
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (err instanceof HttpException) {
|
|
30
|
+
if (err.status >= 500) {
|
|
31
|
+
log.error(err, err.message);
|
|
32
|
+
}
|
|
33
|
+
return res.status(err.status).json({
|
|
34
|
+
message: err.message,
|
|
35
|
+
...err.details ? {
|
|
36
|
+
errors: err.details
|
|
37
|
+
} : {}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const status = err.status || err.statusCode || 500;
|
|
41
|
+
log.error(err, `${req.method} ${req.originalUrl} \u2014 ${err.message || "Unhandled error"}`);
|
|
42
|
+
const message = status === 500 ? "Internal Server Error" : err.message || "Error";
|
|
43
|
+
res.status(status).json({
|
|
44
|
+
message
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
__name(errorHandler, "errorHandler");
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
notFoundHandler,
|
|
52
|
+
errorHandler
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=chunk-BNWCVQQH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware/error-handler.ts"],"sourcesContent":["import type { Request, Response, NextFunction } from 'express'\nimport { HttpException, createLogger } from '@forinda/kickjs-core'\n\nconst log = createLogger('ErrorHandler')\n\n/** Catch-all for unmatched routes */\nexport function notFoundHandler() {\n return (_req: Request, res: Response, _next: NextFunction) => {\n res.status(404).json({ message: 'Not Found' })\n }\n}\n\n/** Global error handler */\nexport function errorHandler() {\n return (err: any, req: Request, res: Response, _next: NextFunction) => {\n // Don't write after headers are already sent\n if (res.headersSent) {\n log.warn(`Error after headers sent: ${err?.message || 'Unknown'}`)\n return\n }\n\n // Zod validation errors\n if (err?.name === 'ZodError') {\n const firstIssue = err.issues?.[0]\n return res.status(422).json({\n message: firstIssue?.message || 'Validation failed',\n errors: err.issues,\n })\n }\n\n // HttpException (expected application errors)\n if (err instanceof HttpException) {\n if (err.status >= 500) {\n log.error(err, err.message)\n }\n return res.status(err.status).json({\n message: err.message,\n ...(err.details ? { errors: err.details } : {}),\n })\n }\n\n // Unexpected errors — always log\n const status = err.status || err.statusCode || 500\n log.error(err, `${req.method} ${req.originalUrl} — ${err.message || 'Unhandled error'}`)\n const message = status === 500 ? 'Internal Server Error' : err.message || 'Error'\n res.status(status).json({ message })\n }\n}\n"],"mappings":";;;;;AACA,SAASA,eAAeC,oBAAoB;AAE5C,IAAMC,MAAMC,aAAa,cAAA;AAGlB,SAASC,kBAAAA;AACd,SAAO,CAACC,MAAeC,KAAeC,UAAAA;AACpCD,QAAIE,OAAO,GAAA,EAAKC,KAAK;MAAEC,SAAS;IAAY,CAAA;EAC9C;AACF;AAJgBN;AAOT,SAASO,eAAAA;AACd,SAAO,CAACC,KAAUC,KAAcP,KAAeC,UAAAA;AAE7C,QAAID,IAAIQ,aAAa;AACnBZ,UAAIa,KAAK,6BAA6BH,KAAKF,WAAW,SAAA,EAAW;AACjE;IACF;AAGA,QAAIE,KAAKI,SAAS,YAAY;AAC5B,YAAMC,aAAaL,IAAIM,SAAS,CAAA;AAChC,aAAOZ,IAAIE,OAAO,GAAA,EAAKC,KAAK;QAC1BC,SAASO,YAAYP,WAAW;QAChCS,QAAQP,IAAIM;MACd,CAAA;IACF;AAGA,QAAIN,eAAeQ,eAAe;AAChC,UAAIR,IAAIJ,UAAU,KAAK;AACrBN,YAAImB,MAAMT,KAAKA,IAAIF,OAAO;MAC5B;AACA,aAAOJ,IAAIE,OAAOI,IAAIJ,MAAM,EAAEC,KAAK;QACjCC,SAASE,IAAIF;QACb,GAAIE,IAAIU,UAAU;UAAEH,QAAQP,IAAIU;QAAQ,IAAI,CAAC;MAC/C,CAAA;IACF;AAGA,UAAMd,SAASI,IAAIJ,UAAUI,IAAIW,cAAc;AAC/CrB,QAAImB,MAAMT,KAAK,GAAGC,IAAIW,MAAM,IAAIX,IAAIY,WAAW,WAAMb,IAAIF,WAAW,iBAAA,EAAmB;AACvF,UAAMA,UAAUF,WAAW,MAAM,0BAA0BI,IAAIF,WAAW;AAC1EJ,QAAIE,OAAOA,MAAAA,EAAQC,KAAK;MAAEC;IAAQ,CAAA;EACpC;AACF;AAlCgBC;","names":["HttpException","createLogger","log","createLogger","notFoundHandler","_req","res","_next","status","json","message","errorHandler","err","req","headersSent","warn","name","firstIssue","issues","errors","HttpException","error","details","statusCode","method","originalUrl"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/middleware/csrf.ts
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
7
|
+
function csrf(options = {}) {
|
|
8
|
+
const cookieName = options.cookie ?? "_csrf";
|
|
9
|
+
const headerName = options.header ?? "x-csrf-token";
|
|
10
|
+
const protectedMethods = new Set((options.methods ?? [
|
|
11
|
+
"POST",
|
|
12
|
+
"PUT",
|
|
13
|
+
"PATCH",
|
|
14
|
+
"DELETE"
|
|
15
|
+
]).map((m) => m.toUpperCase()));
|
|
16
|
+
const ignorePaths = new Set(options.ignorePaths ?? []);
|
|
17
|
+
const tokenLength = options.tokenLength ?? 32;
|
|
18
|
+
const cookieOpts = {
|
|
19
|
+
httpOnly: true,
|
|
20
|
+
sameSite: "strict",
|
|
21
|
+
secure: process.env.NODE_ENV === "production",
|
|
22
|
+
path: "/",
|
|
23
|
+
...options.cookieOptions
|
|
24
|
+
};
|
|
25
|
+
return (req, res, next) => {
|
|
26
|
+
const cookies = req.cookies || {};
|
|
27
|
+
let token = cookies[cookieName];
|
|
28
|
+
if (!token) {
|
|
29
|
+
token = randomBytes(tokenLength).toString("hex");
|
|
30
|
+
res.cookie(cookieName, token, cookieOpts);
|
|
31
|
+
}
|
|
32
|
+
if (!protectedMethods.has(req.method.toUpperCase())) {
|
|
33
|
+
return next();
|
|
34
|
+
}
|
|
35
|
+
if (ignorePaths.has(req.path)) {
|
|
36
|
+
return next();
|
|
37
|
+
}
|
|
38
|
+
const headerToken = req.headers[headerName];
|
|
39
|
+
if (!headerToken || headerToken !== token) {
|
|
40
|
+
return res.status(403).json({
|
|
41
|
+
message: "CSRF token mismatch"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
next();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
__name(csrf, "csrf");
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
csrf
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=chunk-I6UNTOQD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware/csrf.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto'\nimport type { Request, Response, NextFunction } from 'express'\n\nexport interface CsrfOptions {\n /** Cookie name for the CSRF token (default: '_csrf') */\n cookie?: string\n /** Header name to check for the token (default: 'x-csrf-token') */\n header?: string\n /** HTTP methods that require CSRF validation (default: POST, PUT, PATCH, DELETE) */\n methods?: string[]\n /** Paths to exclude from CSRF checks (e.g. webhooks) */\n ignorePaths?: string[]\n /** Token byte length before hex encoding (default: 32 = 64 hex chars) */\n tokenLength?: number\n /** Cookie options */\n cookieOptions?: {\n httpOnly?: boolean\n sameSite?: 'strict' | 'lax' | 'none'\n secure?: boolean\n path?: string\n }\n}\n\n/**\n * Double-submit cookie CSRF protection middleware.\n *\n * On every request, sets a CSRF token cookie. For state-changing methods\n * (POST, PUT, PATCH, DELETE), validates that the request header matches\n * the cookie value.\n *\n * @example\n * ```ts\n * import { csrf } from '@forinda/kickjs-http'\n *\n * bootstrap({\n * modules,\n * middleware: [\n * cookieParser(),\n * csrf(),\n * // ... other middleware\n * ],\n * })\n * ```\n *\n * Client usage:\n * 1. Read the `_csrf` cookie value\n * 2. Send it in the `x-csrf-token` header on every mutating request\n */\nexport function csrf(options: CsrfOptions = {}) {\n const cookieName = options.cookie ?? '_csrf'\n const headerName = options.header ?? 'x-csrf-token'\n const protectedMethods = new Set(\n (options.methods ?? ['POST', 'PUT', 'PATCH', 'DELETE']).map((m) => m.toUpperCase()),\n )\n const ignorePaths = new Set(options.ignorePaths ?? [])\n const tokenLength = options.tokenLength ?? 32\n const cookieOpts = {\n httpOnly: true,\n sameSite: 'strict' as const,\n secure: process.env.NODE_ENV === 'production',\n path: '/',\n ...options.cookieOptions,\n }\n\n return (req: Request, res: Response, next: NextFunction) => {\n // Generate or reuse CSRF token\n const cookies = (req as any).cookies || {}\n let token = cookies[cookieName]\n\n if (!token) {\n token = randomBytes(tokenLength).toString('hex')\n res.cookie(cookieName, token, cookieOpts)\n }\n\n // Skip validation for safe methods and ignored paths\n if (!protectedMethods.has(req.method.toUpperCase())) {\n return next()\n }\n\n if (ignorePaths.has(req.path)) {\n return next()\n }\n\n // Validate: header token must match cookie token\n const headerToken = req.headers[headerName] as string | undefined\n\n if (!headerToken || headerToken !== token) {\n return res.status(403).json({\n message: 'CSRF token mismatch',\n })\n }\n\n next()\n }\n}\n"],"mappings":";;;;;AAAA,SAASA,mBAAmB;AAgDrB,SAASC,KAAKC,UAAuB,CAAC,GAAC;AAC5C,QAAMC,aAAaD,QAAQE,UAAU;AACrC,QAAMC,aAAaH,QAAQI,UAAU;AACrC,QAAMC,mBAAmB,IAAIC,KAC1BN,QAAQO,WAAW;IAAC;IAAQ;IAAO;IAAS;KAAWC,IAAI,CAACC,MAAMA,EAAEC,YAAW,CAAA,CAAA;AAElF,QAAMC,cAAc,IAAIL,IAAIN,QAAQW,eAAe,CAAA,CAAE;AACrD,QAAMC,cAAcZ,QAAQY,eAAe;AAC3C,QAAMC,aAAa;IACjBC,UAAU;IACVC,UAAU;IACVC,QAAQC,QAAQC,IAAIC,aAAa;IACjCC,MAAM;IACN,GAAGpB,QAAQqB;EACb;AAEA,SAAO,CAACC,KAAcC,KAAeC,SAAAA;AAEnC,UAAMC,UAAWH,IAAYG,WAAW,CAAC;AACzC,QAAIC,QAAQD,QAAQxB,UAAAA;AAEpB,QAAI,CAACyB,OAAO;AACVA,cAAQC,YAAYf,WAAAA,EAAagB,SAAS,KAAA;AAC1CL,UAAIrB,OAAOD,YAAYyB,OAAOb,UAAAA;IAChC;AAGA,QAAI,CAACR,iBAAiBwB,IAAIP,IAAIQ,OAAOpB,YAAW,CAAA,GAAK;AACnD,aAAOc,KAAAA;IACT;AAEA,QAAIb,YAAYkB,IAAIP,IAAIF,IAAI,GAAG;AAC7B,aAAOI,KAAAA;IACT;AAGA,UAAMO,cAAcT,IAAIU,QAAQ7B,UAAAA;AAEhC,QAAI,CAAC4B,eAAeA,gBAAgBL,OAAO;AACzC,aAAOH,IAAIU,OAAO,GAAA,EAAKC,KAAK;QAC1BC,SAAS;MACX,CAAA;IACF;AAEAX,SAAAA;EACF;AACF;AA9CgBzB;","names":["randomBytes","csrf","options","cookieName","cookie","headerName","header","protectedMethods","Set","methods","map","m","toUpperCase","ignorePaths","tokenLength","cookieOpts","httpOnly","sameSite","secure","process","env","NODE_ENV","path","cookieOptions","req","res","next","cookies","token","randomBytes","toString","has","method","headerToken","headers","status","json","message"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/middleware/validate.ts
|
|
6
|
+
function validate(schema) {
|
|
7
|
+
return (req, res, next) => {
|
|
8
|
+
try {
|
|
9
|
+
if (schema.body) {
|
|
10
|
+
const result = schema.body.safeParse(req.body);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
return res.status(422).json({
|
|
13
|
+
message: result.error.issues[0]?.message || "Validation failed",
|
|
14
|
+
errors: result.error.issues.map((i) => ({
|
|
15
|
+
field: i.path.join("."),
|
|
16
|
+
message: i.message
|
|
17
|
+
}))
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
req.body = result.data;
|
|
21
|
+
}
|
|
22
|
+
if (schema.query) {
|
|
23
|
+
const result = schema.query.safeParse(req.query);
|
|
24
|
+
if (!result.success) {
|
|
25
|
+
return res.status(422).json({
|
|
26
|
+
message: "Invalid query parameters",
|
|
27
|
+
errors: result.error.issues.map((i) => ({
|
|
28
|
+
field: i.path.join("."),
|
|
29
|
+
message: i.message
|
|
30
|
+
}))
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
;
|
|
34
|
+
req.query = result.data;
|
|
35
|
+
}
|
|
36
|
+
if (schema.params) {
|
|
37
|
+
const result = schema.params.safeParse(req.params);
|
|
38
|
+
if (!result.success) {
|
|
39
|
+
return res.status(422).json({
|
|
40
|
+
message: "Invalid path parameters",
|
|
41
|
+
errors: result.error.issues.map((i) => ({
|
|
42
|
+
field: i.path.join("."),
|
|
43
|
+
message: i.message
|
|
44
|
+
}))
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
;
|
|
48
|
+
req.params = result.data;
|
|
49
|
+
}
|
|
50
|
+
next();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
next(err);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
__name(validate, "validate");
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
validate
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=chunk-JD2RKDKH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware/validate.ts"],"sourcesContent":["import type { Request, Response, NextFunction } from 'express'\n\nexport interface ValidationSchema {\n body?: any\n query?: any\n params?: any\n}\n\n/**\n * Express middleware that validates request body/query/params against schemas.\n * Works with any validation library that exposes `.safeParse(data)` returning\n * `{ success: true, data }` or `{ success: false, error: { issues } }`.\n */\nexport function validate(schema: ValidationSchema) {\n return (req: Request, res: Response, next: NextFunction) => {\n try {\n if (schema.body) {\n const result = schema.body.safeParse(req.body)\n if (!result.success) {\n return res.status(422).json({\n message: result.error.issues[0]?.message || 'Validation failed',\n errors: result.error.issues.map((i: any) => ({\n field: i.path.join('.'),\n message: i.message,\n })),\n })\n }\n req.body = result.data\n }\n\n if (schema.query) {\n const result = schema.query.safeParse(req.query)\n if (!result.success) {\n return res.status(422).json({\n message: 'Invalid query parameters',\n errors: result.error.issues.map((i: any) => ({\n field: i.path.join('.'),\n message: i.message,\n })),\n })\n }\n ;(req as any).query = result.data\n }\n\n if (schema.params) {\n const result = schema.params.safeParse(req.params)\n if (!result.success) {\n return res.status(422).json({\n message: 'Invalid path parameters',\n errors: result.error.issues.map((i: any) => ({\n field: i.path.join('.'),\n message: i.message,\n })),\n })\n }\n ;(req as any).params = result.data\n }\n\n next()\n } catch (err) {\n next(err)\n }\n }\n}\n"],"mappings":";;;;;AAaO,SAASA,SAASC,QAAwB;AAC/C,SAAO,CAACC,KAAcC,KAAeC,SAAAA;AACnC,QAAI;AACF,UAAIH,OAAOI,MAAM;AACf,cAAMC,SAASL,OAAOI,KAAKE,UAAUL,IAAIG,IAAI;AAC7C,YAAI,CAACC,OAAOE,SAAS;AACnB,iBAAOL,IAAIM,OAAO,GAAA,EAAKC,KAAK;YAC1BC,SAASL,OAAOM,MAAMC,OAAO,CAAA,GAAIF,WAAW;YAC5CG,QAAQR,OAAOM,MAAMC,OAAOE,IAAI,CAACC,OAAY;cAC3CC,OAAOD,EAAEE,KAAKC,KAAK,GAAA;cACnBR,SAASK,EAAEL;YACb,EAAA;UACF,CAAA;QACF;AACAT,YAAIG,OAAOC,OAAOc;MACpB;AAEA,UAAInB,OAAOoB,OAAO;AAChB,cAAMf,SAASL,OAAOoB,MAAMd,UAAUL,IAAImB,KAAK;AAC/C,YAAI,CAACf,OAAOE,SAAS;AACnB,iBAAOL,IAAIM,OAAO,GAAA,EAAKC,KAAK;YAC1BC,SAAS;YACTG,QAAQR,OAAOM,MAAMC,OAAOE,IAAI,CAACC,OAAY;cAC3CC,OAAOD,EAAEE,KAAKC,KAAK,GAAA;cACnBR,SAASK,EAAEL;YACb,EAAA;UACF,CAAA;QACF;;AACET,YAAYmB,QAAQf,OAAOc;MAC/B;AAEA,UAAInB,OAAOqB,QAAQ;AACjB,cAAMhB,SAASL,OAAOqB,OAAOf,UAAUL,IAAIoB,MAAM;AACjD,YAAI,CAAChB,OAAOE,SAAS;AACnB,iBAAOL,IAAIM,OAAO,GAAA,EAAKC,KAAK;YAC1BC,SAAS;YACTG,QAAQR,OAAOM,MAAMC,OAAOE,IAAI,CAACC,OAAY;cAC3CC,OAAOD,EAAEE,KAAKC,KAAK,GAAA;cACnBR,SAASK,EAAEL;YACb,EAAA;UACF,CAAA;QACF;;AACET,YAAYoB,SAAShB,OAAOc;MAChC;AAEAhB,WAAAA;IACF,SAASmB,KAAK;AACZnB,WAAKmB,GAAAA;IACP;EACF;AACF;AAlDgBvB;","names":["validate","schema","req","res","next","body","result","safeParse","success","status","json","message","error","issues","errors","map","i","field","path","join","data","query","params","err"]}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/query/types.ts
|
|
6
|
+
var FILTER_OPERATORS = /* @__PURE__ */ new Set([
|
|
7
|
+
"eq",
|
|
8
|
+
"neq",
|
|
9
|
+
"gt",
|
|
10
|
+
"gte",
|
|
11
|
+
"lt",
|
|
12
|
+
"lte",
|
|
13
|
+
"between",
|
|
14
|
+
"in",
|
|
15
|
+
"contains",
|
|
16
|
+
"starts",
|
|
17
|
+
"ends"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// src/query/parse-query.ts
|
|
21
|
+
var MAX_SEARCH_LENGTH = 200;
|
|
22
|
+
var DEFAULT_PAGE = 1;
|
|
23
|
+
var DEFAULT_LIMIT = 20;
|
|
24
|
+
var MAX_LIMIT = 100;
|
|
25
|
+
function parseFilters(filterParam, allowedFields) {
|
|
26
|
+
if (!filterParam) return [];
|
|
27
|
+
const items = Array.isArray(filterParam) ? filterParam : [
|
|
28
|
+
filterParam
|
|
29
|
+
];
|
|
30
|
+
const results = [];
|
|
31
|
+
const allowed = allowedFields ? new Set(allowedFields) : null;
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const firstColon = item.indexOf(":");
|
|
34
|
+
if (firstColon === -1) continue;
|
|
35
|
+
const secondColon = item.indexOf(":", firstColon + 1);
|
|
36
|
+
if (secondColon === -1) continue;
|
|
37
|
+
const field = item.slice(0, firstColon);
|
|
38
|
+
const operator = item.slice(firstColon + 1, secondColon);
|
|
39
|
+
const value = item.slice(secondColon + 1);
|
|
40
|
+
if (!field || !value) continue;
|
|
41
|
+
if (!FILTER_OPERATORS.has(operator)) continue;
|
|
42
|
+
if (allowed && !allowed.has(field)) continue;
|
|
43
|
+
results.push({
|
|
44
|
+
field,
|
|
45
|
+
operator,
|
|
46
|
+
value
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
__name(parseFilters, "parseFilters");
|
|
52
|
+
function parseSort(sortParam, allowedFields) {
|
|
53
|
+
if (!sortParam) return [];
|
|
54
|
+
const items = Array.isArray(sortParam) ? sortParam : [
|
|
55
|
+
sortParam
|
|
56
|
+
];
|
|
57
|
+
const results = [];
|
|
58
|
+
const allowed = allowedFields ? new Set(allowedFields) : null;
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
const lastColon = item.lastIndexOf(":");
|
|
61
|
+
if (lastColon === -1) continue;
|
|
62
|
+
const field = item.slice(0, lastColon);
|
|
63
|
+
const dir = item.slice(lastColon + 1).toLowerCase();
|
|
64
|
+
if (!field) continue;
|
|
65
|
+
if (dir !== "asc" && dir !== "desc") continue;
|
|
66
|
+
if (allowed && !allowed.has(field)) continue;
|
|
67
|
+
results.push({
|
|
68
|
+
field,
|
|
69
|
+
direction: dir
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
__name(parseSort, "parseSort");
|
|
75
|
+
function parsePagination(params) {
|
|
76
|
+
let page = typeof params.page === "string" ? parseInt(params.page, 10) : params.page ?? DEFAULT_PAGE;
|
|
77
|
+
let limit = typeof params.limit === "string" ? parseInt(params.limit, 10) : params.limit ?? DEFAULT_LIMIT;
|
|
78
|
+
if (isNaN(page) || page < 1) page = DEFAULT_PAGE;
|
|
79
|
+
if (isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT;
|
|
80
|
+
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
|
81
|
+
return {
|
|
82
|
+
page,
|
|
83
|
+
limit,
|
|
84
|
+
offset: (page - 1) * limit
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
__name(parsePagination, "parsePagination");
|
|
88
|
+
function parseSearchQuery(q) {
|
|
89
|
+
if (!q) return "";
|
|
90
|
+
return q.trim().slice(0, MAX_SEARCH_LENGTH);
|
|
91
|
+
}
|
|
92
|
+
__name(parseSearchQuery, "parseSearchQuery");
|
|
93
|
+
function parseQuery(query, fieldConfig) {
|
|
94
|
+
return {
|
|
95
|
+
filters: parseFilters(query.filter, fieldConfig?.filterable),
|
|
96
|
+
sort: parseSort(query.sort, fieldConfig?.sortable),
|
|
97
|
+
pagination: parsePagination({
|
|
98
|
+
page: query.page,
|
|
99
|
+
limit: query.limit
|
|
100
|
+
}),
|
|
101
|
+
search: parseSearchQuery(query.q)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
__name(parseQuery, "parseQuery");
|
|
105
|
+
function buildQueryParams(parsed) {
|
|
106
|
+
const params = {};
|
|
107
|
+
if (parsed.filters?.length) {
|
|
108
|
+
params.filter = parsed.filters.map((f) => `${f.field}:${f.operator}:${f.value}`);
|
|
109
|
+
}
|
|
110
|
+
if (parsed.sort?.length) {
|
|
111
|
+
params.sort = parsed.sort.map((s) => `${s.field}:${s.direction}`);
|
|
112
|
+
}
|
|
113
|
+
if (parsed.pagination) {
|
|
114
|
+
params.page = parsed.pagination.page;
|
|
115
|
+
params.limit = parsed.pagination.limit;
|
|
116
|
+
}
|
|
117
|
+
if (parsed.search) {
|
|
118
|
+
params.q = parsed.search;
|
|
119
|
+
}
|
|
120
|
+
return params;
|
|
121
|
+
}
|
|
122
|
+
__name(buildQueryParams, "buildQueryParams");
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
FILTER_OPERATORS,
|
|
126
|
+
parseFilters,
|
|
127
|
+
parseSort,
|
|
128
|
+
parsePagination,
|
|
129
|
+
parseSearchQuery,
|
|
130
|
+
parseQuery,
|
|
131
|
+
buildQueryParams
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=chunk-JM7X7SAD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/query/types.ts","../src/query/parse-query.ts"],"sourcesContent":["/** Supported filter operators for query string parsing */\nexport type FilterOperator =\n | 'eq'\n | 'neq'\n | 'gt'\n | 'gte'\n | 'lt'\n | 'lte'\n | 'between'\n | 'in'\n | 'contains'\n | 'starts'\n | 'ends'\n\nexport const FILTER_OPERATORS = new Set<string>([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'in',\n 'contains',\n 'starts',\n 'ends',\n])\n\nexport interface FilterItem {\n field: string\n operator: FilterOperator\n value: string\n}\n\nexport interface SortItem {\n field: string\n direction: 'asc' | 'desc'\n}\n\nexport interface PaginationParams {\n page: number\n limit: number\n offset: number\n}\n\n/**\n * The result of parsing a query string. ORM-agnostic — pass this to\n * a query builder adapter (Drizzle, Prisma, Sequelize, etc.) to produce\n * database-specific query objects.\n */\nexport interface ParsedQuery {\n filters: FilterItem[]\n sort: SortItem[]\n pagination: PaginationParams\n search: string\n}\n\n/**\n * Restrict which fields can be filtered, sorted, or searched.\n * Fields not in the allow-list are silently ignored.\n */\nexport interface QueryFieldConfig {\n filterable?: string[]\n sortable?: string[]\n searchable?: string[]\n}\n\n/**\n * Interface for ORM-specific query builder adapters.\n * Implement this to translate a `ParsedQuery` into your ORM's query format.\n *\n * @example\n * ```ts\n * // Drizzle adapter\n * class DrizzleQueryAdapter implements QueryBuilderAdapter<DrizzleQueryResult> {\n * build(parsed: ParsedQuery, config: DrizzleConfig): DrizzleQueryResult {\n * // Convert filters → Drizzle SQL conditions\n * // Convert sort → Drizzle orderBy\n * return { where, orderBy, limit, offset }\n * }\n * }\n *\n * // Prisma adapter\n * class PrismaQueryAdapter implements QueryBuilderAdapter<PrismaQueryResult> {\n * build(parsed: ParsedQuery, config: PrismaConfig): PrismaQueryResult {\n * return { where, orderBy, skip, take }\n * }\n * }\n * ```\n */\nexport interface QueryBuilderAdapter<TResult = any, TConfig = any> {\n /** Human-readable name for debugging */\n readonly name: string\n\n /**\n * Convert a ParsedQuery into an ORM-specific query object.\n * @param parsed - The ORM-agnostic parsed query\n * @param config - ORM-specific configuration (column maps, search columns, etc.)\n */\n build(parsed: ParsedQuery, config: TConfig): TResult\n}\n","import {\n FILTER_OPERATORS,\n type FilterItem,\n type FilterOperator,\n type SortItem,\n type PaginationParams,\n type ParsedQuery,\n type QueryFieldConfig,\n} from './types'\n\nconst MAX_SEARCH_LENGTH = 200\nconst DEFAULT_PAGE = 1\nconst DEFAULT_LIMIT = 20\nconst MAX_LIMIT = 100\n\n// ── Individual parsers ──────────────────────────────────────────────────\n\n/** Parse filter strings like \"status:eq:active\" into structured objects */\nexport function parseFilters(\n filterParam: string | string[] | undefined,\n allowedFields?: string[],\n): FilterItem[] {\n if (!filterParam) return []\n\n const items = Array.isArray(filterParam) ? filterParam : [filterParam]\n const results: FilterItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n\n for (const item of items) {\n // Split on first two colons only — value may contain colons (e.g. timestamps)\n const firstColon = item.indexOf(':')\n if (firstColon === -1) continue\n\n const secondColon = item.indexOf(':', firstColon + 1)\n if (secondColon === -1) continue\n\n const field = item.slice(0, firstColon)\n const operator = item.slice(firstColon + 1, secondColon)\n const value = item.slice(secondColon + 1)\n\n if (!field || !value) continue\n if (!FILTER_OPERATORS.has(operator)) continue\n if (allowed && !allowed.has(field)) continue\n\n results.push({ field, operator: operator as FilterOperator, value })\n }\n\n return results\n}\n\n/** Parse sort strings like \"firstName:asc\" into structured objects */\nexport function parseSort(\n sortParam: string | string[] | undefined,\n allowedFields?: string[],\n): SortItem[] {\n if (!sortParam) return []\n\n const items = Array.isArray(sortParam) ? sortParam : [sortParam]\n const results: SortItem[] = []\n const allowed = allowedFields ? new Set(allowedFields) : null\n\n for (const item of items) {\n // Split on last colon so field names with colons work\n const lastColon = item.lastIndexOf(':')\n if (lastColon === -1) continue\n\n const field = item.slice(0, lastColon)\n const dir = item.slice(lastColon + 1).toLowerCase()\n\n if (!field) continue\n if (dir !== 'asc' && dir !== 'desc') continue\n if (allowed && !allowed.has(field)) continue\n\n results.push({ field, direction: dir })\n }\n\n return results\n}\n\n/** Parse page/limit into pagination with computed offset */\nexport function parsePagination(params: {\n page?: string | number\n limit?: string | number\n}): PaginationParams {\n let page =\n typeof params.page === 'string' ? parseInt(params.page, 10) : (params.page ?? DEFAULT_PAGE)\n let limit =\n typeof params.limit === 'string' ? parseInt(params.limit, 10) : (params.limit ?? DEFAULT_LIMIT)\n\n if (isNaN(page) || page < 1) page = DEFAULT_PAGE\n if (isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT\n if (limit > MAX_LIMIT) limit = MAX_LIMIT\n\n return { page, limit, offset: (page - 1) * limit }\n}\n\n/** Sanitize and truncate search query */\nexport function parseSearchQuery(q: string | undefined): string {\n if (!q) return ''\n return q.trim().slice(0, MAX_SEARCH_LENGTH)\n}\n\n// ── Combined parser ─────────────────────────────────────────────────────\n\n/**\n * Parse a raw Express query object into a structured, ORM-agnostic ParsedQuery.\n *\n * @param query - Raw query string object from `req.query` or Zod-validated object\n * @param fieldConfig - Optional field restrictions (whitelist filterable/sortable/searchable)\n *\n * @example\n * ```ts\n * // In a controller\n * @Get('/')\n * async list(ctx: RequestContext) {\n * const parsed = parseQuery(ctx.query, {\n * filterable: ['status', 'priority'],\n * sortable: ['createdAt', 'title'],\n * })\n * // Pass to your ORM query builder adapter\n * const q = drizzleAdapter.build(parsed, { columns, searchColumns })\n * }\n * ```\n *\n * Query string format:\n * - Filters: `?filter=field:operator:value` (repeatable)\n * - Sort: `?sort=field:asc|desc` (repeatable)\n * - Pagination: `?page=1&limit=20`\n * - Search: `?q=search+term`\n *\n * Filter operators: eq, neq, gt, gte, lt, lte, between, in, contains, starts, ends\n */\nexport function parseQuery(\n query: Record<string, any>,\n fieldConfig?: QueryFieldConfig,\n): ParsedQuery {\n return {\n filters: parseFilters(query.filter, fieldConfig?.filterable),\n sort: parseSort(query.sort, fieldConfig?.sortable),\n pagination: parsePagination({ page: query.page, limit: query.limit }),\n search: parseSearchQuery(query.q),\n }\n}\n\n// ── Query URL builder (for client-side or testing) ──────────────────────\n\n/** Convert ParsedQuery back into query string parameters */\nexport function buildQueryParams(\n parsed: Partial<ParsedQuery>,\n): Record<string, string | string[] | number> {\n const params: Record<string, string | string[] | number> = {}\n\n if (parsed.filters?.length) {\n params.filter = parsed.filters.map((f) => `${f.field}:${f.operator}:${f.value}`)\n }\n\n if (parsed.sort?.length) {\n params.sort = parsed.sort.map((s) => `${s.field}:${s.direction}`)\n }\n\n if (parsed.pagination) {\n params.page = parsed.pagination.page\n params.limit = parsed.pagination.limit\n }\n\n if (parsed.search) {\n params.q = parsed.search\n }\n\n return params\n}\n"],"mappings":"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","names":["FILTER_OPERATORS","Set","MAX_SEARCH_LENGTH","DEFAULT_PAGE","DEFAULT_LIMIT","MAX_LIMIT","parseFilters","filterParam","allowedFields","items","Array","isArray","results","allowed","Set","item","firstColon","indexOf","secondColon","field","slice","operator","value","FILTER_OPERATORS","has","push","parseSort","sortParam","lastColon","lastIndexOf","dir","toLowerCase","direction","parsePagination","params","page","parseInt","limit","isNaN","offset","parseSearchQuery","q","trim","parseQuery","query","fieldConfig","filters","filter","filterable","sort","sortable","pagination","search","buildQueryParams","parsed","length","map","f","s"]}
|