@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
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Express API
|
|
2
|
+
|
|
3
|
+
Type-safe Express.js routing with clean, minimal API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @fishka/express
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import { createRouteTable } from '@fishka/express';
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
app.use(express.json());
|
|
19
|
+
|
|
20
|
+
const routes = createRouteTable(app);
|
|
21
|
+
|
|
22
|
+
// GET /users/:id - using function shorthand
|
|
23
|
+
routes.get<{ id: string; name: string }>('users/:id', async ctx => ({
|
|
24
|
+
id: ctx.params.get('id'),
|
|
25
|
+
name: 'John',
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// GET /users - using full endpoint object
|
|
29
|
+
routes.get<Array<{ id: string; name: string }>>('users', async () => [
|
|
30
|
+
{ id: '1', name: 'John' },
|
|
31
|
+
{ id: '2', name: 'Jane' },
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// POST /users
|
|
35
|
+
routes.post<{ name: string }, { id: string }>('users', {
|
|
36
|
+
$body: { name: v => assertString(v, '400: name required') },
|
|
37
|
+
run: async ctx => ({ id: '1' }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// DELETE /users/:id - using function shorthand
|
|
41
|
+
routes.delete('users/:id', async () => {
|
|
42
|
+
// Delete user logic
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.listen(3000);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## URL Parameters
|
|
49
|
+
|
|
50
|
+
Global validation can be enforced for specific URL parameters (e.g., `:id`, `:orgId`) across all routes.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { registerUrlParameter } from '@fishka/express';
|
|
54
|
+
import { assertString } from '@fishka/assertions';
|
|
55
|
+
|
|
56
|
+
// Register parameters with optional validation
|
|
57
|
+
registerUrlParameter('orgId', {
|
|
58
|
+
validator: (val) => {
|
|
59
|
+
assertString(val);
|
|
60
|
+
if (!val.startsWith('org-')) throw new Error('400: Invalid Organization ID');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Now /orgs/:orgId will automatically validate that orgId starts with 'org-'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Authentication
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { createAuthMiddleware, BasicAuthStrategy, getAuthUser } from '@fishka/express';
|
|
71
|
+
|
|
72
|
+
const auth = new BasicAuthStrategy(async (user, pass) =>
|
|
73
|
+
user === 'admin' && pass === 'secret' ? { id: '1', role: 'admin' } : null,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
routes.get('profile', {
|
|
77
|
+
middlewares: [createAuthMiddleware(auth)],
|
|
78
|
+
run: async ctx => {
|
|
79
|
+
const user = getAuthUser(ctx);
|
|
80
|
+
return { id: user.id };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Rate Limiting
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { createRateLimiterMiddleware } from '@fishka/express';
|
|
89
|
+
|
|
90
|
+
app.use(
|
|
91
|
+
await createRateLimiterMiddleware({
|
|
92
|
+
points: { read: 100, write: 50 },
|
|
93
|
+
duration: 60,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Complete Example
|
|
99
|
+
|
|
100
|
+
Here is a full initialization including TLS context, global validation, and proper error handling.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import express from 'express';
|
|
104
|
+
import {
|
|
105
|
+
createRouteTable,
|
|
106
|
+
createTlsMiddleware,
|
|
107
|
+
catchAllMiddleware,
|
|
108
|
+
registerUrlParameter
|
|
109
|
+
} from '@fishka/express';
|
|
110
|
+
import { assertString } from '@fishka/assertions';
|
|
111
|
+
|
|
112
|
+
const app = express();
|
|
113
|
+
|
|
114
|
+
// 1. Basic express middleware
|
|
115
|
+
app.use(express.json());
|
|
116
|
+
|
|
117
|
+
// 2. Initialize TLS context (Request IDs, etc.)
|
|
118
|
+
app.use(createTlsMiddleware());
|
|
119
|
+
|
|
120
|
+
// 3. Register global URL parameters
|
|
121
|
+
registerUrlParameter('id', {
|
|
122
|
+
validator: (val) => assertString(val)
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// 4. Define routes
|
|
126
|
+
const routes = createRouteTable(app);
|
|
127
|
+
routes.get('health', async () => ({ status: 'UP' }));
|
|
128
|
+
|
|
129
|
+
// 5. Global error handler (Must be after route definitions)
|
|
130
|
+
app.use(catchAllMiddleware);
|
|
131
|
+
|
|
132
|
+
app.listen(3000);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.URL_PARAMETER_INFO = exports.HttpError = void 0;
|
|
4
|
+
exports.response = response;
|
|
5
|
+
exports.registerUrlParameter = registerUrlParameter;
|
|
6
|
+
exports.assertUrlParameter = assertUrlParameter;
|
|
7
|
+
const assertions_1 = require("@fishka/assertions");
|
|
8
|
+
class HttpError extends Error {
|
|
9
|
+
constructor(status, message, details) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.details = details;
|
|
13
|
+
// Restore prototype chain for instanceof checks
|
|
14
|
+
Object.setPrototypeOf(this, HttpError.prototype);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.HttpError = HttpError;
|
|
18
|
+
/** Converts an API response value into a standardized ApiResponse structure. */
|
|
19
|
+
function response(result) {
|
|
20
|
+
return { result };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Default documentation and validation for URL parameters.
|
|
24
|
+
* @Internal
|
|
25
|
+
*/
|
|
26
|
+
exports.URL_PARAMETER_INFO = {};
|
|
27
|
+
/** Registers a new URL parameter. */
|
|
28
|
+
function registerUrlParameter(name, info) {
|
|
29
|
+
exports.URL_PARAMETER_INFO[name] = info;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Asserts that the value is a registered URL parameter name.
|
|
33
|
+
* @Internal
|
|
34
|
+
*/
|
|
35
|
+
function assertUrlParameter(name) {
|
|
36
|
+
(0, assertions_1.assertString)(name, 'Url parameter name must be a string');
|
|
37
|
+
(0, assertions_1.assertTruthy)(exports.URL_PARAMETER_INFO[name], `Invalid URL parameter: '${name}'. Please register it using 'registerUrlParameter('${name}', ...)'`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BasicAuthStrategy = void 0;
|
|
4
|
+
const api_types_1 = require("../api.types");
|
|
5
|
+
const http_types_1 = require("../http.types");
|
|
6
|
+
/**
|
|
7
|
+
* Basic authentication strategy using username/password validation.
|
|
8
|
+
* Parses HTTP Basic Authorization header and validates credentials.
|
|
9
|
+
*
|
|
10
|
+
* Example usage:
|
|
11
|
+
* ```
|
|
12
|
+
* const strategy = new BasicAuthStrategy(
|
|
13
|
+
* async (username, password) => {
|
|
14
|
+
* const user = await db.users.findByUsername(username);
|
|
15
|
+
* if (user && await bcrypt.compare(password, user.hash)) {
|
|
16
|
+
* return user;
|
|
17
|
+
* }
|
|
18
|
+
* return null;
|
|
19
|
+
* }
|
|
20
|
+
* );
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
class BasicAuthStrategy {
|
|
24
|
+
constructor(verifyFn) {
|
|
25
|
+
this.verifyFn = verifyFn;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extracts username and password from Basic auth header.
|
|
29
|
+
* Expected format: "Basic base64(username:password)"
|
|
30
|
+
* Returns undefined if header is missing or not Basic.
|
|
31
|
+
*/
|
|
32
|
+
extractCredentials(req) {
|
|
33
|
+
const authHeaderValue = req.header('Authorization');
|
|
34
|
+
if (!authHeaderValue || !authHeaderValue.startsWith('Basic ')) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const decoded = Buffer.from(authHeaderValue.substring(6), 'base64').toString('utf-8');
|
|
39
|
+
const [username, password] = decoded.split(':');
|
|
40
|
+
// If format is "Basic base64(:)", it might mean empty username/password which is technically valid syntax but usually useless.
|
|
41
|
+
// However, split might return undefined for password if ":" is missing.
|
|
42
|
+
if (!username || password === undefined) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return { username, password };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validates the extracted credentials using the provided validation function.
|
|
53
|
+
*/
|
|
54
|
+
async validateCredentials({ username, password }) {
|
|
55
|
+
const user = await this.verifyFn(username, password);
|
|
56
|
+
if (!user) {
|
|
57
|
+
throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'Invalid username or password');
|
|
58
|
+
}
|
|
59
|
+
return user;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.BasicAuthStrategy = BasicAuthStrategy;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAuthMiddleware = createAuthMiddleware;
|
|
4
|
+
exports.getAuthUser = getAuthUser;
|
|
5
|
+
exports.tryGetAuthUser = tryGetAuthUser;
|
|
6
|
+
const api_types_1 = require("../api.types");
|
|
7
|
+
const http_types_1 = require("../http.types");
|
|
8
|
+
/**
|
|
9
|
+
* Creates a middleware that enforces authentication using the provided strategy.
|
|
10
|
+
* The authenticated user is stored in the context under the 'authUser' key.
|
|
11
|
+
*
|
|
12
|
+
* @template User - Type of the authenticated user
|
|
13
|
+
* @param strategy - Authentication strategy to use
|
|
14
|
+
* @param onSuccess - Optional callback to process authenticated user
|
|
15
|
+
* @returns a middleware that enforces authentication
|
|
16
|
+
*/
|
|
17
|
+
function createAuthMiddleware(strategy, onSuccess) {
|
|
18
|
+
return async (handler, context) => {
|
|
19
|
+
// Extract credentials from request
|
|
20
|
+
const credentials = strategy.extractCredentials(context.req);
|
|
21
|
+
// If no credentials found (and strategy returned undefined), we must deny access here.
|
|
22
|
+
// In a composite strategy scenario, we might want to try the next strategy, but this helper is for a single strategy enforcement.
|
|
23
|
+
if (!credentials) {
|
|
24
|
+
throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'No credentials provided or invalid format');
|
|
25
|
+
}
|
|
26
|
+
// Validate credentials and get authenticated user
|
|
27
|
+
const user = await strategy.validateCredentials(credentials);
|
|
28
|
+
// Store authenticated user in state for the handler to access
|
|
29
|
+
context.authUser = user;
|
|
30
|
+
// Optional: Call success callback
|
|
31
|
+
if (onSuccess) {
|
|
32
|
+
onSuccess(user, context);
|
|
33
|
+
}
|
|
34
|
+
// Execute the actual handler
|
|
35
|
+
return handler();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extracts the authenticated user from the request context.
|
|
40
|
+
* Throws if the user is not present (i.e., authentication was not performed).
|
|
41
|
+
*
|
|
42
|
+
* @template User - Type of the authenticated user
|
|
43
|
+
* @param context - Request context
|
|
44
|
+
* @returns The authenticated user
|
|
45
|
+
* @throws Error if user is not found in context
|
|
46
|
+
*/
|
|
47
|
+
function getAuthUser(context) {
|
|
48
|
+
const user = context.authUser;
|
|
49
|
+
if (!user) {
|
|
50
|
+
throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'User not found in context. Did you add auth middleware?');
|
|
51
|
+
}
|
|
52
|
+
return user;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Safely extracts the authenticated user from the request context.
|
|
56
|
+
* Returns undefined if the user is not present.
|
|
57
|
+
*
|
|
58
|
+
* @template User - Type of the authenticated user
|
|
59
|
+
* @param context - Request context
|
|
60
|
+
* @returns The authenticated user, or undefined if not found
|
|
61
|
+
*/
|
|
62
|
+
function tryGetAuthUser(context) {
|
|
63
|
+
return context.authUser;
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BearerAuthStrategy = void 0;
|
|
4
|
+
const api_types_1 = require("../api.types");
|
|
5
|
+
const http_types_1 = require("../http.types");
|
|
6
|
+
/**
|
|
7
|
+
* Bearer authentication strategy (commonly used for JWTs).
|
|
8
|
+
* Extracts the token from the 'Authorization: Bearer <token>' header.
|
|
9
|
+
*
|
|
10
|
+
* The validation logic is delegated to the `verifyFn`, which can:
|
|
11
|
+
* - Validate a JWT signature locally.
|
|
12
|
+
* - Call an external API/website to verify the token (Introspection/UserInfo).
|
|
13
|
+
*
|
|
14
|
+
* Example usage:
|
|
15
|
+
* ```ts
|
|
16
|
+
* const strategy = new BearerAuthStrategy(async (token) => {
|
|
17
|
+
* // Call external website to validate
|
|
18
|
+
* const response = await fetch('https://auth.example.com/verify', {
|
|
19
|
+
* headers: { Authorization: `Bearer ${token}` }
|
|
20
|
+
* });
|
|
21
|
+
* if (!response.ok) return null;
|
|
22
|
+
* return await response.json();
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
class BearerAuthStrategy {
|
|
27
|
+
/**
|
|
28
|
+
* @param verifyFn Function to validate the token. Returns the user if valid, or null if invalid.
|
|
29
|
+
*/
|
|
30
|
+
constructor(verifyFn) {
|
|
31
|
+
this.verifyFn = verifyFn;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extracts the Bearer token from the Authorization header.
|
|
35
|
+
* Returns undefined if the header is missing or not a Bearer token.
|
|
36
|
+
*/
|
|
37
|
+
extractCredentials(req) {
|
|
38
|
+
const authHeader = req.header('Authorization');
|
|
39
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const token = authHeader.substring(7).trim();
|
|
43
|
+
if (!token) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates the extracted token using the provided verification function.
|
|
50
|
+
*/
|
|
51
|
+
async validateCredentials(token) {
|
|
52
|
+
const user = await this.verifyFn(token);
|
|
53
|
+
if (!user) {
|
|
54
|
+
throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'Invalid token');
|
|
55
|
+
}
|
|
56
|
+
return user;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.BearerAuthStrategy = BearerAuthStrategy;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.configureExpressApi = configureExpressApi;
|
|
4
|
+
exports.getExpressApiConfig = getExpressApiConfig;
|
|
5
|
+
exports.resetExpressApiConfig = resetExpressApiConfig;
|
|
6
|
+
const defaultConfig = {
|
|
7
|
+
trustRequestIdHeader: true,
|
|
8
|
+
};
|
|
9
|
+
let currentConfig = { ...defaultConfig };
|
|
10
|
+
/**
|
|
11
|
+
* Configure global @fishka/express settings.
|
|
12
|
+
* @param config Partial configuration to merge with current settings
|
|
13
|
+
*/
|
|
14
|
+
function configureExpressApi(config) {
|
|
15
|
+
currentConfig = { ...currentConfig, ...config };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Get current Express API configuration.
|
|
19
|
+
*/
|
|
20
|
+
function getExpressApiConfig() {
|
|
21
|
+
return currentConfig;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reset API configuration to defaults.
|
|
25
|
+
* Useful for testing.
|
|
26
|
+
*/
|
|
27
|
+
function resetExpressApiConfig() {
|
|
28
|
+
currentConfig = { ...defaultConfig };
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.catchRouteErrors = catchRouteErrors;
|
|
4
|
+
exports.catchAllMiddleware = catchAllMiddleware;
|
|
5
|
+
const assertions_1 = require("@fishka/assertions");
|
|
6
|
+
const api_types_1 = require("./api.types");
|
|
7
|
+
const http_types_1 = require("./http.types");
|
|
8
|
+
const thread_local_storage_1 = require("./thread-local/thread-local-storage");
|
|
9
|
+
const conversion_utils_1 = require("./utils/conversion.utils");
|
|
10
|
+
function buildApiResponse(error) {
|
|
11
|
+
const tls = (0, thread_local_storage_1.getRequestLocalStorage)();
|
|
12
|
+
const requestId = tls?.requestId;
|
|
13
|
+
let response;
|
|
14
|
+
if (error instanceof api_types_1.HttpError) {
|
|
15
|
+
response = {
|
|
16
|
+
...(0, conversion_utils_1.wrapAsApiResponse)(undefined),
|
|
17
|
+
error: error.message,
|
|
18
|
+
status: error.status,
|
|
19
|
+
details: error.details,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const errorMessage = (0, assertions_1.getMessageFromError)(error, '');
|
|
24
|
+
response = {
|
|
25
|
+
...(0, conversion_utils_1.wrapAsApiResponse)(undefined),
|
|
26
|
+
error: errorMessage && errorMessage.length > 0 ? errorMessage : 'Internal error',
|
|
27
|
+
status: http_types_1.INTERNAL_ERROR_STATUS,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (requestId) {
|
|
31
|
+
response.requestId = requestId;
|
|
32
|
+
}
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
/** Catches all kinds of unprocessed exceptions thrown from a single route. */
|
|
36
|
+
function catchRouteErrors(fn) {
|
|
37
|
+
return async (req, res, next) => {
|
|
38
|
+
try {
|
|
39
|
+
await fn(req, res, next);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const apiResponse = buildApiResponse(error);
|
|
43
|
+
if (apiResponse.status >= http_types_1.INTERNAL_ERROR_STATUS) {
|
|
44
|
+
console.error(`catchRouteErrors: ${req.path}`, error);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
console.log(`catchRouteErrors: ${req.path}`, error);
|
|
48
|
+
}
|
|
49
|
+
res.status(apiResponse.status);
|
|
50
|
+
res.send(apiResponse);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Catches all errors in Express.js and is installed as global middleware.
|
|
56
|
+
* Note that individual routes are wrapped with 'catchRouteErrors' middleware.
|
|
57
|
+
*/
|
|
58
|
+
async function catchAllMiddleware(error, _, res, next) {
|
|
59
|
+
if (!error) {
|
|
60
|
+
next();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Report as critical. This kind of error should never happen.
|
|
64
|
+
console.error('catchAllMiddleware:', (0, assertions_1.getMessageFromError)(error));
|
|
65
|
+
const apiResponse = error instanceof SyntaxError // JSON body parsing error.
|
|
66
|
+
? buildApiResponse(`${http_types_1.BAD_REQUEST_STATUS}: Failed to parse request: ${error.message}`)
|
|
67
|
+
: buildApiResponse(error);
|
|
68
|
+
res.status(apiResponse.status);
|
|
69
|
+
res.send(apiResponse);
|
|
70
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TOO_MANY_REQUESTS_STATUS = exports.OK_STATUS = exports.NOT_FOUND_STATUS = exports.FORBIDDEN_STATUS = exports.UNAUTHORIZED_STATUS = exports.BAD_REQUEST_STATUS = exports.INTERNAL_ERROR_STATUS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Common HTTP status codes as numbers.
|
|
6
|
+
* @Internal
|
|
7
|
+
*/
|
|
8
|
+
exports.INTERNAL_ERROR_STATUS = 500;
|
|
9
|
+
/**
|
|
10
|
+
* Common HTTP status codes as numbers.
|
|
11
|
+
* @Internal
|
|
12
|
+
*/
|
|
13
|
+
exports.BAD_REQUEST_STATUS = 400;
|
|
14
|
+
/**
|
|
15
|
+
* Common HTTP status codes as numbers.
|
|
16
|
+
* @Internal
|
|
17
|
+
*/
|
|
18
|
+
exports.UNAUTHORIZED_STATUS = 401;
|
|
19
|
+
/**
|
|
20
|
+
* Common HTTP status codes as numbers.
|
|
21
|
+
* @Internal
|
|
22
|
+
*/
|
|
23
|
+
exports.FORBIDDEN_STATUS = 403;
|
|
24
|
+
/**
|
|
25
|
+
* Common HTTP status codes as numbers.
|
|
26
|
+
* @Internal
|
|
27
|
+
*/
|
|
28
|
+
exports.NOT_FOUND_STATUS = 404;
|
|
29
|
+
/**
|
|
30
|
+
* Common HTTP status codes as numbers.
|
|
31
|
+
* @Internal
|
|
32
|
+
*/
|
|
33
|
+
exports.OK_STATUS = 200;
|
|
34
|
+
/**
|
|
35
|
+
* Common HTTP status codes as numbers.
|
|
36
|
+
* @Internal
|
|
37
|
+
*/
|
|
38
|
+
exports.TOO_MANY_REQUESTS_STATUS = 429;
|
|
@@ -0,0 +1,31 @@
|
|
|
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./api.types"), exports);
|
|
18
|
+
__exportStar(require("./auth/auth-strategy"), exports);
|
|
19
|
+
__exportStar(require("./auth/auth.types"), exports);
|
|
20
|
+
__exportStar(require("./auth/auth.utils"), exports);
|
|
21
|
+
__exportStar(require("./auth/bearer-auth-strategy"), exports);
|
|
22
|
+
__exportStar(require("./config"), exports);
|
|
23
|
+
__exportStar(require("./error-handling"), exports);
|
|
24
|
+
__exportStar(require("./http.types"), exports);
|
|
25
|
+
__exportStar(require("./rate-limit/in-memory-rate-limiter"), exports);
|
|
26
|
+
__exportStar(require("./rate-limit/rate-limit.types"), exports);
|
|
27
|
+
__exportStar(require("./route-table"), exports);
|
|
28
|
+
__exportStar(require("./router"), exports);
|
|
29
|
+
__exportStar(require("./thread-local/thread-local-storage"), exports);
|
|
30
|
+
__exportStar(require("./thread-local/thread-local-storage-middleware"), exports);
|
|
31
|
+
__exportStar(require("./utils/express.utils"), exports);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryRateLimiter = void 0;
|
|
4
|
+
exports.createRateLimiterMiddleware = createRateLimiterMiddleware;
|
|
5
|
+
const rate_limit_1 = require("./rate-limit");
|
|
6
|
+
const MILLIS_PER_SECOND = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* In-memory rate limiter using sliding window counter.
|
|
9
|
+
* Tracks request counts per key with time-based window expiration.
|
|
10
|
+
*/
|
|
11
|
+
class InMemoryRateLimiter {
|
|
12
|
+
constructor(points, durationSeconds) {
|
|
13
|
+
this.limits = new Map();
|
|
14
|
+
this.points = points;
|
|
15
|
+
this.durationMs = durationSeconds * MILLIS_PER_SECOND;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Try to consume points from the rate limit.
|
|
19
|
+
* Returns result if successful, throws if limit exceeded.
|
|
20
|
+
*
|
|
21
|
+
* @param key - Unique identifier for the client (e.g., IP address)
|
|
22
|
+
* @returns Rate limit result with remaining points and ms until reset
|
|
23
|
+
* @throws RateLimitResult if limit exceeded
|
|
24
|
+
*/
|
|
25
|
+
consume(key) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const existing = this.limits.get(key);
|
|
28
|
+
// Reset expired entry
|
|
29
|
+
if (existing && now >= existing.resetTime) {
|
|
30
|
+
this.limits.delete(key);
|
|
31
|
+
}
|
|
32
|
+
const current = this.limits.get(key) || { count: 0, resetTime: now + this.durationMs };
|
|
33
|
+
if (current.count >= this.points) {
|
|
34
|
+
const msBeforeNext = Math.max(0, current.resetTime - now);
|
|
35
|
+
throw {
|
|
36
|
+
remainingPoints: 0,
|
|
37
|
+
msBeforeNext,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
current.count++;
|
|
41
|
+
this.limits.set(key, current);
|
|
42
|
+
const msBeforeNext = Math.max(0, current.resetTime - now);
|
|
43
|
+
return {
|
|
44
|
+
remainingPoints: this.points - current.count,
|
|
45
|
+
msBeforeNext,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.InMemoryRateLimiter = InMemoryRateLimiter;
|
|
50
|
+
/**
|
|
51
|
+
* Creates a rate limiter middleware using in-memory implementation.
|
|
52
|
+
*
|
|
53
|
+
* Separate limiters are used for read (GET) and write (POST/PATCH/PUT/DELETE) requests.
|
|
54
|
+
*
|
|
55
|
+
* @param config - Rate limit configuration
|
|
56
|
+
* @returns Express middleware function
|
|
57
|
+
*/
|
|
58
|
+
async function createRateLimiterMiddleware(config) {
|
|
59
|
+
const readLimiter = new InMemoryRateLimiter(config.points.read, config.duration);
|
|
60
|
+
const writeLimiter = new InMemoryRateLimiter(config.points.write, config.duration);
|
|
61
|
+
const whitelist = config.rateLimitWhitelist || ['/v1', '/health'];
|
|
62
|
+
/**
|
|
63
|
+
* The actual middleware function.
|
|
64
|
+
*/
|
|
65
|
+
return async (req, res, next) => {
|
|
66
|
+
// Check if the path is whitelisted
|
|
67
|
+
if (whitelist.some(path => req.path.includes(path))) {
|
|
68
|
+
next();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const isReadRequest = req.method === 'GET';
|
|
72
|
+
const limiter = isReadRequest ? readLimiter : writeLimiter;
|
|
73
|
+
const limitPoints = isReadRequest ? config.points.read : config.points.write;
|
|
74
|
+
const clientKey = req.ip || 'unknown';
|
|
75
|
+
try {
|
|
76
|
+
const result = limiter.consume(clientKey);
|
|
77
|
+
(0, rate_limit_1.addRateLimitHeaders)(res, result, limitPoints, config.duration);
|
|
78
|
+
next();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const result = error;
|
|
82
|
+
(0, rate_limit_1.addRateLimitHeaders)(res, result, limitPoints, config.duration)
|
|
83
|
+
.header('Retry-After', `${(0, rate_limit_1.msToSeconds)(result.msBeforeNext)}`)
|
|
84
|
+
.status(429)
|
|
85
|
+
.send({ error: 'Too Many Requests' });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.addRateLimitHeaders = addRateLimitHeaders;
|
|
4
|
+
exports.msToSeconds = msToSeconds;
|
|
5
|
+
const MILLIS_PER_SECOND = 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Adds rate limit state headers to the response.
|
|
8
|
+
* See https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
|
|
9
|
+
*
|
|
10
|
+
* Headers added:
|
|
11
|
+
* - X-RateLimit-Limit: Server's quota for requests in the time window
|
|
12
|
+
* - X-RateLimit-Remaining: Remaining quota in the current window
|
|
13
|
+
* - X-RateLimit-Reset: Time remaining in the current window (in seconds)
|
|
14
|
+
* - X-RateLimit-Policy: Quota policies associated with the client
|
|
15
|
+
* @Internal
|
|
16
|
+
*/
|
|
17
|
+
function addRateLimitHeaders(res, result, limitPoints, duration) {
|
|
18
|
+
return (res
|
|
19
|
+
// The server's quota for requests by the client in the time window.
|
|
20
|
+
.header('X-RateLimit-Limit', `${limitPoints}`)
|
|
21
|
+
// The remaining quota in the current window.
|
|
22
|
+
.header('X-RateLimit-Remaining', `${result.remainingPoints}`)
|
|
23
|
+
// The time remaining in the current window specified in seconds.
|
|
24
|
+
.header('X-RateLimit-Reset', `${Math.ceil(result.msBeforeNext / MILLIS_PER_SECOND)}`)
|
|
25
|
+
// Indicates the quota policies currently associated with the client.
|
|
26
|
+
.header('X-RateLimit-Policy', `${limitPoints};w=${duration};comment="fixed window"`));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Converts milliseconds to seconds, rounding up.
|
|
30
|
+
* @Internal
|
|
31
|
+
*/
|
|
32
|
+
function msToSeconds(ms) {
|
|
33
|
+
return Math.ceil(ms / MILLIS_PER_SECOND);
|
|
34
|
+
}
|