@joinremba/gate 0.1.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/README.md +346 -0
- package/package.json +104 -0
- package/src/api-keys.ts +59 -0
- package/src/errors.ts +45 -0
- package/src/idempotency.ts +54 -0
- package/src/index.test.ts +116 -0
- package/src/index.ts +120 -0
- package/src/rate-limit.ts +67 -0
- package/src/respond.ts +77 -0
- package/src/validate.ts +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Remba
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# @joinremba/gate
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@joinremba/gate)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/joinremba/gate/actions/workflows/ci.yml)
|
|
6
|
+
[](https://bun.sh)
|
|
7
|
+
|
|
8
|
+
Gate is the API safety layer for TypeScript backends. It validates requests, formats responses, prevents duplicate operations, manages API keys, and protects endpoints from abuse.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Request validation** — Validate body, query, params, and headers with Zod schemas.
|
|
13
|
+
- **Structured responses** — Consistent `{ success, data, error }` response envelope with `ok()` and `fail()` helpers.
|
|
14
|
+
- **Problem details** — RFC 9457 problem-details-style error format.
|
|
15
|
+
- **Pagination** — Standardised paginated response helper.
|
|
16
|
+
- **Request ID** — Automatic request ID generation and propagation.
|
|
17
|
+
- **Idempotency** — Prevent duplicate writes with idempotency keys. Ships with in-memory store; plug in Redis or Postgres.
|
|
18
|
+
- **Rate limiting** — Protect endpoints from abuse with configurable windows and limits.
|
|
19
|
+
- **API key management** — Rotatable, scoped API key authentication with Bearer token support.
|
|
20
|
+
- **Framework-agnostic** — Use with Express, Hono, Fastify, Elysia, or raw Bun.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bun add @joinremba/gate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createGate, ok } from "@joinremba/gate";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
|
|
34
|
+
const gate = createGate({
|
|
35
|
+
apiKeys: [{ key: "sk-abc123", scopes: ["write"] }],
|
|
36
|
+
rateLimit: { windowMs: 60_000, max: 100 },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
app.post("/transfers", gate.middleware(), async (req, res) => {
|
|
40
|
+
return ok({ message: "Transfer queued" });
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Modules
|
|
45
|
+
|
|
46
|
+
Gate is organised into sub-modules that can be imported individually:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { validateRequest } from "@joinremba/gate/validate";
|
|
50
|
+
import { ok, fail, paginated, problem } from "@joinremba/gate/respond";
|
|
51
|
+
import { idempotency, InMemoryStore } from "@joinremba/gate/idempotency";
|
|
52
|
+
import { rateLimit, InMemoryRateLimitStore } from "@joinremba/gate/rate-limit";
|
|
53
|
+
import { createApiKeyValidator } from "@joinremba/gate/api-keys";
|
|
54
|
+
import { GateError, ValidationError, AuthenticationError } from "@joinremba/gate/errors";
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `createGate(options?)`
|
|
58
|
+
|
|
59
|
+
Factory function that returns a `Gate` instance with all modules pre-configured.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const gate = createGate({
|
|
63
|
+
apiKeys: [
|
|
64
|
+
{ key: "sk-read-only", scopes: ["read"] },
|
|
65
|
+
{ key: "sk-admin", scopes: ["read", "write", "delete"] },
|
|
66
|
+
],
|
|
67
|
+
rateLimit: { windowMs: 60_000, max: 50 },
|
|
68
|
+
idempotency: { keyHeader: "Idempotency-Key", ttl: 86_400_000 },
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Validate (`@joinremba/gate/validate`)
|
|
73
|
+
|
|
74
|
+
Validate request body, query, params, and headers against Zod schemas.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { validateRequest } from "@joinremba/gate/validate";
|
|
78
|
+
import { z } from "zod";
|
|
79
|
+
|
|
80
|
+
const transferSchema = z.object({
|
|
81
|
+
amount: z.number().positive(),
|
|
82
|
+
currency: z.enum(["NGN", "USD", "EUR"]),
|
|
83
|
+
recipient: z.string().min(1),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = validateRequest({ body: transferSchema }, { body: req.body });
|
|
87
|
+
|
|
88
|
+
if (!result.success) {
|
|
89
|
+
return gate.fail("Validation failed", "VALIDATION_ERROR", result.errors);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Respond (`@joinremba/gate/respond`)
|
|
94
|
+
|
|
95
|
+
Build consistent API responses.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { ok, fail, paginated, problem } from "@joinremba/gate/respond";
|
|
99
|
+
|
|
100
|
+
// Success
|
|
101
|
+
ok({ id: 1, name: "Alice" });
|
|
102
|
+
// -> { success: true, data: { id: 1, name: "Alice" } }
|
|
103
|
+
|
|
104
|
+
// Error
|
|
105
|
+
fail("Resource not found", "NOT_FOUND");
|
|
106
|
+
// -> { success: false, error: { message: "Resource not found", code: "NOT_FOUND" } }
|
|
107
|
+
|
|
108
|
+
// Paginated
|
|
109
|
+
paginated([{ id: 1 }], 25, 1, 10);
|
|
110
|
+
// -> { success: true, data: [...], pagination: { total: 25, page: 1, limit: 10, pages: 3 } }
|
|
111
|
+
|
|
112
|
+
// Problem details (RFC 9457)
|
|
113
|
+
problem({
|
|
114
|
+
type: "https://errors.remba.com/rate-limit",
|
|
115
|
+
title: "Rate Limit Exceeded",
|
|
116
|
+
status: 429,
|
|
117
|
+
detail: "Too many requests, please retry later",
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Idempotency (`@joinremba/gate/idempotency`)
|
|
122
|
+
|
|
123
|
+
Prevent duplicate processing of the same request using idempotency keys.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { idempotency, InMemoryStore } from "@joinremba/gate/idempotency";
|
|
127
|
+
|
|
128
|
+
const guard = idempotency({
|
|
129
|
+
store: new InMemoryStore(),
|
|
130
|
+
keyHeader: "Idempotency-Key",
|
|
131
|
+
ttl: 86_400_000, // 24 hours
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Check if a request has been processed
|
|
135
|
+
const existing = await guard.getResponse(idempotencyKey);
|
|
136
|
+
if (existing) return existing;
|
|
137
|
+
|
|
138
|
+
// Store the response after processing
|
|
139
|
+
await guard.setResponse(idempotencyKey, response);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Bring your own store by implementing the `IdempotencyStore` interface (Redis, Postgres, etc.).
|
|
143
|
+
|
|
144
|
+
### Rate Limiting (`@joinremba/gate/rate-limit`)
|
|
145
|
+
|
|
146
|
+
Protect endpoints from abuse.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { rateLimit, InMemoryRateLimitStore } from "@joinremba/gate/rate-limit";
|
|
150
|
+
|
|
151
|
+
const limiter = rateLimit({
|
|
152
|
+
windowMs: 60_000, // 1 minute
|
|
153
|
+
max: 30,
|
|
154
|
+
keyFn: (req) => req.headers.get("x-forwarded-for") ?? "global",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const { allowed, remaining } = await limiter.check(req);
|
|
158
|
+
if (!allowed) throw new RateLimitError();
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Customise the key function to rate-limit by user ID, API key, or IP.
|
|
162
|
+
|
|
163
|
+
### API Keys (`@joinremba/gate/api-keys`)
|
|
164
|
+
|
|
165
|
+
Validate API keys with optional scoped permissions.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { createApiKeyValidator } from "@joinremba/gate/api-keys";
|
|
169
|
+
|
|
170
|
+
const keys = createApiKeyValidator([
|
|
171
|
+
{ key: "sk-read-only", scopes: ["read"] },
|
|
172
|
+
{ key: "sk-admin", scopes: ["read", "write", "delete"] },
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// Direct validation
|
|
176
|
+
keys.validate("sk-read-only");
|
|
177
|
+
// -> { authenticated: true, key: "sk-read-only", scopes: ["read"] }
|
|
178
|
+
|
|
179
|
+
// Request authentication middleware
|
|
180
|
+
const auth = keys.authenticate({ requiredScopes: ["write"], header: "Authorization" });
|
|
181
|
+
const result = auth(request);
|
|
182
|
+
if (!result.authenticated) throw new AuthenticationError(result.error);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Errors (`@joinremba/gate/errors`)
|
|
186
|
+
|
|
187
|
+
Standard error types for consistent error handling.
|
|
188
|
+
|
|
189
|
+
| Error | Status | Code | When |
|
|
190
|
+
| --------------------- | ------ | ---------------------- | -------------------------- |
|
|
191
|
+
| `GateError` | 500 | `GATE_ERROR` | Base error type |
|
|
192
|
+
| `ValidationError` | 400 | `VALIDATION_ERROR` | Invalid request input |
|
|
193
|
+
| `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` | Missing or invalid API key |
|
|
194
|
+
| `RateLimitError` | 429 | `RATE_LIMIT_ERROR` | Rate limit exceeded |
|
|
195
|
+
| `IdempotencyError` | 409 | `IDEMPOTENCY_ERROR` | Idempotency key conflict |
|
|
196
|
+
|
|
197
|
+
## TypeScript Types
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import type {
|
|
201
|
+
Gate,
|
|
202
|
+
GateOptions,
|
|
203
|
+
Middleware,
|
|
204
|
+
ValidationSchemas,
|
|
205
|
+
ValidationResult,
|
|
206
|
+
SuccessResponse,
|
|
207
|
+
ErrorResponse,
|
|
208
|
+
PaginatedResponse,
|
|
209
|
+
ProblemDetails,
|
|
210
|
+
IdempotencyStore,
|
|
211
|
+
IdempotencyOptions,
|
|
212
|
+
RateLimitStore,
|
|
213
|
+
RateLimitOptions,
|
|
214
|
+
RateLimitStrategy,
|
|
215
|
+
ApiKeyEntry,
|
|
216
|
+
AuthenticateOptions,
|
|
217
|
+
AuthenticateResult,
|
|
218
|
+
} from "@joinremba/gate";
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Examples
|
|
222
|
+
|
|
223
|
+
### Express middleware
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import express from "express";
|
|
227
|
+
import { createGate, ok } from "@joinremba/gate";
|
|
228
|
+
import { z } from "zod";
|
|
229
|
+
|
|
230
|
+
const app = express();
|
|
231
|
+
const gate = createGate({ rateLimit: { windowMs: 60_000, max: 30 } });
|
|
232
|
+
|
|
233
|
+
app.post(
|
|
234
|
+
"/api/orders",
|
|
235
|
+
(req, res, next) => {
|
|
236
|
+
const result = gate.validate(
|
|
237
|
+
{ body: z.object({ productId: z.string(), quantity: z.number() }) },
|
|
238
|
+
{ body: req.body }
|
|
239
|
+
);
|
|
240
|
+
if (!result.success) {
|
|
241
|
+
return res.status(400).json(gate.fail("Validation failed", undefined, result.errors));
|
|
242
|
+
}
|
|
243
|
+
req.body = result.data.body;
|
|
244
|
+
next();
|
|
245
|
+
},
|
|
246
|
+
async (req, res) => {
|
|
247
|
+
const order = await createOrder(req.body);
|
|
248
|
+
res.json(ok(order));
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Hono middleware
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { Hono } from "hono";
|
|
257
|
+
import { createGate, ok } from "@joinremba/gate";
|
|
258
|
+
|
|
259
|
+
const app = new Hono();
|
|
260
|
+
const gate = createGate({ apiKeys: [{ key: "sk-admin" }] });
|
|
261
|
+
|
|
262
|
+
app.use("/api/*", async (c, next) => {
|
|
263
|
+
const auth = gate.apiKeys.authenticate()(c.req.raw);
|
|
264
|
+
if (!auth.authenticated) {
|
|
265
|
+
return c.json(gate.fail("Unauthorized"), 401);
|
|
266
|
+
}
|
|
267
|
+
await next();
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Combining multiple features
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const gate = createGate({
|
|
275
|
+
apiKeys: [{ key: "sk-admin", scopes: ["write"] }],
|
|
276
|
+
idempotency: { store: new InMemoryStore(), ttl: 86_400_000 },
|
|
277
|
+
rateLimit: { windowMs: 60_000, max: 50 },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
app.post("/orders", async (req, res, next) => {
|
|
281
|
+
// Rate limit
|
|
282
|
+
const rl = await gate.rateLimit.check(req);
|
|
283
|
+
if (!rl.allowed) return res.status(429).json(gate.fail("Too many requests"));
|
|
284
|
+
|
|
285
|
+
// Idempotency
|
|
286
|
+
const idemKey = req.headers.get(gate.idempotency.keyHeader);
|
|
287
|
+
if (idemKey) {
|
|
288
|
+
const cached = await gate.idempotency.getResponse(idemKey);
|
|
289
|
+
if (cached) return res.json(cached);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Validate
|
|
293
|
+
const result = gate.validate({ body: z.object({ amount: z.number() }) }, { body: req.body });
|
|
294
|
+
if (!result.success) return res.status(400).json(gate.fail("Validation failed"));
|
|
295
|
+
|
|
296
|
+
const response = ok(await processOrder(result.data.body));
|
|
297
|
+
|
|
298
|
+
if (idemKey) await gate.idempotency.setResponse(idemKey, response);
|
|
299
|
+
res.json(response);
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Roadmap
|
|
304
|
+
|
|
305
|
+
**MVP** (current)
|
|
306
|
+
|
|
307
|
+
- Request validation (body, query, params, headers)
|
|
308
|
+
- Standard success/error responses
|
|
309
|
+
- Problem-details error format (RFC 9457)
|
|
310
|
+
- Pagination helper
|
|
311
|
+
- Request ID support
|
|
312
|
+
- Express and Hono middleware examples
|
|
313
|
+
- In-memory idempotency store
|
|
314
|
+
- In-memory rate limiting store
|
|
315
|
+
|
|
316
|
+
**V1**
|
|
317
|
+
|
|
318
|
+
- Idempotency middleware
|
|
319
|
+
- Redis idempotency store
|
|
320
|
+
- Postgres idempotency store
|
|
321
|
+
- Rate limiting middleware
|
|
322
|
+
- API key hashing and validation
|
|
323
|
+
- Scopes and permissions middleware
|
|
324
|
+
- Usage tracking hooks
|
|
325
|
+
|
|
326
|
+
**V2**
|
|
327
|
+
|
|
328
|
+
- API key dashboard
|
|
329
|
+
- Usage analytics
|
|
330
|
+
- Abuse detection
|
|
331
|
+
- Team API key management
|
|
332
|
+
- Hosted key verification
|
|
333
|
+
- Organisation-level quotas
|
|
334
|
+
|
|
335
|
+
## Related Packages
|
|
336
|
+
|
|
337
|
+
- [@joinremba/beacon](https://github.com/joinremba/beacon) — Environment validation, config, secrets, and feature gates.
|
|
338
|
+
- [@joinremba/catalog](https://github.com/joinremba/catalog) — Production-ready logging and error event layer built on Pino.
|
|
339
|
+
|
|
340
|
+
## Contributing
|
|
341
|
+
|
|
342
|
+
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, development workflow, and pull request process.
|
|
343
|
+
|
|
344
|
+
## License
|
|
345
|
+
|
|
346
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@joinremba/gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "API safety layer for TypeScript backends. Validate requests, format responses, prevent duplicates, manage API keys, and protect endpoints from abuse.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./validate": {
|
|
15
|
+
"types": "./src/validate.ts",
|
|
16
|
+
"import": "./src/validate.ts",
|
|
17
|
+
"default": "./src/validate.ts"
|
|
18
|
+
},
|
|
19
|
+
"./respond": {
|
|
20
|
+
"types": "./src/respond.ts",
|
|
21
|
+
"import": "./src/respond.ts",
|
|
22
|
+
"default": "./src/respond.ts"
|
|
23
|
+
},
|
|
24
|
+
"./idempotency": {
|
|
25
|
+
"types": "./src/idempotency.ts",
|
|
26
|
+
"import": "./src/idempotency.ts",
|
|
27
|
+
"default": "./src/idempotency.ts"
|
|
28
|
+
},
|
|
29
|
+
"./rate-limit": {
|
|
30
|
+
"types": "./src/rate-limit.ts",
|
|
31
|
+
"import": "./src/rate-limit.ts",
|
|
32
|
+
"default": "./src/rate-limit.ts"
|
|
33
|
+
},
|
|
34
|
+
"./api-keys": {
|
|
35
|
+
"types": "./src/api-keys.ts",
|
|
36
|
+
"import": "./src/api-keys.ts",
|
|
37
|
+
"default": "./src/api-keys.ts"
|
|
38
|
+
},
|
|
39
|
+
"./errors": {
|
|
40
|
+
"types": "./src/errors.ts",
|
|
41
|
+
"import": "./src/errors.ts",
|
|
42
|
+
"default": "./src/errors.ts"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --format esm",
|
|
52
|
+
"dev": "bun --watch ./src/index.ts",
|
|
53
|
+
"format": "prettier --write .",
|
|
54
|
+
"format:check": "prettier --check .",
|
|
55
|
+
"lint": "eslint .",
|
|
56
|
+
"lint:fix": "eslint . --fix",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "bun test",
|
|
59
|
+
"test:watch": "bun test --watch",
|
|
60
|
+
"prepublishOnly": "bun run build",
|
|
61
|
+
"check": "bun lint && bun format:check && bun typecheck && bun test"
|
|
62
|
+
},
|
|
63
|
+
"author": {
|
|
64
|
+
"name": "Benson Isaac",
|
|
65
|
+
"email": "bensxnisaac@gmail.com"
|
|
66
|
+
},
|
|
67
|
+
"license": "MIT",
|
|
68
|
+
"repository": {
|
|
69
|
+
"type": "git",
|
|
70
|
+
"url": "git+https://github.com/joinremba/gate.git"
|
|
71
|
+
},
|
|
72
|
+
"bugs": {
|
|
73
|
+
"url": "https://github.com/joinremba/gate/issues"
|
|
74
|
+
},
|
|
75
|
+
"homepage": "https://github.com/joinremba/gate#readme",
|
|
76
|
+
"keywords": [
|
|
77
|
+
"api",
|
|
78
|
+
"middleware",
|
|
79
|
+
"validation",
|
|
80
|
+
"request-validation",
|
|
81
|
+
"rate-limiting",
|
|
82
|
+
"idempotency",
|
|
83
|
+
"api-keys",
|
|
84
|
+
"security"
|
|
85
|
+
],
|
|
86
|
+
"publishConfig": {
|
|
87
|
+
"access": "public"
|
|
88
|
+
},
|
|
89
|
+
"engines": {
|
|
90
|
+
"bun": ">=1.3.1"
|
|
91
|
+
},
|
|
92
|
+
"dependencies": {
|
|
93
|
+
"zod": "^4.4.2"
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@types/bun": "latest",
|
|
97
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
98
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
99
|
+
"eslint": "^8.57.1",
|
|
100
|
+
"eslint-config-prettier": "^10.1.8",
|
|
101
|
+
"prettier": "^3.8.3",
|
|
102
|
+
"typescript": "^6.0.3"
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/api-keys.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface ApiKeyEntry {
|
|
2
|
+
key: string;
|
|
3
|
+
scopes?: string[];
|
|
4
|
+
metadata?: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AuthenticateOptions {
|
|
8
|
+
requiredScopes?: string[];
|
|
9
|
+
header?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AuthenticateResult {
|
|
13
|
+
authenticated: boolean;
|
|
14
|
+
key?: string;
|
|
15
|
+
scopes?: string[];
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createApiKeyValidator(keys: ApiKeyEntry[]) {
|
|
20
|
+
const keyMap = new Map(keys.map((k) => [k.key, k]));
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
validate(providedKey: string): AuthenticateResult {
|
|
24
|
+
const entry = keyMap.get(providedKey);
|
|
25
|
+
if (!entry) {
|
|
26
|
+
return { authenticated: false, error: "Invalid API key" };
|
|
27
|
+
}
|
|
28
|
+
return { authenticated: true, key: entry.key, scopes: entry.scopes };
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
authenticate(options: AuthenticateOptions = {}) {
|
|
32
|
+
const header = options.header ?? "Authorization";
|
|
33
|
+
const requiredScopes = options.requiredScopes ?? [];
|
|
34
|
+
|
|
35
|
+
return (req: Request): AuthenticateResult => {
|
|
36
|
+
const authHeader = req.headers.get(header);
|
|
37
|
+
if (!authHeader) {
|
|
38
|
+
return { authenticated: false, error: "Missing API key" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
42
|
+
const result = this.validate(token);
|
|
43
|
+
|
|
44
|
+
if (!result.authenticated) return result;
|
|
45
|
+
|
|
46
|
+
if (requiredScopes.length > 0) {
|
|
47
|
+
const hasScopes = requiredScopes.every((s) => result.scopes?.includes(s));
|
|
48
|
+
if (!hasScopes) {
|
|
49
|
+
return { authenticated: false, error: "Insufficient permissions" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ApiKeyValidator = ReturnType<typeof createApiKeyValidator>;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class GateError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly status: number;
|
|
4
|
+
|
|
5
|
+
constructor(message: string, code: string, status = 500) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "GateError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ValidationError extends GateError {
|
|
14
|
+
readonly issues: unknown[];
|
|
15
|
+
|
|
16
|
+
constructor(message: string, issues: unknown[] = []) {
|
|
17
|
+
super(message, "VALIDATION_ERROR", 400);
|
|
18
|
+
this.name = "ValidationError";
|
|
19
|
+
this.issues = issues;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class AuthenticationError extends GateError {
|
|
24
|
+
constructor(message = "Unauthorized") {
|
|
25
|
+
super(message, "AUTHENTICATION_ERROR", 401);
|
|
26
|
+
this.name = "AuthenticationError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RateLimitError extends GateError {
|
|
31
|
+
readonly retryAfter: number;
|
|
32
|
+
|
|
33
|
+
constructor(retryAfter = 60) {
|
|
34
|
+
super("Too many requests", "RATE_LIMIT_ERROR", 429);
|
|
35
|
+
this.name = "RateLimitError";
|
|
36
|
+
this.retryAfter = retryAfter;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class IdempotencyError extends GateError {
|
|
41
|
+
constructor(message = "Idempotency key conflict") {
|
|
42
|
+
super(message, "IDEMPOTENCY_ERROR", 409);
|
|
43
|
+
this.name = "IdempotencyError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface IdempotencyStore {
|
|
2
|
+
get(key: string): Promise<unknown | null>;
|
|
3
|
+
set(key: string, value: unknown, ttl: number): Promise<void>;
|
|
4
|
+
delete(key: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class InMemoryStore implements IdempotencyStore {
|
|
8
|
+
private store = new Map<string, { value: unknown; expires: number }>();
|
|
9
|
+
|
|
10
|
+
async get(key: string): Promise<unknown | null> {
|
|
11
|
+
const entry = this.store.get(key);
|
|
12
|
+
if (!entry) return null;
|
|
13
|
+
if (Date.now() > entry.expires) {
|
|
14
|
+
this.store.delete(key);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return entry.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async set(key: string, value: unknown, ttl: number): Promise<void> {
|
|
21
|
+
this.store.set(key, { value, expires: Date.now() + ttl });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async delete(key: string): Promise<void> {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IdempotencyOptions {
|
|
30
|
+
store: IdempotencyStore;
|
|
31
|
+
keyHeader?: string;
|
|
32
|
+
ttl?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function idempotency(options: IdempotencyOptions) {
|
|
36
|
+
const keyHeader = options.keyHeader ?? "Idempotency-Key";
|
|
37
|
+
const ttl = options.ttl ?? 86_400_000; // 24 hours
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
keyHeader,
|
|
41
|
+
ttl,
|
|
42
|
+
store: options.store,
|
|
43
|
+
|
|
44
|
+
async getResponse(key: string) {
|
|
45
|
+
return options.store.get(`idemp:${key}`);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async setResponse(key: string, response: unknown) {
|
|
49
|
+
await options.store.set(`idemp:${key}`, response, ttl);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type IdempotencyInstance = ReturnType<typeof idempotency>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createGate, ok, fail, paginated, problem, validateRequest } from "./index";
|
|
4
|
+
|
|
5
|
+
test("createGate returns a gate instance", () => {
|
|
6
|
+
const gate = createGate();
|
|
7
|
+
expect(gate).toBeDefined();
|
|
8
|
+
expect(typeof gate.validate).toBe("function");
|
|
9
|
+
expect(typeof gate.ok).toBe("function");
|
|
10
|
+
expect(typeof gate.fail).toBe("function");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("respond", () => {
|
|
14
|
+
test("ok returns success response", () => {
|
|
15
|
+
const res = ok({ id: 1, name: "Alice" });
|
|
16
|
+
expect(res).toEqual({ success: true, data: { id: 1, name: "Alice" } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("fail returns error response", () => {
|
|
20
|
+
const res = fail("Not found", "NOT_FOUND");
|
|
21
|
+
expect(res).toEqual({
|
|
22
|
+
success: false,
|
|
23
|
+
error: { message: "Not found", code: "NOT_FOUND", details: undefined },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("paginated returns paginated response", () => {
|
|
28
|
+
const res = paginated([{ id: 1 }], 25, 1, 10);
|
|
29
|
+
expect(res.success).toBe(true);
|
|
30
|
+
expect(res.pagination).toEqual({ total: 25, page: 1, limit: 10, pages: 3 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("problem returns problem-details response", () => {
|
|
34
|
+
const res = problem({
|
|
35
|
+
type: "https://errors.remba.com/rate-limit",
|
|
36
|
+
title: "Rate Limit Exceeded",
|
|
37
|
+
status: 429,
|
|
38
|
+
detail: "Too many requests, please retry later",
|
|
39
|
+
});
|
|
40
|
+
expect(res.success).toBe(false);
|
|
41
|
+
expect(res.problem.title).toBe("Rate Limit Exceeded");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("validate", () => {
|
|
46
|
+
test("returns success for valid input", () => {
|
|
47
|
+
const schema = z.object({ name: z.string() });
|
|
48
|
+
const result = validateRequest({ body: schema }, { body: { name: "Alice" } });
|
|
49
|
+
expect(result.success).toBe(true);
|
|
50
|
+
if (result.success) {
|
|
51
|
+
expect(result.data).toEqual({ body: { name: "Alice" } });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns errors for invalid input", () => {
|
|
56
|
+
const schema = z.object({ name: z.string().min(1) });
|
|
57
|
+
const result = validateRequest({ body: schema }, { body: { name: "" } });
|
|
58
|
+
expect(result.success).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("validates query params", () => {
|
|
62
|
+
const schema = z.object({ page: z.coerce.number().int().positive() });
|
|
63
|
+
const result = validateRequest({ query: schema }, { query: { page: "2" } });
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("idempotency", () => {
|
|
69
|
+
test("creates idempotency instance", () => {
|
|
70
|
+
const gate = createGate();
|
|
71
|
+
expect(gate.idempotency.store).toBeDefined();
|
|
72
|
+
expect(gate.idempotency.keyHeader).toBe("Idempotency-Key");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("stores and retrieves responses", async () => {
|
|
76
|
+
const gate = createGate({
|
|
77
|
+
idempotency: { ttl: 60000 },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const response = { status: 201, body: { id: "order-1" } };
|
|
81
|
+
await gate.idempotency.setResponse("test-key", response);
|
|
82
|
+
const cached = await gate.idempotency.getResponse("test-key");
|
|
83
|
+
expect(cached).toEqual(response);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("rate limit", () => {
|
|
88
|
+
test("allows requests within limit", async () => {
|
|
89
|
+
const gate = createGate({
|
|
90
|
+
rateLimit: { windowMs: 60000, max: 100 },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const req = new Request("http://localhost/test");
|
|
94
|
+
const result = await gate.rateLimit.check(req);
|
|
95
|
+
expect(result.allowed).toBe(true);
|
|
96
|
+
expect(result.remaining).toBe(99);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("api keys", () => {
|
|
101
|
+
test("validates correct API key", () => {
|
|
102
|
+
const gate = createGate({
|
|
103
|
+
apiKeys: [{ key: "sk-valid", scopes: ["read"] }],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = gate.apiKeys.validate("sk-valid");
|
|
107
|
+
expect(result.authenticated).toBe(true);
|
|
108
|
+
expect(result.scopes).toEqual(["read"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects invalid API key", () => {
|
|
112
|
+
const gate = createGate({ apiKeys: [{ key: "sk-valid" }] });
|
|
113
|
+
const result = gate.apiKeys.validate("sk-invalid");
|
|
114
|
+
expect(result.authenticated).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { validateRequest } from "./validate";
|
|
2
|
+
import { ok, fail, paginated, problem } from "./respond";
|
|
3
|
+
import { idempotency, InMemoryStore } from "./idempotency";
|
|
4
|
+
import { rateLimit, InMemoryRateLimitStore } from "./rate-limit";
|
|
5
|
+
import { createApiKeyValidator } from "./api-keys";
|
|
6
|
+
import type { ApiKeyEntry } from "./api-keys";
|
|
7
|
+
import type { IdempotencyStore } from "./idempotency";
|
|
8
|
+
import {
|
|
9
|
+
GateError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
IdempotencyError,
|
|
14
|
+
} from "./errors";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
validateRequest,
|
|
18
|
+
ok,
|
|
19
|
+
fail,
|
|
20
|
+
paginated,
|
|
21
|
+
problem,
|
|
22
|
+
idempotency,
|
|
23
|
+
InMemoryStore,
|
|
24
|
+
rateLimit,
|
|
25
|
+
InMemoryRateLimitStore,
|
|
26
|
+
createApiKeyValidator,
|
|
27
|
+
GateError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
IdempotencyError,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type { ValidationSchemas, ValidationResult, ValidatedRequest } from "./validate";
|
|
35
|
+
export type {
|
|
36
|
+
SuccessResponse,
|
|
37
|
+
ErrorResponse,
|
|
38
|
+
ErrorPayload,
|
|
39
|
+
PaginatedResponse,
|
|
40
|
+
ProblemDetails,
|
|
41
|
+
StructuredResponse,
|
|
42
|
+
} from "./respond";
|
|
43
|
+
export type { IdempotencyStore, IdempotencyOptions, IdempotencyInstance } from "./idempotency";
|
|
44
|
+
export type {
|
|
45
|
+
RateLimitStore,
|
|
46
|
+
RateLimitOptions,
|
|
47
|
+
RateLimitStrategy,
|
|
48
|
+
RateLimitInstance,
|
|
49
|
+
} from "./rate-limit";
|
|
50
|
+
export type {
|
|
51
|
+
ApiKeyEntry,
|
|
52
|
+
AuthenticateOptions,
|
|
53
|
+
AuthenticateResult,
|
|
54
|
+
ApiKeyValidator,
|
|
55
|
+
} from "./api-keys";
|
|
56
|
+
|
|
57
|
+
export type Middleware = (req: Request, next?: () => Promise<Response>) => Promise<Response | null>;
|
|
58
|
+
|
|
59
|
+
export interface GateOptions {
|
|
60
|
+
apiKeys?: ApiKeyEntry[];
|
|
61
|
+
idempotency?: {
|
|
62
|
+
store?: IdempotencyStore;
|
|
63
|
+
keyHeader?: string;
|
|
64
|
+
ttl?: number;
|
|
65
|
+
};
|
|
66
|
+
rateLimit?: {
|
|
67
|
+
windowMs?: number;
|
|
68
|
+
max?: number;
|
|
69
|
+
strategy?: "fixed" | "sliding";
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Gate {
|
|
74
|
+
validate: typeof validateRequest;
|
|
75
|
+
ok: typeof ok;
|
|
76
|
+
fail: typeof fail;
|
|
77
|
+
paginated: typeof paginated;
|
|
78
|
+
problem: typeof problem;
|
|
79
|
+
idempotency: ReturnType<typeof idempotency>;
|
|
80
|
+
rateLimit: ReturnType<typeof rateLimit>;
|
|
81
|
+
apiKeys: ReturnType<typeof createApiKeyValidator>;
|
|
82
|
+
middleware(): Middleware;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createGate(options: GateOptions = {}): Gate {
|
|
86
|
+
const idempInstance = idempotency({
|
|
87
|
+
store: options.idempotency?.store ?? new InMemoryStore(),
|
|
88
|
+
keyHeader: options.idempotency?.keyHeader,
|
|
89
|
+
ttl: options.idempotency?.ttl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rlInstance = rateLimit({
|
|
93
|
+
windowMs: options.rateLimit?.windowMs,
|
|
94
|
+
max: options.rateLimit?.max,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const apiKeyValidator = createApiKeyValidator(options.apiKeys ?? []);
|
|
98
|
+
|
|
99
|
+
const gate: Gate = {
|
|
100
|
+
validate: validateRequest,
|
|
101
|
+
ok,
|
|
102
|
+
fail,
|
|
103
|
+
paginated,
|
|
104
|
+
problem,
|
|
105
|
+
idempotency: idempInstance,
|
|
106
|
+
rateLimit: rlInstance,
|
|
107
|
+
apiKeys: apiKeyValidator,
|
|
108
|
+
|
|
109
|
+
middleware() {
|
|
110
|
+
return async (req: Request, next?: () => Promise<Response>) => {
|
|
111
|
+
if (!next) return null;
|
|
112
|
+
return next();
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return gate;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default createGate;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface RateLimitStore {
|
|
2
|
+
increment(key: string, windowMs: number): Promise<{ count: number; reset: number }>;
|
|
3
|
+
reset(key: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class InMemoryRateLimitStore implements RateLimitStore {
|
|
7
|
+
private store = new Map<string, { count: number; reset: number }>();
|
|
8
|
+
|
|
9
|
+
async increment(key: string, windowMs: number): Promise<{ count: number; reset: number }> {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const entry = this.store.get(key);
|
|
12
|
+
|
|
13
|
+
if (!entry || now > entry.reset) {
|
|
14
|
+
const reset = now + windowMs;
|
|
15
|
+
this.store.set(key, { count: 1, reset });
|
|
16
|
+
return { count: 1, reset };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
entry.count += 1;
|
|
20
|
+
return { count: entry.count, reset: entry.reset };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async reset(key: string): Promise<void> {
|
|
24
|
+
this.store.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RateLimitStrategy = "fixed" | "sliding";
|
|
29
|
+
|
|
30
|
+
export interface RateLimitOptions {
|
|
31
|
+
windowMs?: number;
|
|
32
|
+
max?: number;
|
|
33
|
+
strategy?: RateLimitStrategy;
|
|
34
|
+
store?: RateLimitStore;
|
|
35
|
+
keyFn?: (req: Request) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rateLimit(options: RateLimitOptions = {}) {
|
|
39
|
+
const windowMs = options.windowMs ?? 60_000;
|
|
40
|
+
const max = options.max ?? 100;
|
|
41
|
+
const store = options.store ?? new InMemoryRateLimitStore();
|
|
42
|
+
const keyFn =
|
|
43
|
+
options.keyFn ??
|
|
44
|
+
((req: Request) => {
|
|
45
|
+
const forwarded = req.headers.get("x-forwarded-for");
|
|
46
|
+
return forwarded ?? "global";
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
windowMs,
|
|
51
|
+
max,
|
|
52
|
+
store,
|
|
53
|
+
keyFn,
|
|
54
|
+
|
|
55
|
+
async check(req: Request): Promise<{ allowed: boolean; remaining: number; reset: number }> {
|
|
56
|
+
const key = keyFn(req);
|
|
57
|
+
const { count, reset } = await store.increment(`rl:${key}`, windowMs);
|
|
58
|
+
return {
|
|
59
|
+
allowed: count <= max,
|
|
60
|
+
remaining: Math.max(0, max - count),
|
|
61
|
+
reset,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type RateLimitInstance = ReturnType<typeof rateLimit>;
|
package/src/respond.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface SuccessResponse<T = unknown> {
|
|
2
|
+
success: true;
|
|
3
|
+
data: T;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ErrorPayload {
|
|
7
|
+
message: string;
|
|
8
|
+
code?: string;
|
|
9
|
+
details?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ErrorResponse {
|
|
13
|
+
success: false;
|
|
14
|
+
error: ErrorPayload;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PaginatedResponse<T = unknown> {
|
|
18
|
+
success: true;
|
|
19
|
+
data: T[];
|
|
20
|
+
pagination: {
|
|
21
|
+
total: number;
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
pages: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProblemDetails {
|
|
29
|
+
type: string;
|
|
30
|
+
title: string;
|
|
31
|
+
status: number;
|
|
32
|
+
detail: string;
|
|
33
|
+
instance?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type StructuredResponse<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
|
37
|
+
|
|
38
|
+
export function ok<T>(data: T): SuccessResponse<T> {
|
|
39
|
+
return { success: true, data };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fail(message: string, code?: string, details?: unknown): ErrorResponse {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
error: { message, code, details },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function paginated<T>(
|
|
50
|
+
data: T[],
|
|
51
|
+
total: number,
|
|
52
|
+
page: number,
|
|
53
|
+
limit: number
|
|
54
|
+
): PaginatedResponse<T> {
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
data,
|
|
58
|
+
pagination: {
|
|
59
|
+
total,
|
|
60
|
+
page,
|
|
61
|
+
limit,
|
|
62
|
+
pages: Math.ceil(total / limit),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function problem(detail: ProblemDetails): ErrorResponse & { problem: ProblemDetails } {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: {
|
|
71
|
+
message: detail.title,
|
|
72
|
+
code: detail.type,
|
|
73
|
+
details: detail.detail,
|
|
74
|
+
},
|
|
75
|
+
problem: detail,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export interface ValidationSchemas {
|
|
4
|
+
body?: z.ZodType;
|
|
5
|
+
query?: z.ZodType;
|
|
6
|
+
params?: z.ZodType;
|
|
7
|
+
headers?: z.ZodType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ValidationResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
data?: Record<string, unknown>;
|
|
13
|
+
errors?: Record<string, string[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValidatedRequest {
|
|
17
|
+
body?: unknown;
|
|
18
|
+
query?: unknown;
|
|
19
|
+
params?: unknown;
|
|
20
|
+
headers?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseSchema(
|
|
24
|
+
schema: z.ZodType,
|
|
25
|
+
value: unknown,
|
|
26
|
+
label: string
|
|
27
|
+
): { success: true; data: unknown } | { success: false; errors: string[] } {
|
|
28
|
+
try {
|
|
29
|
+
const data = schema.parse(value);
|
|
30
|
+
return { success: true, data };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err instanceof z.ZodError) {
|
|
33
|
+
const errors = err.issues.map((i) => `${label}.${i.path.join(".")}: ${i.message}`);
|
|
34
|
+
return { success: false, errors };
|
|
35
|
+
}
|
|
36
|
+
return { success: false, errors: [`${label}: Invalid value`] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateRequest(
|
|
41
|
+
schemas: ValidationSchemas,
|
|
42
|
+
request: { body?: unknown; query?: unknown; params?: unknown; headers?: unknown }
|
|
43
|
+
): ValidationResult {
|
|
44
|
+
const allErrors: Record<string, string[]> = {};
|
|
45
|
+
const result: Record<string, unknown> = {};
|
|
46
|
+
|
|
47
|
+
for (const [key, schema] of Object.entries(schemas)) {
|
|
48
|
+
if (!schema) continue;
|
|
49
|
+
const input = request[key as keyof typeof request];
|
|
50
|
+
const parsed = parseSchema(schema, input, key);
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
allErrors[key] = parsed.errors;
|
|
53
|
+
} else {
|
|
54
|
+
result[key] = parsed.data;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Object.keys(allErrors).length > 0) {
|
|
59
|
+
return { success: false, errors: allErrors };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { success: true, data: result };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validate(schemas: ValidationSchemas) {
|
|
66
|
+
return (req: Request) => {
|
|
67
|
+
const url = new URL(req.url);
|
|
68
|
+
|
|
69
|
+
const searchParams: Record<string, string> = {};
|
|
70
|
+
url.searchParams.forEach((v, k) => {
|
|
71
|
+
searchParams[k] = v;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = validateRequest(schemas, {
|
|
75
|
+
body: req.body,
|
|
76
|
+
query: searchParams,
|
|
77
|
+
headers: Object.fromEntries(req.headers.entries()),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
}
|