@shaferllc/keel 0.66.0 → 0.68.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/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +2 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/index.d.ts +15 -10
- package/dist/core/index.js +7 -4
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +26 -7
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# Transformers
|
|
2
|
+
|
|
3
|
+
A model knows the database; a **transformer** knows the API. It's the
|
|
4
|
+
presentation layer between the two — subclass `Transformer`, define one
|
|
5
|
+
`transform()` that maps a value to the exact shape you expose, and get
|
|
6
|
+
`item` / `collection` / `document` for free. No columns leak by accident, no
|
|
7
|
+
relation triggers a surprise query, and the same shape renders everywhere.
|
|
8
|
+
Edge-safe, like everything under it — a transformer leans on nothing but the
|
|
9
|
+
value you hand it.
|
|
10
|
+
|
|
11
|
+
## Defining a transformer
|
|
12
|
+
|
|
13
|
+
Subclass `Transformer<T>` and implement `transform`. The generic is the type you
|
|
14
|
+
map *from* (often a model); the return is a plain, JSON-ready object:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Transformer, type Attributes } from "@shaferllc/keel/core";
|
|
18
|
+
import { User } from "../app/Models/User.js";
|
|
19
|
+
|
|
20
|
+
export class UserTransformer extends Transformer<User> {
|
|
21
|
+
transform(user: User): Attributes {
|
|
22
|
+
return {
|
|
23
|
+
id: user.id,
|
|
24
|
+
name: user.name,
|
|
25
|
+
joined: user.created_at,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Generate one with `keel make:transformer User` (→
|
|
32
|
+
`app/Transformers/UserTransformer.ts`). Pass `--model Account` when the class name
|
|
33
|
+
doesn't match the value it maps.
|
|
34
|
+
|
|
35
|
+
## Transforming
|
|
36
|
+
|
|
37
|
+
Three methods cover every case — one, many, or a full response document:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { json } from "@shaferllc/keel/core";
|
|
41
|
+
|
|
42
|
+
const users = new UserTransformer();
|
|
43
|
+
|
|
44
|
+
json(users.item(user)); // one → { id, name, joined } | null
|
|
45
|
+
json(users.collection(list)); // many → [{ … }, { … }]
|
|
46
|
+
json(users.document(list, { // wrapped, with meta
|
|
47
|
+
meta: { total: list.length },
|
|
48
|
+
})); // → { data: [{ … }], total }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`item` returns `null` for a nullish value, so a not-found lookup passes straight
|
|
52
|
+
through. `collection` maps each value through `transform`. `document` is what you
|
|
53
|
+
usually hand back from a controller — it wraps the payload under a key (`data` by
|
|
54
|
+
default) and merges any top-level `meta` (pagination, counts, links) beside it.
|
|
55
|
+
|
|
56
|
+
## Conditional fields
|
|
57
|
+
|
|
58
|
+
`when` includes a key only when a condition holds — and *removes the key entirely*
|
|
59
|
+
otherwise, so no `null` leaks into the payload:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
transform(user: User): Attributes {
|
|
63
|
+
return {
|
|
64
|
+
id: user.id,
|
|
65
|
+
name: user.name,
|
|
66
|
+
email: this.when(String(user.id) === this.viewerId, user.email), // only your own email
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For someone else's user, the response is simply `{ id, name }` — the `email` key
|
|
72
|
+
is gone, not `null`. Pass a third argument to substitute a fallback instead of
|
|
73
|
+
omitting, and pass a **thunk** to defer an expensive value until the condition is
|
|
74
|
+
true:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
token: this.when(fresh, () => mintToken(user), null), // null when not fresh
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To gate *several* keys at once, `mergeWhen` returns an object to spread — `{}`
|
|
81
|
+
when the condition is false, so nothing is added:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
return {
|
|
85
|
+
id: user.id,
|
|
86
|
+
...this.mergeWhen(user.admin, { role: user.role, permissions: user.permissions }),
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Transformers pass the current viewer (or any context) through the constructor —
|
|
91
|
+
they're plain instances:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
export class UserTransformer extends Transformer<User> {
|
|
95
|
+
constructor(private viewerId: string | null) {
|
|
96
|
+
super();
|
|
97
|
+
}
|
|
98
|
+
// …use this.viewerId in transform()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
json(new UserTransformer(auth().id()).collection(users));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Nesting & relations
|
|
105
|
+
|
|
106
|
+
Embed one transformer inside another by calling it inline — the seam composes:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
transform(post: Post): Attributes {
|
|
110
|
+
return {
|
|
111
|
+
id: post.id,
|
|
112
|
+
title: post.title,
|
|
113
|
+
author: new UserTransformer(this.viewerId).item(post.author),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
But for a [model](./models.md) relation, reach for `whenLoaded` — it includes the
|
|
119
|
+
relation **only if it was eager-loaded**, so a transformer never fires a query
|
|
120
|
+
behind your back:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
transform(user: User): Attributes {
|
|
124
|
+
return {
|
|
125
|
+
id: user.id,
|
|
126
|
+
name: user.name,
|
|
127
|
+
posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`whenLoaded` reads the relation off the model (via the model's `getRelation`, set
|
|
133
|
+
by [`Model.load`](./models.md)), and, if present, runs it through the transformer
|
|
134
|
+
you pass — a `collection` for an array relation, an `item` for a single one. If
|
|
135
|
+
the relation wasn't loaded, the key is omitted. Pass a plain function instead of a
|
|
136
|
+
transformer to map it yourself:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
roles: this.whenLoaded(user, "roles", (roles) => roles.map((r) => r.name)),
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
So the caller controls depth by choosing what to load:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const users = await User.all();
|
|
146
|
+
await User.load(users, "posts"); // eager-load first
|
|
147
|
+
json(new UserTransformer().collection(users)); // …then posts appear
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Without the `load`, the same transformer simply omits `posts` — no N+1, no
|
|
151
|
+
surprise. See [Models](./models.md#eager-loading) for eager loading.
|
|
152
|
+
|
|
153
|
+
## Response documents
|
|
154
|
+
|
|
155
|
+
`document` builds the envelope most JSON APIs return — a wrapped payload plus
|
|
156
|
+
top-level metadata:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const page = await User.all();
|
|
160
|
+
return json(
|
|
161
|
+
new UserTransformer().document(page, {
|
|
162
|
+
meta: { total: page.length, page: 1 },
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
// { "data": [ … ], "total": 42, "page": 1 }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Change the wrapper per class by setting `wrapKey`, or per call with the `key`
|
|
169
|
+
option; set `key: null` to merge a single object's fields to the top level (meta
|
|
170
|
+
included):
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
class UserTransformer extends Transformer<User> {
|
|
174
|
+
wrapKey = "user"; // → { user: { … } }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
new UserTransformer().document(user, { key: null, meta: { fetchedAt } });
|
|
178
|
+
// { id, name, …, fetchedAt }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`item` and `collection` return the **bare** shape (no wrapper) so they compose
|
|
182
|
+
cleanly when nested; `document` is the one that wraps. Reach for `document` at the
|
|
183
|
+
edge of a response and `item`/`collection` everywhere inside.
|
|
184
|
+
|
|
185
|
+
## In a controller
|
|
186
|
+
|
|
187
|
+
The whole point is a controller that reads clean:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
export class UserController {
|
|
191
|
+
async show(c: Ctx) {
|
|
192
|
+
const user = await User.findOrFail(c.req.param("id"));
|
|
193
|
+
return c.json(new UserTransformer(auth().id()).item(user));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async index(c: Ctx) {
|
|
197
|
+
const users = await User.all();
|
|
198
|
+
await User.load(users, "posts");
|
|
199
|
+
return c.json(new UserTransformer(auth().id()).document(users));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Related
|
|
205
|
+
|
|
206
|
+
Transformers sit downstream of [Models](./models.md) — they shape what a model
|
|
207
|
+
exposes without the model knowing about the API. They pair with the
|
|
208
|
+
[request/response](./request-response.md) helpers (`json`) at the edge, and with
|
|
209
|
+
[authentication](./authentication.md) when a field depends on the viewer.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## API reference
|
|
214
|
+
|
|
215
|
+
### `Transformer<T>`
|
|
216
|
+
|
|
217
|
+
The abstract base. Subclass it, set the generic to the value you map *from*, and
|
|
218
|
+
implement `transform`. Instances are plain — pass request context (a viewer id, a
|
|
219
|
+
locale) through the constructor.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
class UserTransformer extends Transformer<User> {
|
|
223
|
+
transform(user: User): Attributes {
|
|
224
|
+
return { id: user.id, name: user.name };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### `transform(item)`
|
|
230
|
+
|
|
231
|
+
`abstract transform(item: T): Attributes`
|
|
232
|
+
|
|
233
|
+
Maps one value to its API shape — the only method a subclass must implement.
|
|
234
|
+
Returns a plain object; use the helpers below to add fields conditionally.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
transform(user: User): Attributes {
|
|
238
|
+
return { id: user.id, name: user.name };
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Notes:** called once per value by `item`/`collection`. Its result is *pruned*
|
|
243
|
+
(any `when`/`whenLoaded`-omitted keys are stripped, recursively) before you see
|
|
244
|
+
it, so an omitted key is truly absent — not `undefined`.
|
|
245
|
+
|
|
246
|
+
#### `item(value)`
|
|
247
|
+
|
|
248
|
+
`item(value: T | null | undefined): Attributes | null`
|
|
249
|
+
|
|
250
|
+
Transforms a single value. A nullish value passes straight through as `null`.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
new UserTransformer().item(user); // { id, name }
|
|
254
|
+
new UserTransformer().item(null); // null
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Notes:** returns the **bare** shape (no `wrapKey` wrapper) — wrap with
|
|
258
|
+
`document` when returning a response. `null` in, `null` out, so a `findOrNull`
|
|
259
|
+
result needs no guard.
|
|
260
|
+
|
|
261
|
+
#### `collection(values)`
|
|
262
|
+
|
|
263
|
+
`collection(values: T[]): Attributes[]`
|
|
264
|
+
|
|
265
|
+
Transforms an array, each value through `transform`.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
new UserTransformer().collection(await User.all());
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Notes:** returns a bare array (no wrapper). Empty in, empty out. Combine with
|
|
272
|
+
`Model.load` beforehand so any `whenLoaded` relations are present.
|
|
273
|
+
|
|
274
|
+
#### `document(value, options?)`
|
|
275
|
+
|
|
276
|
+
`document(value: T | T[] | null | undefined, options?: DocumentOptions): Attributes`
|
|
277
|
+
|
|
278
|
+
Builds a response document: the transformed payload wrapped under a key, with
|
|
279
|
+
optional top-level `meta`. An array becomes a list; anything else a single object.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
new UserTransformer().document(users, { meta: { total: users.length } });
|
|
283
|
+
// { data: [ … ], total }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Notes:** the wrapper key is `options.key` if given, else the instance `wrapKey`
|
|
287
|
+
(default `"data"`). With `key: null` a single object's fields merge to the top
|
|
288
|
+
level alongside `meta`; an array with no key still gets a `data` home (meta can't
|
|
289
|
+
share a level with a bare array).
|
|
290
|
+
|
|
291
|
+
#### `wrapKey`
|
|
292
|
+
|
|
293
|
+
`wrapKey: string | null`
|
|
294
|
+
|
|
295
|
+
The key `document` wraps under by default. Override per subclass; `null` disables
|
|
296
|
+
wrapping.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
class UserTransformer extends Transformer<User> {
|
|
300
|
+
wrapKey = "user";
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Notes:** defaults to `"data"`. Only consulted by `document` — `item` and
|
|
305
|
+
`collection` never wrap.
|
|
306
|
+
|
|
307
|
+
#### `when(condition, value, fallback?)`
|
|
308
|
+
|
|
309
|
+
`protected when<V>(condition: unknown, value: V | (() => V), fallback?: V): V`
|
|
310
|
+
|
|
311
|
+
Include `value` when `condition` is truthy; otherwise **omit the key** — or use
|
|
312
|
+
`fallback` if you pass one. `value` may be a thunk, evaluated only when the
|
|
313
|
+
condition holds.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
email: this.when(isSelf, user.email), // key vanishes for others
|
|
317
|
+
token: this.when(fresh, () => mint(), null), // null fallback, lazy value
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Notes:** a helper for use inside `transform`. With no `fallback`, a false
|
|
321
|
+
condition removes the key entirely (via a sentinel that pruning strips) rather
|
|
322
|
+
than emitting `null`. The thunk form defers work you don't want to pay for when
|
|
323
|
+
the field is hidden.
|
|
324
|
+
|
|
325
|
+
#### `mergeWhen(condition, values)`
|
|
326
|
+
|
|
327
|
+
`protected mergeWhen(condition: unknown, values: Attributes | (() => Attributes)): Attributes`
|
|
328
|
+
|
|
329
|
+
The merge counterpart to `when` — returns `values` (spread several keys in) when
|
|
330
|
+
`condition` holds, or `{}` when it doesn't.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
return { id: u.id, ...this.mergeWhen(u.admin, { role: u.role, flags: u.flags }) };
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Notes:** meant to be spread (`...`). `values` may be a thunk, deferred until the
|
|
337
|
+
condition is true. Use it when a *group* of fields appears together.
|
|
338
|
+
|
|
339
|
+
#### `whenLoaded(model, name, map?)`
|
|
340
|
+
|
|
341
|
+
`protected whenLoaded<V>(model: unknown, name: string, map?: Transformer | ((value) => unknown)): V`
|
|
342
|
+
|
|
343
|
+
Include a relation only if it was already loaded — **never fires a query**. Reads
|
|
344
|
+
the relation off the model and, if present, runs it through `map` (a transformer
|
|
345
|
+
or a function). Omits the key when it isn't loaded.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
posts: this.whenLoaded(user, "posts", new PostTransformer()),
|
|
349
|
+
roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Notes:** resolves the relation via the model's `getRelation` (set by
|
|
353
|
+
`Model.load`) or a plain loaded property — a relation *method* is never mistaken
|
|
354
|
+
for a value. With a `Transformer`, an array relation goes through `collection` and
|
|
355
|
+
a single one through `item`. With no `map`, the raw loaded value is used.
|
|
356
|
+
|
|
357
|
+
### `Attributes`
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
type Attributes = Record<string, unknown>;
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The shape a transformer produces — a plain, JSON-ready object. `transform` returns
|
|
364
|
+
one; so do `item` and `document`.
|
|
365
|
+
|
|
366
|
+
### `DocumentOptions`
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
interface DocumentOptions {
|
|
370
|
+
key?: string | null; // wrap under this key; null disables. Defaults to wrapKey.
|
|
371
|
+
meta?: Attributes; // top-level fields merged beside the payload.
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Controls `document`'s envelope. `key` overrides the instance `wrapKey` for one
|
|
376
|
+
call; `meta` supplies pagination, counts, or links at the top level.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
new UserTransformer().document(users, { key: "records", meta: { total: 42 } });
|
|
380
|
+
// { records: [ … ], total: 42 }
|
|
381
|
+
```
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# URL Builder
|
|
2
|
+
|
|
3
|
+
Generate URLs from **named routes** so paths live in one place. Name a route,
|
|
4
|
+
then build its URL by name — with params and query strings — and never hardcode
|
|
5
|
+
a path again.
|
|
6
|
+
|
|
7
|
+
The URL builder lives on the `Router`. In an app you resolve it from the
|
|
8
|
+
container (`app.make(Router)`); the examples below assume a `router` in scope.
|
|
9
|
+
|
|
10
|
+
## Building URLs
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
router.get("/users/:id", [UserController, "show"]).name("users.show");
|
|
14
|
+
|
|
15
|
+
router.url("users.show", { id: 42 });
|
|
16
|
+
// "/users/42"
|
|
17
|
+
|
|
18
|
+
router.url("users.show", { id: 42 }, { qs: { tab: "posts", page: 2 } });
|
|
19
|
+
// "/users/42?tab=posts&page=2"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Params are matched by name against the `:param` segments in the route path, and
|
|
23
|
+
each value is `encodeURIComponent`-escaped, so slashes and spaces are safe:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
router.get("/files/:name", [FileController]).name("files.show");
|
|
27
|
+
router.url("files.show", { name: "a/b c.txt" });
|
|
28
|
+
// "/files/a%2Fb%20c.txt"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Query values are coerced to strings (numbers become their decimal form), so you
|
|
32
|
+
can pass `{ page: 2 }` and get `page=2`. An empty `qs` (`{}`) adds no `?`.
|
|
33
|
+
|
|
34
|
+
### Optional params
|
|
35
|
+
|
|
36
|
+
A trailing `:param?` segment is dropped when you don't pass it:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
router.get("/posts/:id?", [PostController, "show"]).name("posts.show");
|
|
40
|
+
|
|
41
|
+
router.url("posts.show", { id: 7 }); // "/posts/7"
|
|
42
|
+
router.url("posts.show", {}); // "/posts" (optional segment stripped)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Any required `:param` you forget to supply is stripped too — so a missing param
|
|
46
|
+
silently produces a shorter path rather than throwing. Pass every required param.
|
|
47
|
+
|
|
48
|
+
### Errors
|
|
49
|
+
|
|
50
|
+
`url()` throws `No route named [name].` if no registered route carries that name.
|
|
51
|
+
Names come from `.name()` / `.as()`, so name a route before you build its URL.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
router.url("nope"); // throws: No route named [nope].
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Signed URLs
|
|
58
|
+
|
|
59
|
+
A signed URL carries a tamper-proof signature — perfect for one-off links
|
|
60
|
+
(email confirmations, unsubscribe, downloads) where you want to trust the
|
|
61
|
+
parameters without a database lookup. Signing uses `config('app.key')`, so set
|
|
62
|
+
an `APP_KEY`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// generate (async — uses Web Crypto, works on Node and the edge)
|
|
66
|
+
const url = await router.signedUrl("download", { id: 7 });
|
|
67
|
+
const expiring = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`signedUrl` builds the URL exactly like `url()`, appends any `qs` you pass, adds
|
|
71
|
+
an `expires` timestamp when `expiresIn` is set, then HMAC-SHA256 signs the whole
|
|
72
|
+
path-plus-query with the app key and appends a `signature` parameter. The result
|
|
73
|
+
looks like:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
/download/7?expires=1710000000&signature=8f3c…
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Verify the incoming request in your handler or a middleware:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
show() {
|
|
83
|
+
if (!(await router.hasValidSignature())) {
|
|
84
|
+
return response.abort("Invalid or expired link", 403);
|
|
85
|
+
}
|
|
86
|
+
// …trusted params
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`hasValidSignature()` reads the current request, strips the `signature`
|
|
91
|
+
parameter, re-signs the remaining path and query with the app key, and compares.
|
|
92
|
+
It returns `false` if the signature is missing, the URL was tampered with, or an
|
|
93
|
+
`expires` timestamp has passed.
|
|
94
|
+
|
|
95
|
+
Both `signedUrl()` and `hasValidSignature()` throw
|
|
96
|
+
`Signed URLs require config('app.key').` when no app key is configured — set
|
|
97
|
+
`APP_KEY` before you use either.
|
|
98
|
+
|
|
99
|
+
## Parameter constraints & matchers
|
|
100
|
+
|
|
101
|
+
Route params can be constrained with a regex so a URL only matches when the
|
|
102
|
+
segment fits. A constraint is a `Matcher` — a `RegExp`, a raw source string, or
|
|
103
|
+
a `{ match: RegExp }` object — passed to `.where()`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
router.get("/users/:id", [UserController]).where("id", /\d+/);
|
|
107
|
+
router.get("/p/:slug", [PostController]).where("slug", { match: /[a-z0-9-]+/ });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `matchers` export bundles the common patterns so you don't rewrite them:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { matchers } from "@shaferllc/keel/core";
|
|
114
|
+
|
|
115
|
+
router.get("/users/:id", [UserController]).where("id", matchers.number());
|
|
116
|
+
router.get("/t/:id", [TeamController]).where("id", matchers.uuid());
|
|
117
|
+
router.get("/p/:slug", [PostController]).where("slug", matchers.slug());
|
|
118
|
+
router.get("/c/:code", [CodeController]).where("code", matchers.alpha());
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The same helpers hang off the router instance as `router.matchers`, so you can
|
|
122
|
+
reach them without a separate import. `matchers` is not part of URL *generation*
|
|
123
|
+
— it shapes which URLs a route will *match* — but the two work together: build a
|
|
124
|
+
URL with `url()` and it will satisfy the constraint if your params are the right
|
|
125
|
+
shape.
|
|
126
|
+
|
|
127
|
+
## Notes
|
|
128
|
+
|
|
129
|
+
- Signatures cover the path **and** query string, so changing any parameter
|
|
130
|
+
invalidates the link.
|
|
131
|
+
- The signing key must be stable and secret. Set `APP_KEY` to a long random
|
|
132
|
+
value (and keep it out of source control).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## API reference
|
|
137
|
+
|
|
138
|
+
### Router (URL methods)
|
|
139
|
+
|
|
140
|
+
You get the `Router` from the container (`app.make(Router)`); in tests it's
|
|
141
|
+
constructed directly as `new Router(container)`. These three methods make up the
|
|
142
|
+
URL-building surface.
|
|
143
|
+
|
|
144
|
+
#### `url(name, params?, options?)`
|
|
145
|
+
|
|
146
|
+
`url(name: string, params?: Record<string, string | number>, options?: UrlOptions): string`
|
|
147
|
+
|
|
148
|
+
Builds the path for a named route, substituting `:params` and appending an
|
|
149
|
+
optional query string.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
router.url("users.show", { id: 42 }, { qs: { tab: "posts" } });
|
|
153
|
+
// "/users/42?tab=posts"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Notes:** `params` defaults to `{}`, `options` to `{}`. Values are
|
|
157
|
+
`encodeURIComponent`-escaped; query values are stringified. Optional (`:id?`) and
|
|
158
|
+
any unsupplied required params are stripped from the path. Throws
|
|
159
|
+
`No route named [name].` if the name is unknown. Each `:param` is replaced once,
|
|
160
|
+
so a param that appears twice in a single path only substitutes its first
|
|
161
|
+
occurrence — avoid repeating a param name in one route.
|
|
162
|
+
|
|
163
|
+
#### `signedUrl(name, params?, options?)`
|
|
164
|
+
|
|
165
|
+
`signedUrl(name: string, params?: Record<string, string | number>, options?: SignedUrlOptions): Promise<string>`
|
|
166
|
+
|
|
167
|
+
Like `url()`, but HMAC-SHA256 signs the path-plus-query with `config('app.key')`
|
|
168
|
+
and appends a `signature` parameter, yielding a tamper-proof link.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const link = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 });
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Notes:** async (Web Crypto — Node and edge). `expiresIn` is seconds from now;
|
|
175
|
+
it adds an `expires` unix-second timestamp that is covered by the signature.
|
|
176
|
+
Throws `Signed URLs require config('app.key').` if no app key is set. Reserve the
|
|
177
|
+
`signature` and `expires` query keys — passing them via `options.qs` collides
|
|
178
|
+
with the ones this method adds.
|
|
179
|
+
|
|
180
|
+
#### `hasValidSignature()`
|
|
181
|
+
|
|
182
|
+
`hasValidSignature(): Promise<boolean>`
|
|
183
|
+
|
|
184
|
+
Verifies the signature on the current request: re-signs the path and query
|
|
185
|
+
(minus `signature`) and checks it matches, honoring any `expires` timestamp.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
if (!(await router.hasValidSignature())) {
|
|
189
|
+
return response.abort("Invalid or expired link", 403);
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Notes:** reads the ambient request, so call it inside a handler/middleware.
|
|
194
|
+
Returns `false` when the `signature` param is absent, the recomputed HMAC
|
|
195
|
+
differs, or `expires` is in the past. Throws
|
|
196
|
+
`Signed URLs require config('app.key').` if no app key is set.
|
|
197
|
+
|
|
198
|
+
### `matchers`
|
|
199
|
+
|
|
200
|
+
An object of built-in parameter-constraint patterns. Each is a zero-arg function
|
|
201
|
+
returning a fresh `RegExp`, suitable as the `Matcher` argument to `.where()`.
|
|
202
|
+
Also exposed on the router as `router.matchers`.
|
|
203
|
+
|
|
204
|
+
#### `matchers.number()`
|
|
205
|
+
|
|
206
|
+
`number(): RegExp`
|
|
207
|
+
|
|
208
|
+
Matches one or more digits — `/\d+/`.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
router.get("/users/:id", [UserController]).where("id", matchers.number());
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### `matchers.uuid()`
|
|
215
|
+
|
|
216
|
+
`uuid(): RegExp`
|
|
217
|
+
|
|
218
|
+
Matches a canonical 8-4-4-4-12 hex UUID (case-insensitive).
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
router.get("/t/:id", [TeamController]).where("id", matchers.uuid());
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### `matchers.slug()`
|
|
225
|
+
|
|
226
|
+
`slug(): RegExp`
|
|
227
|
+
|
|
228
|
+
Matches a lowercase slug — `[a-z0-9]+` groups joined by single hyphens
|
|
229
|
+
(`/[a-z0-9]+(?:-[a-z0-9]+)*/`).
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
router.get("/p/:slug", [PostController]).where("slug", matchers.slug());
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### `matchers.alpha()`
|
|
236
|
+
|
|
237
|
+
`alpha(): RegExp`
|
|
238
|
+
|
|
239
|
+
Matches one or more ASCII letters — `/[a-zA-Z]+/`.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
router.get("/c/:code", [CodeController]).where("code", matchers.alpha());
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Interfaces & types
|
|
246
|
+
|
|
247
|
+
#### `UrlOptions`
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
interface UrlOptions {
|
|
251
|
+
qs?: Record<string, string | number>;
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The options bag for `url()`. `qs` becomes the query string; each value is
|
|
256
|
+
stringified. Use it to tack a query onto a generated URL.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const opts: UrlOptions = { qs: { page: 2, tab: "posts" } };
|
|
260
|
+
router.url("users.show", { id: 1 }, opts);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### `SignedUrlOptions`
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
interface SignedUrlOptions extends UrlOptions {
|
|
267
|
+
/** Expiry in seconds from now. */
|
|
268
|
+
expiresIn?: number;
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Extends `UrlOptions` with `expiresIn` for `signedUrl()`. With `expiresIn` set,
|
|
273
|
+
the signed link stops validating after that many seconds.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const opts: SignedUrlOptions = { qs: { plan: "pro" }, expiresIn: 3600 };
|
|
277
|
+
await router.signedUrl("download", { id: 7 }, opts);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
#### `Matcher`
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
type Matcher = RegExp | string | { match: RegExp };
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A route-parameter constraint accepted by `.where()`: a `RegExp`, a raw regex
|
|
287
|
+
*source* string, or a `{ match: RegExp }` wrapper. The `matchers` helpers return
|
|
288
|
+
the `RegExp` form.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const a: Matcher = /\d+/;
|
|
292
|
+
const b: Matcher = "[0-9]+";
|
|
293
|
+
const c: Matcher = { match: /[a-z-]+/ };
|
|
294
|
+
router.get("/x/:id", [XController]).where("id", a);
|
|
295
|
+
```
|