@fishka/express 0.9.5
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 +137 -0
- package/dist/cjs/api.types.js +38 -0
- package/dist/cjs/auth/auth-strategy.js +62 -0
- package/dist/cjs/auth/auth.types.js +2 -0
- package/dist/cjs/auth/auth.utils.js +64 -0
- package/dist/cjs/auth/bearer-auth-strategy.js +59 -0
- package/dist/cjs/config.js +29 -0
- package/dist/cjs/error-handling.js +70 -0
- package/dist/cjs/http.types.js +38 -0
- package/dist/cjs/index.js +31 -0
- package/dist/cjs/rate-limit/in-memory-rate-limiter.js +88 -0
- package/dist/cjs/rate-limit/rate-limit.js +34 -0
- package/dist/cjs/rate-limit/rate-limit.types.js +2 -0
- package/dist/cjs/route-table.js +45 -0
- package/dist/cjs/router.js +245 -0
- package/dist/cjs/thread-local/thread-local-storage-middleware.js +27 -0
- package/dist/cjs/thread-local/thread-local-storage.js +31 -0
- package/dist/cjs/utils/conversion.utils.js +26 -0
- package/dist/cjs/utils/express.utils.js +2 -0
- package/dist/esm/api.types.js +31 -0
- package/dist/esm/auth/auth-strategy.js +58 -0
- package/dist/esm/auth/auth.types.js +1 -0
- package/dist/esm/auth/auth.utils.js +59 -0
- package/dist/esm/auth/bearer-auth-strategy.js +55 -0
- package/dist/esm/config.js +24 -0
- package/dist/esm/error-handling.js +66 -0
- package/dist/esm/http.types.js +35 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/rate-limit/in-memory-rate-limiter.js +88 -0
- package/dist/esm/rate-limit/rate-limit.js +30 -0
- package/dist/esm/rate-limit/rate-limit.types.js +1 -0
- package/dist/esm/route-table.js +40 -0
- package/dist/esm/router.js +203 -0
- package/dist/esm/thread-local/thread-local-storage-middleware.js +24 -0
- package/dist/esm/thread-local/thread-local-storage.js +27 -0
- package/dist/esm/utils/conversion.utils.js +22 -0
- package/dist/esm/utils/express.utils.js +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RouteTable = void 0;
|
|
4
|
+
exports.createRouteTable = createRouteTable;
|
|
5
|
+
const router_1 = require("./router");
|
|
6
|
+
/**
|
|
7
|
+
* Helper utility for organizing and mounting routes.
|
|
8
|
+
* Provides a fluent interface for registering multiple handlers.
|
|
9
|
+
*/
|
|
10
|
+
class RouteTable {
|
|
11
|
+
constructor(app) {
|
|
12
|
+
this.app = app;
|
|
13
|
+
}
|
|
14
|
+
get(path, endpointOrRun) {
|
|
15
|
+
const endpoint = typeof endpointOrRun === 'function' ? { run: endpointOrRun } : endpointOrRun;
|
|
16
|
+
(0, router_1.mountGet)(this.app, path, endpoint);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
post(path, endpoint) {
|
|
20
|
+
(0, router_1.mountPost)(this.app, path, endpoint);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
patch(path, endpoint) {
|
|
24
|
+
(0, router_1.mountPatch)(this.app, path, endpoint);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
put(path, endpoint) {
|
|
28
|
+
(0, router_1.mountPut)(this.app, path, endpoint);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
delete(path, endpointOrRun) {
|
|
32
|
+
const endpoint = typeof endpointOrRun === 'function' ? { run: endpointOrRun } : endpointOrRun;
|
|
33
|
+
(0, router_1.mountDelete)(this.app, path, endpoint);
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.RouteTable = RouteTable;
|
|
38
|
+
/**
|
|
39
|
+
* Factory function to create a new route table.
|
|
40
|
+
* @param app Express application instance
|
|
41
|
+
* @returns RouteTable instance with fluent API
|
|
42
|
+
*/
|
|
43
|
+
function createRouteTable(app) {
|
|
44
|
+
return new RouteTable(app);
|
|
45
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.mountDelete = exports.mountPut = exports.mountPatch = exports.mountPost = exports.mountGet = void 0;
|
|
37
|
+
exports.mount = mount;
|
|
38
|
+
const assertions_1 = require("@fishka/assertions");
|
|
39
|
+
const url = __importStar(require("url"));
|
|
40
|
+
const api_types_1 = require("./api.types");
|
|
41
|
+
const http_types_1 = require("./http.types");
|
|
42
|
+
const error_handling_1 = require("./error-handling");
|
|
43
|
+
const thread_local_storage_1 = require("./thread-local/thread-local-storage");
|
|
44
|
+
const conversion_utils_1 = require("./utils/conversion.utils");
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Internal implementation details
|
|
47
|
+
// ============================================================================
|
|
48
|
+
/**
|
|
49
|
+
* Registers a GET route.
|
|
50
|
+
*/
|
|
51
|
+
const mountGet = (app, path, endpoint) => mount(app, { method: 'get', route: endpoint, path });
|
|
52
|
+
exports.mountGet = mountGet;
|
|
53
|
+
/**
|
|
54
|
+
* Registers a POST route.
|
|
55
|
+
*/
|
|
56
|
+
const mountPost = (app, path, endpoint) => mount(app, { method: 'post', route: endpoint, path });
|
|
57
|
+
exports.mountPost = mountPost;
|
|
58
|
+
/**
|
|
59
|
+
* Registers a PATCH route.
|
|
60
|
+
*/
|
|
61
|
+
const mountPatch = (app, path, endpoint) => mount(app, { method: 'patch', route: endpoint, path });
|
|
62
|
+
exports.mountPatch = mountPatch;
|
|
63
|
+
/**
|
|
64
|
+
* Registers a PUT route.
|
|
65
|
+
*/
|
|
66
|
+
const mountPut = (app, path, endpoint) => mount(app, { method: 'put', route: endpoint, path });
|
|
67
|
+
exports.mountPut = mountPut;
|
|
68
|
+
/**
|
|
69
|
+
* Registers a DELETE route.
|
|
70
|
+
*/
|
|
71
|
+
const mountDelete = (app, path, endpoint) => mount(app, { method: 'delete', route: endpoint, path });
|
|
72
|
+
exports.mountDelete = mountDelete;
|
|
73
|
+
/**
|
|
74
|
+
* Mounts a route with the given method, endpoint, and path.
|
|
75
|
+
*/
|
|
76
|
+
function mount(app, { method, route, path }) {
|
|
77
|
+
const fullPath = `/${path}`;
|
|
78
|
+
const handler = createRouteHandler(method, route);
|
|
79
|
+
app[method](fullPath, (0, error_handling_1.catchRouteErrors)(handler));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* @Internal
|
|
83
|
+
* Creates a route handler from an endpoint definition.
|
|
84
|
+
*/
|
|
85
|
+
function createRouteHandler(method, endpoint) {
|
|
86
|
+
return async (req, res, _next) => {
|
|
87
|
+
let result;
|
|
88
|
+
switch (method) {
|
|
89
|
+
case 'post':
|
|
90
|
+
case 'put':
|
|
91
|
+
case 'patch':
|
|
92
|
+
result = await executeBodyEndpoint(endpoint, req, res);
|
|
93
|
+
break;
|
|
94
|
+
case 'delete':
|
|
95
|
+
result = await executeDeleteEndpoint(endpoint, req, res);
|
|
96
|
+
break;
|
|
97
|
+
case 'get':
|
|
98
|
+
result = await executeGetEndpoint(endpoint, req, res);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
const response = (0, conversion_utils_1.wrapAsApiResponse)(result);
|
|
102
|
+
const tls = (0, thread_local_storage_1.getRequestLocalStorage)();
|
|
103
|
+
if (tls?.requestId) {
|
|
104
|
+
response.requestId = tls.requestId;
|
|
105
|
+
}
|
|
106
|
+
response.status = response.status || http_types_1.OK_STATUS;
|
|
107
|
+
res.status(response.status);
|
|
108
|
+
res.send(response);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* @Internal
|
|
113
|
+
* Validates request parameters using custom validators.
|
|
114
|
+
*/
|
|
115
|
+
function validateUrlParameters(req, { $path, $query, }) {
|
|
116
|
+
try {
|
|
117
|
+
for (const key in req.params) {
|
|
118
|
+
const value = req.params[key];
|
|
119
|
+
// Run Global Validation if registered.
|
|
120
|
+
const globalValidator = api_types_1.URL_PARAMETER_INFO[key]?.validator;
|
|
121
|
+
if (globalValidator) {
|
|
122
|
+
(0, assertions_1.callValueAssertion)(value, globalValidator, `${http_types_1.BAD_REQUEST_STATUS}`);
|
|
123
|
+
}
|
|
124
|
+
// Run Local Validation.
|
|
125
|
+
const validator = $path?.[key];
|
|
126
|
+
if (validator) {
|
|
127
|
+
(0, assertions_1.callValueAssertion)(value, validator, `${http_types_1.BAD_REQUEST_STATUS}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const parsedUrl = url.parse(req.url, true);
|
|
131
|
+
for (const key in parsedUrl.query) {
|
|
132
|
+
const value = parsedUrl.query[key];
|
|
133
|
+
// Global Validation if registered (also applies to query params if names match).
|
|
134
|
+
const globalValidator = api_types_1.URL_PARAMETER_INFO[key]?.validator;
|
|
135
|
+
if (globalValidator) {
|
|
136
|
+
// Query params can be string | string[] | undefined. Global validators usually expect string.
|
|
137
|
+
// We only validate if it's a single value or handle array in validator.
|
|
138
|
+
// For simplicity, we pass value as is (unknown) to assertion.
|
|
139
|
+
(0, assertions_1.callValueAssertion)(value, globalValidator, `${http_types_1.BAD_REQUEST_STATUS}`);
|
|
140
|
+
}
|
|
141
|
+
const validator = $query?.[key];
|
|
142
|
+
if (validator) {
|
|
143
|
+
(0, assertions_1.callValueAssertion)(value, validator, `${http_types_1.BAD_REQUEST_STATUS}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
throw new api_types_1.HttpError(http_types_1.BAD_REQUEST_STATUS, (0, assertions_1.getMessageFromError)(error));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* @Internal
|
|
153
|
+
* Runs GET handler with optional middleware.
|
|
154
|
+
*/
|
|
155
|
+
async function executeGetEndpoint(route, req, res) {
|
|
156
|
+
const requestContext = newRequestContext(undefined, req, res);
|
|
157
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
158
|
+
return await executeWithMiddleware(() => route.run(requestContext), route.middlewares || [], requestContext);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* @Internal
|
|
162
|
+
* Runs DELETE handler with optional middleware.
|
|
163
|
+
*/
|
|
164
|
+
async function executeDeleteEndpoint(route, req, res) {
|
|
165
|
+
const requestContext = newRequestContext(undefined, req, res);
|
|
166
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
167
|
+
await executeWithMiddleware(() => route.run(requestContext), route.middlewares || [], requestContext);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* @Internal
|
|
172
|
+
* Runs POST/PUT/PATCH handler with optional middleware.
|
|
173
|
+
*/
|
|
174
|
+
async function executeBodyEndpoint(route, req, res) {
|
|
175
|
+
const validator = route.$body;
|
|
176
|
+
const apiRequest = req.body;
|
|
177
|
+
try {
|
|
178
|
+
// Handle validation based on whether validator is an object or function
|
|
179
|
+
if (typeof validator === 'function') {
|
|
180
|
+
// It's a ValueAssertion (function)
|
|
181
|
+
(0, assertions_1.callValueAssertion)(apiRequest, validator, `${http_types_1.BAD_REQUEST_STATUS}: request body`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// It's an ObjectAssertion - use validateObject
|
|
185
|
+
// We strictly assume it is an object because of the type definition (function | object)
|
|
186
|
+
const objectValidator = validator;
|
|
187
|
+
const isEmptyValidator = Object.keys(objectValidator).length === 0;
|
|
188
|
+
const error = (0, assertions_1.validateObject)(apiRequest, objectValidator, `${http_types_1.BAD_REQUEST_STATUS}: request body`, {
|
|
189
|
+
failOnUnknownFields: !isEmptyValidator,
|
|
190
|
+
});
|
|
191
|
+
(0, assertions_1.assertTruthy)(!error, error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof api_types_1.HttpError)
|
|
196
|
+
throw error;
|
|
197
|
+
throw new api_types_1.HttpError(http_types_1.BAD_REQUEST_STATUS, (0, assertions_1.getMessageFromError)(error));
|
|
198
|
+
}
|
|
199
|
+
const requestContext = newRequestContext(apiRequest, req, res);
|
|
200
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
201
|
+
requestContext.body = req.body;
|
|
202
|
+
return await executeWithMiddleware(() => route.run(requestContext), (route.middlewares || []), requestContext);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* @Internal
|
|
206
|
+
* Executes handler with middleware chain.
|
|
207
|
+
*/
|
|
208
|
+
async function executeWithMiddleware(run, middlewares, context) {
|
|
209
|
+
const current = async (index) => {
|
|
210
|
+
if (index >= middlewares.length) {
|
|
211
|
+
const result = await run();
|
|
212
|
+
return (0, conversion_utils_1.wrapAsApiResponse)(result);
|
|
213
|
+
}
|
|
214
|
+
const middleware = middlewares[index];
|
|
215
|
+
return (await middleware(() => current(index + 1), context));
|
|
216
|
+
};
|
|
217
|
+
return await current(0);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* @Internal
|
|
221
|
+
* Creates a new RequestContext instance.
|
|
222
|
+
*/
|
|
223
|
+
function newRequestContext(requestBody, req, res) {
|
|
224
|
+
return {
|
|
225
|
+
body: requestBody,
|
|
226
|
+
req,
|
|
227
|
+
res,
|
|
228
|
+
params: {
|
|
229
|
+
get: (key) => {
|
|
230
|
+
const value = req.params[key];
|
|
231
|
+
(0, assertions_1.assertTruthy)(value, `Path parameter '${key}' not found`);
|
|
232
|
+
return value;
|
|
233
|
+
},
|
|
234
|
+
tryGet: (key) => req.params[key],
|
|
235
|
+
},
|
|
236
|
+
query: {
|
|
237
|
+
get: (key) => {
|
|
238
|
+
const parsedUrl = url.parse(req.url, true);
|
|
239
|
+
const value = parsedUrl.query[key];
|
|
240
|
+
return Array.isArray(value) ? value[0] : value;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
state: new Map(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTlsMiddleware = createTlsMiddleware;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const config_1 = require("../config");
|
|
6
|
+
const thread_local_storage_1 = require("./thread-local-storage");
|
|
7
|
+
/**
|
|
8
|
+
* Creates middleware that initializes thread-local storage for each request.
|
|
9
|
+
* Automatically generates a unique request ID and makes it available throughout
|
|
10
|
+
* the request lifecycle.
|
|
11
|
+
*
|
|
12
|
+
* @returns Express middleware function
|
|
13
|
+
*/
|
|
14
|
+
function createTlsMiddleware() {
|
|
15
|
+
return async (req, _res, next) => {
|
|
16
|
+
const config = (0, config_1.getExpressApiConfig)();
|
|
17
|
+
const headerId = config.trustRequestIdHeader ? req.headers['x-request-id'] : undefined;
|
|
18
|
+
const existingId = req.requestId || headerId;
|
|
19
|
+
const requestId = typeof existingId === 'string' ? existingId : (0, crypto_1.randomUUID)();
|
|
20
|
+
// Run the next handler within the TLS context
|
|
21
|
+
await (0, thread_local_storage_1.runWithRequestTlsData)({
|
|
22
|
+
requestId,
|
|
23
|
+
}, async () => {
|
|
24
|
+
next();
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getRequestLocalStorage = getRequestLocalStorage;
|
|
4
|
+
exports.runWithRequestTlsData = runWithRequestTlsData;
|
|
5
|
+
const async_hooks_1 = require("async_hooks");
|
|
6
|
+
/**
|
|
7
|
+
* AsyncLocalStorage instance for managing per-request context.
|
|
8
|
+
* This ensures that each async operation associated with a request
|
|
9
|
+
* can access the request-specific data even across async boundaries.
|
|
10
|
+
* @Internal
|
|
11
|
+
*/
|
|
12
|
+
const asyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
|
|
13
|
+
/**
|
|
14
|
+
* Gets all thread-local data for the current request context.
|
|
15
|
+
* Returns undefined if called outside an async context managed by API.
|
|
16
|
+
* @Internal
|
|
17
|
+
*/
|
|
18
|
+
function getRequestLocalStorage() {
|
|
19
|
+
return asyncLocalStorage.getStore();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Executes a callback within a request context with the given thread-local data.
|
|
23
|
+
* Used by middleware to set up the context for handlers.
|
|
24
|
+
* @Internal
|
|
25
|
+
* @param data - Thread-local data to establish
|
|
26
|
+
* @param callback - Function to execute within the context
|
|
27
|
+
* @returns Result of the callback
|
|
28
|
+
*/
|
|
29
|
+
async function runWithRequestTlsData(data, callback) {
|
|
30
|
+
return asyncLocalStorage.run(data, callback);
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toApiDateString = toApiDateString;
|
|
4
|
+
exports.wrapAsApiResponse = wrapAsApiResponse;
|
|
5
|
+
/**
|
|
6
|
+
* Converts JS timestamp or date to ISO 8601 format (without milliseconds).
|
|
7
|
+
* Example: "2012-07-20T01:19:13Z".
|
|
8
|
+
* @Internal
|
|
9
|
+
*/
|
|
10
|
+
function toApiDateString(value) {
|
|
11
|
+
const resultWithMillis = (typeof value === 'number' ? new Date(value) : value).toISOString();
|
|
12
|
+
return `${resultWithMillis.substring(0, resultWithMillis.length - 5)}Z`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Wraps the response into the correct API form.
|
|
16
|
+
* Add necessary fields, like 'requestId'.
|
|
17
|
+
* If the response is already in the correct form, returns it as-is.
|
|
18
|
+
* @Internal
|
|
19
|
+
*/
|
|
20
|
+
function wrapAsApiResponse(apiResponseOrResultValue) {
|
|
21
|
+
let apiResponse = apiResponseOrResultValue;
|
|
22
|
+
apiResponse = apiResponse?.result
|
|
23
|
+
? apiResponse // The value is in the correct 'ApiResponse' form: just return it.
|
|
24
|
+
: { result: apiResponseOrResultValue }; // Wrap the raw value into the correct ApiResponse form.
|
|
25
|
+
return apiResponse;
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { assertString, assertTruthy } from '@fishka/assertions';
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
constructor(status, message, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.details = details;
|
|
7
|
+
// Restore prototype chain for instanceof checks
|
|
8
|
+
Object.setPrototypeOf(this, HttpError.prototype);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Converts an API response value into a standardized ApiResponse structure. */
|
|
12
|
+
export function response(result) {
|
|
13
|
+
return { result };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Default documentation and validation for URL parameters.
|
|
17
|
+
* @Internal
|
|
18
|
+
*/
|
|
19
|
+
export const URL_PARAMETER_INFO = {};
|
|
20
|
+
/** Registers a new URL parameter. */
|
|
21
|
+
export function registerUrlParameter(name, info) {
|
|
22
|
+
URL_PARAMETER_INFO[name] = info;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Asserts that the value is a registered URL parameter name.
|
|
26
|
+
* @Internal
|
|
27
|
+
*/
|
|
28
|
+
export function assertUrlParameter(name) {
|
|
29
|
+
assertString(name, 'Url parameter name must be a string');
|
|
30
|
+
assertTruthy(URL_PARAMETER_INFO[name], `Invalid URL parameter: '${name}'. Please register it using 'registerUrlParameter('${name}', ...)'`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { HttpError } from '../api.types';
|
|
2
|
+
import { UNAUTHORIZED_STATUS } from '../http.types';
|
|
3
|
+
/**
|
|
4
|
+
* Basic authentication strategy using username/password validation.
|
|
5
|
+
* Parses HTTP Basic Authorization header and validates credentials.
|
|
6
|
+
*
|
|
7
|
+
* Example usage:
|
|
8
|
+
* ```
|
|
9
|
+
* const strategy = new BasicAuthStrategy(
|
|
10
|
+
* async (username, password) => {
|
|
11
|
+
* const user = await db.users.findByUsername(username);
|
|
12
|
+
* if (user && await bcrypt.compare(password, user.hash)) {
|
|
13
|
+
* return user;
|
|
14
|
+
* }
|
|
15
|
+
* return null;
|
|
16
|
+
* }
|
|
17
|
+
* );
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class BasicAuthStrategy {
|
|
21
|
+
constructor(verifyFn) {
|
|
22
|
+
this.verifyFn = verifyFn;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts username and password from Basic auth header.
|
|
26
|
+
* Expected format: "Basic base64(username:password)"
|
|
27
|
+
* Returns undefined if header is missing or not Basic.
|
|
28
|
+
*/
|
|
29
|
+
extractCredentials(req) {
|
|
30
|
+
const authHeaderValue = req.header('Authorization');
|
|
31
|
+
if (!authHeaderValue || !authHeaderValue.startsWith('Basic ')) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const decoded = Buffer.from(authHeaderValue.substring(6), 'base64').toString('utf-8');
|
|
36
|
+
const [username, password] = decoded.split(':');
|
|
37
|
+
// If format is "Basic base64(:)", it might mean empty username/password which is technically valid syntax but usually useless.
|
|
38
|
+
// However, split might return undefined for password if ":" is missing.
|
|
39
|
+
if (!username || password === undefined) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { username, password };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates the extracted credentials using the provided validation function.
|
|
50
|
+
*/
|
|
51
|
+
async validateCredentials({ username, password }) {
|
|
52
|
+
const user = await this.verifyFn(username, password);
|
|
53
|
+
if (!user) {
|
|
54
|
+
throw new HttpError(UNAUTHORIZED_STATUS, 'Invalid username or password');
|
|
55
|
+
}
|
|
56
|
+
return user;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { HttpError } from '../api.types';
|
|
2
|
+
import { UNAUTHORIZED_STATUS } from '../http.types';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a middleware that enforces authentication using the provided strategy.
|
|
5
|
+
* The authenticated user is stored in the context under the 'authUser' key.
|
|
6
|
+
*
|
|
7
|
+
* @template User - Type of the authenticated user
|
|
8
|
+
* @param strategy - Authentication strategy to use
|
|
9
|
+
* @param onSuccess - Optional callback to process authenticated user
|
|
10
|
+
* @returns a middleware that enforces authentication
|
|
11
|
+
*/
|
|
12
|
+
export function createAuthMiddleware(strategy, onSuccess) {
|
|
13
|
+
return async (handler, context) => {
|
|
14
|
+
// Extract credentials from request
|
|
15
|
+
const credentials = strategy.extractCredentials(context.req);
|
|
16
|
+
// If no credentials found (and strategy returned undefined), we must deny access here.
|
|
17
|
+
// In a composite strategy scenario, we might want to try the next strategy, but this helper is for a single strategy enforcement.
|
|
18
|
+
if (!credentials) {
|
|
19
|
+
throw new HttpError(UNAUTHORIZED_STATUS, 'No credentials provided or invalid format');
|
|
20
|
+
}
|
|
21
|
+
// Validate credentials and get authenticated user
|
|
22
|
+
const user = await strategy.validateCredentials(credentials);
|
|
23
|
+
// Store authenticated user in state for the handler to access
|
|
24
|
+
context.authUser = user;
|
|
25
|
+
// Optional: Call success callback
|
|
26
|
+
if (onSuccess) {
|
|
27
|
+
onSuccess(user, context);
|
|
28
|
+
}
|
|
29
|
+
// Execute the actual handler
|
|
30
|
+
return handler();
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extracts the authenticated user from the request context.
|
|
35
|
+
* Throws if the user is not present (i.e., authentication was not performed).
|
|
36
|
+
*
|
|
37
|
+
* @template User - Type of the authenticated user
|
|
38
|
+
* @param context - Request context
|
|
39
|
+
* @returns The authenticated user
|
|
40
|
+
* @throws Error if user is not found in context
|
|
41
|
+
*/
|
|
42
|
+
export function getAuthUser(context) {
|
|
43
|
+
const user = context.authUser;
|
|
44
|
+
if (!user) {
|
|
45
|
+
throw new HttpError(UNAUTHORIZED_STATUS, 'User not found in context. Did you add auth middleware?');
|
|
46
|
+
}
|
|
47
|
+
return user;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Safely extracts the authenticated user from the request context.
|
|
51
|
+
* Returns undefined if the user is not present.
|
|
52
|
+
*
|
|
53
|
+
* @template User - Type of the authenticated user
|
|
54
|
+
* @param context - Request context
|
|
55
|
+
* @returns The authenticated user, or undefined if not found
|
|
56
|
+
*/
|
|
57
|
+
export function tryGetAuthUser(context) {
|
|
58
|
+
return context.authUser;
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { HttpError } from '../api.types';
|
|
2
|
+
import { UNAUTHORIZED_STATUS } from '../http.types';
|
|
3
|
+
/**
|
|
4
|
+
* Bearer authentication strategy (commonly used for JWTs).
|
|
5
|
+
* Extracts the token from the 'Authorization: Bearer <token>' header.
|
|
6
|
+
*
|
|
7
|
+
* The validation logic is delegated to the `verifyFn`, which can:
|
|
8
|
+
* - Validate a JWT signature locally.
|
|
9
|
+
* - Call an external API/website to verify the token (Introspection/UserInfo).
|
|
10
|
+
*
|
|
11
|
+
* Example usage:
|
|
12
|
+
* ```ts
|
|
13
|
+
* const strategy = new BearerAuthStrategy(async (token) => {
|
|
14
|
+
* // Call external website to validate
|
|
15
|
+
* const response = await fetch('https://auth.example.com/verify', {
|
|
16
|
+
* headers: { Authorization: `Bearer ${token}` }
|
|
17
|
+
* });
|
|
18
|
+
* if (!response.ok) return null;
|
|
19
|
+
* return await response.json();
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class BearerAuthStrategy {
|
|
24
|
+
/**
|
|
25
|
+
* @param verifyFn Function to validate the token. Returns the user if valid, or null if invalid.
|
|
26
|
+
*/
|
|
27
|
+
constructor(verifyFn) {
|
|
28
|
+
this.verifyFn = verifyFn;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extracts the Bearer token from the Authorization header.
|
|
32
|
+
* Returns undefined if the header is missing or not a Bearer token.
|
|
33
|
+
*/
|
|
34
|
+
extractCredentials(req) {
|
|
35
|
+
const authHeader = req.header('Authorization');
|
|
36
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const token = authHeader.substring(7).trim();
|
|
40
|
+
if (!token) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return token;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates the extracted token using the provided verification function.
|
|
47
|
+
*/
|
|
48
|
+
async validateCredentials(token) {
|
|
49
|
+
const user = await this.verifyFn(token);
|
|
50
|
+
if (!user) {
|
|
51
|
+
throw new HttpError(UNAUTHORIZED_STATUS, 'Invalid token');
|
|
52
|
+
}
|
|
53
|
+
return user;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const defaultConfig = {
|
|
2
|
+
trustRequestIdHeader: true,
|
|
3
|
+
};
|
|
4
|
+
let currentConfig = { ...defaultConfig };
|
|
5
|
+
/**
|
|
6
|
+
* Configure global @fishka/express settings.
|
|
7
|
+
* @param config Partial configuration to merge with current settings
|
|
8
|
+
*/
|
|
9
|
+
export function configureExpressApi(config) {
|
|
10
|
+
currentConfig = { ...currentConfig, ...config };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Get current Express API configuration.
|
|
14
|
+
*/
|
|
15
|
+
export function getExpressApiConfig() {
|
|
16
|
+
return currentConfig;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reset API configuration to defaults.
|
|
20
|
+
* Useful for testing.
|
|
21
|
+
*/
|
|
22
|
+
export function resetExpressApiConfig() {
|
|
23
|
+
currentConfig = { ...defaultConfig };
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getMessageFromError } from '@fishka/assertions';
|
|
2
|
+
import { HttpError } from './api.types';
|
|
3
|
+
import { BAD_REQUEST_STATUS, INTERNAL_ERROR_STATUS } from './http.types';
|
|
4
|
+
import { getRequestLocalStorage } from './thread-local/thread-local-storage';
|
|
5
|
+
import { wrapAsApiResponse } from './utils/conversion.utils';
|
|
6
|
+
function buildApiResponse(error) {
|
|
7
|
+
const tls = getRequestLocalStorage();
|
|
8
|
+
const requestId = tls?.requestId;
|
|
9
|
+
let response;
|
|
10
|
+
if (error instanceof HttpError) {
|
|
11
|
+
response = {
|
|
12
|
+
...wrapAsApiResponse(undefined),
|
|
13
|
+
error: error.message,
|
|
14
|
+
status: error.status,
|
|
15
|
+
details: error.details,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const errorMessage = getMessageFromError(error, '');
|
|
20
|
+
response = {
|
|
21
|
+
...wrapAsApiResponse(undefined),
|
|
22
|
+
error: errorMessage && errorMessage.length > 0 ? errorMessage : 'Internal error',
|
|
23
|
+
status: INTERNAL_ERROR_STATUS,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (requestId) {
|
|
27
|
+
response.requestId = requestId;
|
|
28
|
+
}
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
/** Catches all kinds of unprocessed exceptions thrown from a single route. */
|
|
32
|
+
export function catchRouteErrors(fn) {
|
|
33
|
+
return async (req, res, next) => {
|
|
34
|
+
try {
|
|
35
|
+
await fn(req, res, next);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const apiResponse = buildApiResponse(error);
|
|
39
|
+
if (apiResponse.status >= INTERNAL_ERROR_STATUS) {
|
|
40
|
+
console.error(`catchRouteErrors: ${req.path}`, error);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(`catchRouteErrors: ${req.path}`, error);
|
|
44
|
+
}
|
|
45
|
+
res.status(apiResponse.status);
|
|
46
|
+
res.send(apiResponse);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Catches all errors in Express.js and is installed as global middleware.
|
|
52
|
+
* Note that individual routes are wrapped with 'catchRouteErrors' middleware.
|
|
53
|
+
*/
|
|
54
|
+
export async function catchAllMiddleware(error, _, res, next) {
|
|
55
|
+
if (!error) {
|
|
56
|
+
next();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Report as critical. This kind of error should never happen.
|
|
60
|
+
console.error('catchAllMiddleware:', getMessageFromError(error));
|
|
61
|
+
const apiResponse = error instanceof SyntaxError // JSON body parsing error.
|
|
62
|
+
? buildApiResponse(`${BAD_REQUEST_STATUS}: Failed to parse request: ${error.message}`)
|
|
63
|
+
: buildApiResponse(error);
|
|
64
|
+
res.status(apiResponse.status);
|
|
65
|
+
res.send(apiResponse);
|
|
66
|
+
}
|