@warlock.js/core 5.2.4 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/cli/commands/generate/templates/stubs.mjs +13 -14
- package/esm/cli/commands/generate/templates/stubs.mjs.map +1 -1
- package/esm/generations/stubs.mjs +13 -25
- package/esm/generations/stubs.mjs.map +1 -1
- package/esm/http/index.d.mts +1 -1
- package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
- package/esm/http/middleware/inject-request-context.mjs +2 -21
- package/esm/http/middleware/inject-request-context.mjs.map +1 -1
- package/esm/http/request.d.mts +25 -6
- package/esm/http/request.d.mts.map +1 -1
- package/esm/http/request.mjs +48 -0
- package/esm/http/request.mjs.map +1 -1
- package/esm/http/response.d.mts +3 -3
- package/esm/http/response.d.mts.map +1 -1
- package/esm/http/types.d.mts +36 -5
- package/esm/http/types.d.mts.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/validation/plugins/file-plugin.mjs.map +1 -1
- package/esm/validation/plugins/localized-plugin.mjs +2 -2
- package/esm/validation/plugins/localized-plugin.mjs.map +1 -1
- package/esm/validation/types.d.mts +17 -4
- package/esm/validation/types.d.mts.map +1 -1
- package/llms-full.txt +109 -86
- package/llms.txt +1 -1
- package/package.json +12 -12
- package/skills/README.md +1 -1
- package/skills/create-controller/SKILL.md +9 -9
- package/skills/send-response/SKILL.md +51 -37
- package/skills/store-file/SKILL.md +8 -3
- package/skills/upload-file/SKILL.md +9 -7
- package/skills/use-app-context/SKILL.md +2 -2
- package/skills/use-localization/SKILL.md +6 -2
- package/skills/use-repository/SKILL.md +2 -2
- package/skills/use-request-locals/SKILL.md +3 -3
- package/skills/validate-input/SKILL.md +2 -2
- package/skills/warlock-conventions/SKILL.md +2 -2
- package/skills/wire-socket/SKILL.md +2 -2
- package/skills/write-middleware/SKILL.md +13 -15
|
@@ -12,7 +12,7 @@ A controller is a thin function: pull inputs from `request`, call work, return t
|
|
|
12
12
|
```ts title="src/app/<module>/controllers/<action>.controller.ts"
|
|
13
13
|
import { type RequestHandler } from "@warlock.js/core";
|
|
14
14
|
|
|
15
|
-
export const listProductsController: RequestHandler = async (request, response) => {
|
|
15
|
+
export const listProductsController: RequestHandler = async ({ request, response }) => {
|
|
16
16
|
return response.success({ products: [] });
|
|
17
17
|
};
|
|
18
18
|
```
|
|
@@ -67,10 +67,10 @@ import { type Request, type RequestHandler } from "@warlock.js/core";
|
|
|
67
67
|
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
|
|
68
68
|
import { createProductService } from "../services/create-product.service";
|
|
69
69
|
|
|
70
|
-
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
|
|
70
|
+
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
|
|
71
71
|
request,
|
|
72
72
|
response,
|
|
73
|
-
) => {
|
|
73
|
+
}) => {
|
|
74
74
|
const product = await createProductService(request.validated());
|
|
75
75
|
|
|
76
76
|
return response.successCreate({ product });
|
|
@@ -96,10 +96,10 @@ Routes behind `authMiddleware` need `request.user` typed. Project conventions ad
|
|
|
96
96
|
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
97
97
|
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
|
|
98
98
|
|
|
99
|
-
export const createProductController: GuardedRequestHandler<CreateProductSchema> = async (
|
|
99
|
+
export const createProductController: GuardedRequestHandler<CreateProductSchema> = async ({
|
|
100
100
|
request,
|
|
101
101
|
response,
|
|
102
|
-
) => {
|
|
102
|
+
}) => {
|
|
103
103
|
// request.user is typed
|
|
104
104
|
const product = await createProductService(request.validated());
|
|
105
105
|
return response.successCreate({ product });
|
|
@@ -146,7 +146,7 @@ If your controller is over ~30 lines, the work probably belongs in a service.
|
|
|
146
146
|
import { type RequestHandler } from "@warlock.js/core";
|
|
147
147
|
import { listProductsService } from "../services/list-products.service";
|
|
148
148
|
|
|
149
|
-
export const listProductsController: RequestHandler = async (request, response) => {
|
|
149
|
+
export const listProductsController: RequestHandler = async ({ request, response }) => {
|
|
150
150
|
const { data: products, pagination } = await listProductsService({
|
|
151
151
|
...request.all(),
|
|
152
152
|
organization_id: request.user.organizationId,
|
|
@@ -163,10 +163,10 @@ import { type Request, type RequestHandler } from "@warlock.js/core";
|
|
|
163
163
|
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
|
|
164
164
|
import { createProductService } from "../services/create-product.service";
|
|
165
165
|
|
|
166
|
-
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
|
|
166
|
+
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
|
|
167
167
|
request,
|
|
168
168
|
response,
|
|
169
|
-
) => {
|
|
169
|
+
}) => {
|
|
170
170
|
const product = await createProductService(request.validated());
|
|
171
171
|
|
|
172
172
|
return response.successCreate({ product });
|
|
@@ -200,7 +200,7 @@ export async function getProductService(id: string) {
|
|
|
200
200
|
import type { RequestHandler } from "@warlock.js/core";
|
|
201
201
|
import { getProductService } from "../services/get-product.service";
|
|
202
202
|
|
|
203
|
-
export const getProductController: RequestHandler = async (request, response) => {
|
|
203
|
+
export const getProductController: RequestHandler = async ({ request, response }) => {
|
|
204
204
|
const product = await getProductService(request.input("id"));
|
|
205
205
|
|
|
206
206
|
return response.success({ product });
|
|
@@ -10,9 +10,9 @@ description: 'Send HTTP responses via @warlock.js/core''s Response helpers — s
|
|
|
10
10
|
## The shape
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
import type { RequestHandler
|
|
13
|
+
import type { RequestHandler } from "@warlock.js/core";
|
|
14
14
|
|
|
15
|
-
export const myController: RequestHandler = async (request, response
|
|
15
|
+
export const myController: RequestHandler = async ({ request, response }) => {
|
|
16
16
|
// …choose a helper and return it
|
|
17
17
|
return response.success({ data: "…" });
|
|
18
18
|
};
|
|
@@ -22,11 +22,11 @@ Always `return response.<helper>(...)`. The return value drives Fastify's send.
|
|
|
22
22
|
|
|
23
23
|
## Success helpers
|
|
24
24
|
|
|
25
|
-
| Method
|
|
26
|
-
|
|
|
27
|
-
| `response.success(data?)`
|
|
28
|
-
| `response.successCreate(data)`
|
|
29
|
-
| `response.noContent()`
|
|
25
|
+
| Method | Status | When |
|
|
26
|
+
| ------------------------------ | ------ | -------------------------------- |
|
|
27
|
+
| `response.success(data?)` | 200 | normal read / update |
|
|
28
|
+
| `response.successCreate(data)` | 201 | resource created (POST) |
|
|
29
|
+
| `response.noContent()` | 204 | delete succeeded, no body needed |
|
|
30
30
|
|
|
31
31
|
```ts
|
|
32
32
|
return response.success({ products: [...] });
|
|
@@ -40,13 +40,13 @@ return response.noContent();
|
|
|
40
40
|
|
|
41
41
|
## Client-error helpers
|
|
42
42
|
|
|
43
|
-
| Method
|
|
44
|
-
|
|
|
45
|
-
| `response.badRequest(data)`
|
|
46
|
-
| `response.unauthorized(data?)`
|
|
47
|
-
| `response.forbidden(data?)`
|
|
48
|
-
| `response.notFound(data?)`
|
|
49
|
-
| `response.conflict(data?)`
|
|
43
|
+
| Method | Status | When |
|
|
44
|
+
| ------------------------------ | ------ | ------------------------------------ |
|
|
45
|
+
| `response.badRequest(data)` | 400 | malformed or invalid input |
|
|
46
|
+
| `response.unauthorized(data?)` | 401 | missing/invalid auth token |
|
|
47
|
+
| `response.forbidden(data?)` | 403 | authenticated but not allowed |
|
|
48
|
+
| `response.notFound(data?)` | 404 | record missing |
|
|
49
|
+
| `response.conflict(data?)` | 409 | uniqueness violation, state conflict |
|
|
50
50
|
|
|
51
51
|
```ts
|
|
52
52
|
return response.badRequest({ error: t("validation.invalid") });
|
|
@@ -65,8 +65,8 @@ Most error helpers accept an optional payload — if you omit it, they send a de
|
|
|
65
65
|
## Redirects
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
|
-
return response.redirect("/login");
|
|
69
|
-
return response.redirect("/new-home", 301);
|
|
68
|
+
return response.redirect("/login"); // 302
|
|
69
|
+
return response.redirect("/new-home", 301); // permanent
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
## Files
|
|
@@ -98,19 +98,19 @@ stream.end();
|
|
|
98
98
|
|
|
99
99
|
## Throwing HTTP errors
|
|
100
100
|
|
|
101
|
-
Most of the time, controllers don't need to
|
|
101
|
+
Most of the time, controllers don't need to _choose_ an error helper — they throw from the service layer instead. The request middleware (`http/middleware/inject-request-context.ts`) catches every `HttpError` subclass and produces the matching response. The error classes mirror the helpers above:
|
|
102
102
|
|
|
103
103
|
```ts
|
|
104
104
|
import {
|
|
105
|
-
ResourceNotFoundError,
|
|
106
|
-
UnAuthorizedError,
|
|
107
|
-
ForbiddenError,
|
|
108
|
-
BadRequestError,
|
|
109
|
-
ConflictError,
|
|
110
|
-
NotAcceptableError,
|
|
111
|
-
NotAllowedError,
|
|
112
|
-
ServerError,
|
|
113
|
-
HttpError,
|
|
105
|
+
ResourceNotFoundError, // 404
|
|
106
|
+
UnAuthorizedError, // 401
|
|
107
|
+
ForbiddenError, // 403
|
|
108
|
+
BadRequestError, // 400
|
|
109
|
+
ConflictError, // 409
|
|
110
|
+
NotAcceptableError, // 406
|
|
111
|
+
NotAllowedError, // 405
|
|
112
|
+
ServerError, // 500
|
|
113
|
+
HttpError, // base class — `new HttpError(status, message, payload?)` for arbitrary codes
|
|
114
114
|
} from "@warlock.js/core";
|
|
115
115
|
|
|
116
116
|
throw new ResourceNotFoundError("product.notFound");
|
|
@@ -118,13 +118,27 @@ throw new ForbiddenError("permission.denied", { resource: "product", id });
|
|
|
118
118
|
throw new ConflictError("user.duplicateEmail");
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
Each class takes `(message, payload?)`. The
|
|
121
|
+
Each class takes `(message, payload?)`. The framework keeps the optional
|
|
122
|
+
detail nested under `payload`:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"error": "Product not found",
|
|
127
|
+
"payload": { "id": 42 }
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Every unhandled error response also carries
|
|
132
|
+
`Cache-Control: private, no-store`, regardless of status. The floor is
|
|
133
|
+
applied once at the shared error funnel, so API-route errors cannot be stored
|
|
134
|
+
and replayed across users. In development mode, the stack trace is included
|
|
135
|
+
too.
|
|
122
136
|
|
|
123
137
|
Pick the class, throw from the service or use-case, and forget about response shaping at the call site. The controller stays focused on the success path:
|
|
124
138
|
|
|
125
139
|
```ts
|
|
126
|
-
export const getProductController: RequestHandler = async (request, response) => {
|
|
127
|
-
const product = await getProductService(request.input("id"));
|
|
140
|
+
export const getProductController: RequestHandler = async ({ request, response }) => {
|
|
141
|
+
const product = await getProductService(request.input("id")); // throws ResourceNotFoundError on miss
|
|
128
142
|
return response.success({ product });
|
|
129
143
|
};
|
|
130
144
|
```
|
|
@@ -136,9 +150,9 @@ See [`create-controller`](../create-controller/SKILL.md) for the "throw from ser
|
|
|
136
150
|
```ts
|
|
137
151
|
const sse = response.sse();
|
|
138
152
|
|
|
139
|
-
sse.send("tick", { count: 1 });
|
|
140
|
-
sse.send("tick", { count: 2 }, "msg-2");
|
|
141
|
-
sse.comment("keep-alive");
|
|
153
|
+
sse.send("tick", { count: 1 }); // event name, data, optional id
|
|
154
|
+
sse.send("tick", { count: 2 }, "msg-2"); // third arg is the SSE event id
|
|
155
|
+
sse.comment("keep-alive"); // invisible to the client, prevents timeout
|
|
142
156
|
sse.end();
|
|
143
157
|
```
|
|
144
158
|
|
|
@@ -178,11 +192,11 @@ response.clearCookie("session_id");
|
|
|
178
192
|
|
|
179
193
|
Every response cookie gets `httpOnly: true`, `sameSite: "lax"`, and `secure: true` outside development, unless you override them:
|
|
180
194
|
|
|
181
|
-
| Flag
|
|
182
|
-
|
|
183
|
-
| `httpOnly` | `true`
|
|
184
|
-
| `sameSite` | `"lax"`
|
|
185
|
-
| `secure`
|
|
195
|
+
| Flag | Default | Why it's the default |
|
|
196
|
+
| ---------- | ----------------------------- | --------------------------------------------------------- |
|
|
197
|
+
| `httpOnly` | `true` | without it, any injected script can read the cookie |
|
|
198
|
+
| `sameSite` | `"lax"` | without it, the cookie rides along on cross-site requests |
|
|
199
|
+
| `secure` | `true`, except in development | without it, the cookie travels in cleartext |
|
|
186
200
|
|
|
187
201
|
`secure` is relaxed in development only — browsers drop a `Secure` cookie over plain http, which would silently break every local login. It stays on in test and staging.
|
|
188
202
|
|
|
@@ -345,12 +345,17 @@ Set `STORAGE_DRIVER=r2` in production, leave unset in dev — same code uses loc
|
|
|
345
345
|
### Uploading a request file
|
|
346
346
|
|
|
347
347
|
```ts
|
|
348
|
-
import
|
|
348
|
+
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
349
349
|
import { storage } from "@warlock.js/core";
|
|
350
350
|
|
|
351
|
-
export const uploadAvatarController:
|
|
351
|
+
export const uploadAvatarController: GuardedRequestHandler = async ({ request, response }) => {
|
|
352
352
|
const upload = request.file("avatar");
|
|
353
|
-
|
|
353
|
+
|
|
354
|
+
if (!upload) {
|
|
355
|
+
return response.badRequest({ error: "missing file" });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const file = await storage.put(upload, `avatars/${request.user.id}/${upload.name}`);
|
|
354
359
|
|
|
355
360
|
return response.successCreate({ url: file.url, hash: file.hash });
|
|
356
361
|
};
|
|
@@ -9,8 +9,8 @@ Multipart uploads come in as `UploadedFile` instances. The class wraps Fastify's
|
|
|
9
9
|
|
|
10
10
|
## The shape
|
|
11
11
|
|
|
12
|
-
```ts title="src/app/uploads/schema/
|
|
13
|
-
import { v } from "@warlock.js/seal";
|
|
12
|
+
```ts title="src/app/uploads/schema/upload-avatar.schema.ts"
|
|
13
|
+
import { v, type Infer } from "@warlock.js/seal";
|
|
14
14
|
|
|
15
15
|
export const uploadAvatarSchema = v.object({
|
|
16
16
|
avatar: v
|
|
@@ -19,16 +19,18 @@ export const uploadAvatarSchema = v.object({
|
|
|
19
19
|
.maxSize({ unit: "MB", size: 5 })
|
|
20
20
|
.mimeType(["image/jpeg", "image/png", "image/webp"]),
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
export type UploadAvatarSchema = Infer<typeof uploadAvatarSchema>;
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
```ts title="src/app/uploads/controllers/upload-avatar.controller.ts"
|
|
25
|
-
import { type GuardedRequestHandler } from "app/auth/
|
|
27
|
+
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
26
28
|
import { type UploadAvatarSchema, uploadAvatarSchema } from "../schema/upload-avatar.schema";
|
|
27
29
|
|
|
28
|
-
export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> = async (
|
|
30
|
+
export const uploadAvatarController: GuardedRequestHandler<UploadAvatarSchema> = async ({
|
|
29
31
|
request,
|
|
30
32
|
response,
|
|
31
|
-
) => {
|
|
33
|
+
}) => {
|
|
32
34
|
const { avatar } = request.validated();
|
|
33
35
|
|
|
34
36
|
const file = await avatar
|
|
@@ -54,7 +56,7 @@ Inside a controller:
|
|
|
54
56
|
```ts
|
|
55
57
|
import type { RequestHandler, UploadedFile } from "@warlock.js/core";
|
|
56
58
|
|
|
57
|
-
export const uploadController: RequestHandler = async (request, response) => {
|
|
59
|
+
export const uploadController: RequestHandler = async ({ request, response }) => {
|
|
58
60
|
// option A — direct from request, no validation
|
|
59
61
|
const file: UploadedFile | undefined = request.file("avatar");
|
|
60
62
|
|
|
@@ -236,7 +238,7 @@ export const uploadFilesSchema = v.object({
|
|
|
236
238
|
```ts title="src/app/uploads/controllers/create-upload.controller.ts"
|
|
237
239
|
import type { RequestHandler } from "@warlock.js/core";
|
|
238
240
|
|
|
239
|
-
export const createUploadController: RequestHandler = async (request, response) => {
|
|
241
|
+
export const createUploadController: RequestHandler = async ({ request, response }) => {
|
|
240
242
|
const { files } = request.validated();
|
|
241
243
|
|
|
242
244
|
const saved = await Promise.all(
|
|
@@ -232,9 +232,9 @@ const template = await readFile(appPath("mailers/templates/welcome.html"), "utf-
|
|
|
232
232
|
### Health endpoint
|
|
233
233
|
|
|
234
234
|
```ts title="src/app/system/controllers/health.controller.ts"
|
|
235
|
-
import { Application, type RequestHandler
|
|
235
|
+
import { Application, type RequestHandler } from "@warlock.js/core";
|
|
236
236
|
|
|
237
|
-
export const healthController: RequestHandler = async (
|
|
237
|
+
export const healthController: RequestHandler = async ({ response }) => {
|
|
238
238
|
return response.success({
|
|
239
239
|
status: "ok",
|
|
240
240
|
environment: Application.environment,
|
|
@@ -229,10 +229,14 @@ The error's message is locale-aware — the `inject-request-context` middleware
|
|
|
229
229
|
### Translated response message in a controller
|
|
230
230
|
|
|
231
231
|
```ts
|
|
232
|
-
|
|
232
|
+
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
233
|
+
import { type CreateProductSchema } from "../schema/create-product.schema";
|
|
234
|
+
import { createProductService } from "../services/create-product.service";
|
|
235
|
+
|
|
236
|
+
export const createProductController: GuardedRequestHandler<CreateProductSchema> = async ({
|
|
233
237
|
request,
|
|
234
238
|
response,
|
|
235
|
-
) => {
|
|
239
|
+
}) => {
|
|
236
240
|
const product = await createProductService(request.validated());
|
|
237
241
|
return response.success({
|
|
238
242
|
message: request.t("products.created"),
|
|
@@ -276,10 +276,10 @@ export async function listFaqsService(filters: FaqListOptions) {
|
|
|
276
276
|
```
|
|
277
277
|
|
|
278
278
|
```ts title="src/app/faqs/controllers/list-faqs.controller.ts"
|
|
279
|
-
import
|
|
279
|
+
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
280
280
|
import { listFaqsService } from "../services/list-faqs.service";
|
|
281
281
|
|
|
282
|
-
export const listFaqsController:
|
|
282
|
+
export const listFaqsController: GuardedRequestHandler = async ({ request, response }) => {
|
|
283
283
|
const { data, pagination } = await listFaqsService({
|
|
284
284
|
...request.all(),
|
|
285
285
|
organization_id: request.user.organizationId,
|
|
@@ -30,7 +30,7 @@ public locals: RequestLocals = {};
|
|
|
30
30
|
```ts title="src/app/observability/middleware/request-timing.middleware.ts"
|
|
31
31
|
import type { Middleware } from "@warlock.js/core";
|
|
32
32
|
|
|
33
|
-
export const requestTimingMiddleware: Middleware = request => {
|
|
33
|
+
export const requestTimingMiddleware: Middleware = (request) => {
|
|
34
34
|
request.startedAt = Date.now();
|
|
35
35
|
};
|
|
36
36
|
```
|
|
@@ -38,7 +38,7 @@ export const requestTimingMiddleware: Middleware = request => {
|
|
|
38
38
|
```ts title="src/app/observability/controllers/timing.controller.ts"
|
|
39
39
|
import type { RequestHandler } from "@warlock.js/core";
|
|
40
40
|
|
|
41
|
-
export const timingController: RequestHandler = async (request, response) => {
|
|
41
|
+
export const timingController: RequestHandler = async ({ request, response }) => {
|
|
42
42
|
return response.success({ elapsedMs: Date.now() - request.startedAt });
|
|
43
43
|
};
|
|
44
44
|
```
|
|
@@ -123,7 +123,7 @@ Feature-local files such as `src/app/organizations/request-locals.d.ts` are equa
|
|
|
123
123
|
|
|
124
124
|
Two rules the scaffold's own comments spell out, and both bite silently:
|
|
125
125
|
|
|
126
|
-
- **Keep the trailing `export {}`.** `declare module "x"` inside a file with no top-level import or export declares an
|
|
126
|
+
- **Keep the trailing `export {}`.** `declare module "x"` inside a file with no top-level import or export declares an _ambient_ module, which REPLACES `@warlock.js/core`'s real typings instead of merging into them — every framework export vanishes. The `export {}` is what makes the file a module and the block an augmentation. It is not an unused statement to clean up.
|
|
127
127
|
- **Keep them `interface`, not `type`.** This project otherwise prefers `type`; these are the named exception, because declaration merging is interface-only. `type RequestUser = { ... }` is a duplicate-identifier error, not an augmentation.
|
|
128
128
|
|
|
129
129
|
On a project scaffolded before 5.1 there is no `src/typings.d.ts`, and `tsconfig.json` carries `"typeRoots": ["./src/typings.d.ts"]` — wrong twice, since `typeRoots` takes directories of `@types` packages rather than files, and that file did not exist. Drop the `typeRoots` entry, create the file, and list it under `include`.
|
|
@@ -26,10 +26,10 @@ import type { Request, RequestHandler } from "@warlock.js/core";
|
|
|
26
26
|
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
|
|
27
27
|
import { createProductService } from "../services/create-product.service";
|
|
28
28
|
|
|
29
|
-
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async (
|
|
29
|
+
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
|
|
30
30
|
request,
|
|
31
31
|
response,
|
|
32
|
-
) => {
|
|
32
|
+
}) => {
|
|
33
33
|
const product = await createProductService(request.validated());
|
|
34
34
|
|
|
35
35
|
return response.successCreate({ product });
|
|
@@ -38,11 +38,11 @@ This skill is the foundation. Every other warlock skill (`register-route`, `crea
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
```ts title="src/app/<module>/controllers/create-<thing>.controller.ts"
|
|
41
|
-
import { type GuardedRequestHandler } from "app/auth/
|
|
41
|
+
import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
42
42
|
import { type CreateThingSchema, createThingSchema } from "../schema/create-thing.schema";
|
|
43
43
|
import { createThingService } from "../services/create-thing.service";
|
|
44
44
|
|
|
45
|
-
export const createThingController: GuardedRequestHandler<CreateThingSchema> = async (request, response) => {
|
|
45
|
+
export const createThingController: GuardedRequestHandler<CreateThingSchema> = async ({ request, response }) => {
|
|
46
46
|
const thing = await createThingService(request.validated());
|
|
47
47
|
return response.success({ thing });
|
|
48
48
|
};
|
|
@@ -123,10 +123,10 @@ export async function notifyUserService(user: User, payload: unknown) {
|
|
|
123
123
|
Then from a controller:
|
|
124
124
|
|
|
125
125
|
```ts
|
|
126
|
-
import type { GuardedRequestHandler } from "app/auth/
|
|
126
|
+
import type { GuardedRequestHandler } from "app/auth/requests/guarded.request";
|
|
127
127
|
import { notifyUserService } from "../services/notify-user.service";
|
|
128
128
|
|
|
129
|
-
export const sendNotificationController: GuardedRequestHandler = async (request, response) => {
|
|
129
|
+
export const sendNotificationController: GuardedRequestHandler = async ({ request, response }) => {
|
|
130
130
|
await notifyUserService(request.user, request.input("payload"));
|
|
131
131
|
return response.success({ delivered: true });
|
|
132
132
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: write-middleware
|
|
3
|
-
description: 'Author HTTP middleware for @warlock.js/core — the `(request, response)` signature, short-circuit by returning a response, enrich the request with extra fields, register per-route, per-group, or app-wide. Triggers: `Middleware`, `MiddlewareResponse`, `router.group`, `guarded`, `request.detectIp`, `authMiddleware`; "write a custom middleware", "short-circuit a request", "enrich the request with extra fields", "per-route vs per-group middleware"; typical import `import type { Middleware } from "@warlock.js/core"`. Skip: built-in middleware catalog — `@warlock.js/core/use-middleware/SKILL.md`; route attachment — `@warlock.js/core/register-route/SKILL.md`; response helpers — `@warlock.js/core/send-response/SKILL.md`; competing patterns: `express` `(req, res, next)` middleware, Fastify `preHandler` hooks.'
|
|
3
|
+
description: 'Author HTTP middleware for @warlock.js/core — the `({ request, response })` signature, short-circuit by returning a response, enrich the request with extra fields, register per-route, per-group, or app-wide. Triggers: `Middleware`, `MiddlewareResponse`, `router.group`, `guarded`, `request.detectIp`, `authMiddleware`; "write a custom middleware", "short-circuit a request", "enrich the request with extra fields", "per-route vs per-group middleware"; typical import `import type { Middleware } from "@warlock.js/core"`. Skip: built-in middleware catalog — `@warlock.js/core/use-middleware/SKILL.md`; route attachment — `@warlock.js/core/register-route/SKILL.md`; response helpers — `@warlock.js/core/send-response/SKILL.md`; competing patterns: `express` `(req, res, next)` middleware, Fastify `preHandler` hooks.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Warlock — write a middleware
|
|
@@ -12,7 +12,7 @@ Middleware is a plain function that runs before the controller. Two outcomes: re
|
|
|
12
12
|
```ts title="src/app/<module>/utils/<name>.middleware.ts"
|
|
13
13
|
import type { Middleware } from "@warlock.js/core";
|
|
14
14
|
|
|
15
|
-
export const requireApiKey: Middleware = (request, response) => {
|
|
15
|
+
export const requireApiKey: Middleware = ({ request, response }) => {
|
|
16
16
|
const key = request.header("X-API-Key");
|
|
17
17
|
|
|
18
18
|
if (!key || key !== process.env.API_KEY) {
|
|
@@ -23,16 +23,14 @@ export const requireApiKey: Middleware = (request, response) => {
|
|
|
23
23
|
};
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
That's the contract: `(
|
|
26
|
+
That's the contract: `(context: HttpContext<Request>) => Response | undefined | void`, where `context` is `{ request, response }`. Async is fine — return a `Promise<Response | undefined | void>`.
|
|
27
27
|
|
|
28
28
|
The real type, from `@warlock.js/core/src/router/types.ts`:
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
31
|
export type Middleware<MiddlewareRequest extends Request = Request> = {
|
|
32
|
-
(
|
|
32
|
+
(context: HttpContext<MiddlewareRequest>): MiddlewareResponse;
|
|
33
33
|
};
|
|
34
|
-
|
|
35
|
-
export type MiddlewareResponse = ReturnedResponse | undefined | void;
|
|
36
34
|
```
|
|
37
35
|
|
|
38
36
|
## Short-circuit vs continue
|
|
@@ -42,7 +40,7 @@ The pattern is "return a response to stop, return nothing to continue":
|
|
|
42
40
|
```ts
|
|
43
41
|
import type { Middleware } from "@warlock.js/core";
|
|
44
42
|
|
|
45
|
-
export const requireFeatureFlag: Middleware = async (request, response) => {
|
|
43
|
+
export const requireFeatureFlag: Middleware = async ({ request, response }) => {
|
|
46
44
|
const flag = await loadFeatureFlag(request.input("organization_id"));
|
|
47
45
|
|
|
48
46
|
if (!flag.enabled) {
|
|
@@ -60,8 +58,8 @@ If you short-circuit, the controller never runs. The response helper you pick (`
|
|
|
60
58
|
You can attach arbitrary fields to `request` from a middleware, and they survive into the controller. The cleanest pattern is to extend `Request` via module augmentation in a `.d.ts` and assign in the middleware:
|
|
61
59
|
|
|
62
60
|
```ts title="src/app/feature-flags/middleware/load-feature-flag.middleware.ts"
|
|
63
|
-
import type { Middleware } from "@warlock.js/core";
|
|
64
|
-
import
|
|
61
|
+
import type { Middleware, Request, RequestUser } from "@warlock.js/core";
|
|
62
|
+
import { FeatureFlag } from "../models/feature-flag";
|
|
65
63
|
|
|
66
64
|
declare module "@warlock.js/core" {
|
|
67
65
|
interface Request {
|
|
@@ -69,7 +67,7 @@ declare module "@warlock.js/core" {
|
|
|
69
67
|
}
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
export const loadFeatureFlag: Middleware = async (request) => {
|
|
70
|
+
export const loadFeatureFlag: Middleware<Request & { user: RequestUser }> = async ({ request }) => {
|
|
73
71
|
request.featureFlag = await FeatureFlag.findBy("organization_id", request.user.organizationId);
|
|
74
72
|
};
|
|
75
73
|
```
|
|
@@ -139,7 +137,7 @@ export function guarded(callback: () => void) {
|
|
|
139
137
|
}
|
|
140
138
|
|
|
141
139
|
export function guardedAdmin(callback: () => void) {
|
|
142
|
-
router.group({ prefix: "/admin", middleware: [authMiddleware()] }, callback);
|
|
140
|
+
router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, callback);
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
export function publicRoutes(callback: () => void) {
|
|
@@ -157,7 +155,7 @@ guarded(() => {
|
|
|
157
155
|
});
|
|
158
156
|
```
|
|
159
157
|
|
|
160
|
-
`authMiddleware(allowedUserType?)`
|
|
158
|
+
`authMiddleware(allowedUserType, tokenFrom?)` requires a user-type string or array — `[]` accepts any authenticated user without checking `userType`; `"user"` / `"admin"` also checks the decoded `userType` matches.
|
|
161
159
|
|
|
162
160
|
## Common patterns
|
|
163
161
|
|
|
@@ -184,13 +182,13 @@ router.group(
|
|
|
184
182
|
```ts
|
|
185
183
|
import type { Middleware } from "@warlock.js/core";
|
|
186
184
|
|
|
187
|
-
export const optionalAuth: Middleware = async (request, response) => {
|
|
185
|
+
export const optionalAuth: Middleware = async ({ request, response }) => {
|
|
188
186
|
if (!request.authorizationValue) {
|
|
189
187
|
return; // anonymous — let it through
|
|
190
188
|
}
|
|
191
189
|
|
|
192
190
|
// token present → enforce it
|
|
193
|
-
return authMiddleware("user")(request, response);
|
|
191
|
+
return authMiddleware("user")({ request, response });
|
|
194
192
|
};
|
|
195
193
|
```
|
|
196
194
|
|
|
@@ -206,7 +204,7 @@ export const optionalAuth: Middleware = async (request, response) => {
|
|
|
206
204
|
- **Group middleware runs before per-route middleware**, in array order. The full chain is `app.all → group → per-route → controller`. Mind the order if you stack auth + rate-limit + audit.
|
|
207
205
|
- **Middleware can be async.** Returning `Promise<undefined>` continues the chain. Returning `Promise<Response>` short-circuits. The framework awaits the result.
|
|
208
206
|
- **Don't mutate `request.payload` directly.** Use `request.setValidatedData(...)` or attach a new named field (`request.featureFlag = ...`). The internals expect `payload.all` shapes to come from the validator pipeline.
|
|
209
|
-
- **`Middleware` is generic over the request type.** For middleware that assumes a validated schema, narrow it: `const m: Middleware<CreateProductRequest> = (request) => { ... }`. But most middlewares run before validation, so the default `Middleware` is right.
|
|
207
|
+
- **`Middleware` is generic over the request type.** For middleware that assumes a validated schema, narrow it: `const m: Middleware<CreateProductRequest> = ({ request }) => { ... }`. But most middlewares run before validation, so the default `Middleware` is right.
|
|
210
208
|
- **No `next()` parameter.** Express-style `next()` doesn't apply here. The framework chains based on return value.
|
|
211
209
|
- **`request.baseRequest` / `response.baseResponse` are escape hatches, not API.** They expose the underlying Fastify primitives for cases the framework hasn't covered yet (streaming was the historical precedent). Prefer framework helpers first — `response.send()`, `response.header()`, `response.replay()`, `request.input()`, `request.detectIp()`, etc. If you find yourself reaching for `baseResponse` or `baseRequest` for non-streaming work, that's a missing helper — file an issue. The cache and idempotency middlewares both shipped with a quietly-broken FastifyReply-return bug because they bypassed the helper layer; the framework now guards `Response.send()` against double-send, but the right answer is "use the helper."
|
|
212
210
|
- **Behind any proxy, use `request.detectIp()` not `request.ip`.** `request.ip` is the immediate peer (likely your load balancer); `request.detectIp()` honors `X-Real-IP` / `X-Forwarded-For`. Either way, only trust the result as far as you trust the upstream chain — those headers are client-settable; verify the request came through your trusted edge before treating the value as authoritative.
|