@emeryld/rrroutes-contract 2.1.6 → 2.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Summary:
|
|
3
|
+
- Added end-to-end examples: quick start registry build/consume, cache keys, path interpolation, CRUD toggles/extras, and socket event validation.
|
|
4
|
+
- Removed references to deprecated auth flags/legacy CLI notes; focused on current exports only.
|
|
5
|
+
- Missing (future): planned socket middleware/onReceive helpers from the TODO block once implemented.
|
|
6
|
+
-->
|
|
7
|
+
|
|
1
8
|
# @emeryld/rrroutes-contract
|
|
2
9
|
|
|
3
|
-
|
|
10
|
+
Type-safe contract toolkit for RRRoutes. Ship the HTTP route DSL (`resource` + `withCrud`), registry/finalization helpers, cache-key builder used by the client package, and shared socket event contracts.
|
|
4
11
|
|
|
5
12
|
## Installation
|
|
6
13
|
|
|
@@ -10,52 +17,239 @@ pnpm add @emeryld/rrroutes-contract
|
|
|
10
17
|
npm install @emeryld/rrroutes-contract
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
`zod` ships as a dependency
|
|
20
|
+
`zod` ships as a dependency—nothing extra to install.
|
|
14
21
|
|
|
15
|
-
## Quick start
|
|
22
|
+
## Quick start (build + consume a registry)
|
|
16
23
|
|
|
17
24
|
```ts
|
|
18
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
buildCacheKey,
|
|
27
|
+
compilePath,
|
|
28
|
+
finalize,
|
|
29
|
+
InferOutput,
|
|
30
|
+
InferParams,
|
|
31
|
+
InferQuery,
|
|
32
|
+
resource,
|
|
33
|
+
} from '@emeryld/rrroutes-contract';
|
|
19
34
|
import { z } from 'zod';
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
// 1) Describe your API
|
|
37
|
+
const leaves = resource('/v1')
|
|
22
38
|
.sub('users', (users) =>
|
|
23
39
|
users
|
|
24
40
|
.get({
|
|
25
|
-
querySchema: z.object({
|
|
41
|
+
querySchema: z.object({
|
|
42
|
+
search: z.string().optional(),
|
|
43
|
+
limit: z.coerce.number().min(1).max(50).default(20),
|
|
44
|
+
}),
|
|
26
45
|
outputSchema: z.array(z.object({ id: z.string().uuid(), email: z.string().email() })),
|
|
46
|
+
description: 'Find users',
|
|
27
47
|
})
|
|
28
48
|
.routeParameter('userId', z.string().uuid(), (user) =>
|
|
29
|
-
user.
|
|
30
|
-
|
|
49
|
+
user.patch({
|
|
50
|
+
bodySchema: z.object({ name: z.string().min(1) }),
|
|
51
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
31
52
|
}),
|
|
32
53
|
)
|
|
33
54
|
.done(),
|
|
34
55
|
)
|
|
35
56
|
.done();
|
|
36
57
|
|
|
37
|
-
|
|
58
|
+
// 2) Freeze it into a registry for typed lookups
|
|
59
|
+
export const registry = finalize(leaves);
|
|
60
|
+
|
|
61
|
+
// 3) Consume a leaf with full types
|
|
62
|
+
const leaf = registry.byKey['PATCH /v1/users/:userId'];
|
|
63
|
+
type Params = InferParams<typeof leaf>; // { userId: string }
|
|
64
|
+
type Query = InferQuery<typeof leaf>; // never (no query)
|
|
65
|
+
type Output = InferOutput<typeof leaf>; // { ok: true }
|
|
66
|
+
|
|
67
|
+
// 4) Build URLs + cache keys (React Query friendly)
|
|
68
|
+
const url = compilePath(leaf.path, { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' });
|
|
69
|
+
const key = buildCacheKey({ leaf, params: { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' } });
|
|
70
|
+
// key => ['patch', 'v1', 'users', 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2', { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' }]
|
|
38
71
|
```
|
|
39
72
|
|
|
40
|
-
|
|
73
|
+
## Detailed usage
|
|
41
74
|
|
|
42
|
-
|
|
75
|
+
### Fluent route builder
|
|
43
76
|
|
|
44
|
-
|
|
77
|
+
```ts
|
|
78
|
+
import { resource } from '@emeryld/rrroutes-contract';
|
|
79
|
+
import { z } from 'zod';
|
|
45
80
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const leaves = resource('/api') // base path, optional inherited cfg
|
|
82
|
+
.with({ feed: false }) // merges flags into descendants (extend NodeCfg via declaration merging if you add your own)
|
|
83
|
+
.sub('projects', (projects) =>
|
|
84
|
+
projects
|
|
85
|
+
.get({
|
|
86
|
+
feed: true, // infinite/feed for clients
|
|
87
|
+
querySchema: z.object({ cursor: z.string().optional(), limit: z.coerce.number().default(25) }),
|
|
88
|
+
outputSchema: z.object({
|
|
89
|
+
items: z.array(z.object({ id: z.string(), name: z.string() })),
|
|
90
|
+
nextCursor: z.string().optional(),
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
.post({
|
|
94
|
+
bodySchema: z.object({ name: z.string().min(1) }),
|
|
95
|
+
outputSchema: z.object({ id: z.string(), name: z.string() }),
|
|
96
|
+
description: 'Create a project',
|
|
97
|
+
})
|
|
98
|
+
.routeParameter('projectId', z.string().uuid(), (project) =>
|
|
99
|
+
project
|
|
100
|
+
.get({ outputSchema: z.object({ id: z.string(), name: z.string() }) })
|
|
101
|
+
.patch({
|
|
102
|
+
bodySchema: z.object({ name: z.string().min(1) }),
|
|
103
|
+
outputSchema: z.object({ id: z.string(), name: z.string() }),
|
|
104
|
+
})
|
|
105
|
+
.sub('avatar', (avatar) =>
|
|
106
|
+
avatar
|
|
107
|
+
.put({
|
|
108
|
+
bodyFiles: [{ name: 'avatar', maxCount: 1 }], // signals multipart upload
|
|
109
|
+
bodySchema: z.object({ avatar: z.instanceof(Blob) }),
|
|
110
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
111
|
+
})
|
|
112
|
+
.done(),
|
|
113
|
+
)
|
|
114
|
+
.done(),
|
|
115
|
+
)
|
|
116
|
+
.done(),
|
|
117
|
+
)
|
|
118
|
+
.done();
|
|
50
119
|
```
|
|
51
120
|
|
|
52
|
-
|
|
121
|
+
- `sub(name, [cfg], builder?)` nests paths (`/api/projects`).
|
|
122
|
+
- `routeParameter(name, schema, builder)` creates `/:name` segments and merges param schemas downward.
|
|
123
|
+
- Methods (`get/post/put/patch/delete`) merge the active param schema unless you override via `paramsSchema`.
|
|
124
|
+
- `done()` closes a branch and returns the collected readonly tuple of leaves.
|
|
53
125
|
|
|
54
|
-
|
|
126
|
+
### Registry helpers, URL building, and typing
|
|
55
127
|
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
|
|
128
|
+
```ts
|
|
129
|
+
import {
|
|
130
|
+
buildCacheKey,
|
|
131
|
+
compilePath,
|
|
132
|
+
finalize,
|
|
133
|
+
InferBody,
|
|
134
|
+
InferOutput,
|
|
135
|
+
SubsetRoutes,
|
|
136
|
+
} from '@emeryld/rrroutes-contract';
|
|
137
|
+
|
|
138
|
+
const registry = finalize(leaves);
|
|
139
|
+
const leaf = registry.byKey['PATCH /api/projects/:projectId'];
|
|
140
|
+
|
|
141
|
+
// TypeScript helpers
|
|
142
|
+
type Body = InferBody<typeof leaf>; // { name: string }
|
|
143
|
+
type Output = InferOutput<typeof leaf>; // { id: string; name: string }
|
|
144
|
+
|
|
145
|
+
// Runtime helpers
|
|
146
|
+
const url = compilePath(leaf.path, { projectId: '123' }); // "/api/projects/123"
|
|
147
|
+
const cacheKey = buildCacheKey({ leaf, params: { projectId: '123' } });
|
|
148
|
+
|
|
149
|
+
// Typed subsets for routers/microfrontends
|
|
150
|
+
type ProjectRoutes = SubsetRoutes<typeof registry.all, '/api/projects'>;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `finalize(leaves)` freezes the tuple and provides `byKey['METHOD /path']`, `all`, and `log(logger)`.
|
|
154
|
+
- `compilePath` throws if required params are missing; wrap user-provided values in try/catch.
|
|
155
|
+
- `buildCacheKey` produces the deterministic tuple the client package uses for React Query; reuse it for manual invalidation.
|
|
156
|
+
|
|
157
|
+
### CRUD helper (`withCrud` / `resourceWithCrud`)
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { CrudDefaultPagination, finalize, resource, withCrud } from '@emeryld/rrroutes-contract';
|
|
161
|
+
import { z } from 'zod';
|
|
162
|
+
|
|
163
|
+
const r = withCrud(resource('/v1'));
|
|
164
|
+
|
|
165
|
+
const leaves = r
|
|
166
|
+
.crud(
|
|
167
|
+
'articles',
|
|
168
|
+
{
|
|
169
|
+
paramSchema: z.string().uuid(), // value schema; becomes :articlesId
|
|
170
|
+
itemOutputSchema: z.object({ id: z.string().uuid(), title: z.string(), body: z.string() }),
|
|
171
|
+
list: { querySchema: CrudDefaultPagination },
|
|
172
|
+
create: { bodySchema: z.object({ title: z.string(), body: z.string() }) },
|
|
173
|
+
update: { bodySchema: z.object({ title: z.string().optional(), body: z.string().optional() }) },
|
|
174
|
+
enable: { remove: false }, // opt out of DELETE
|
|
175
|
+
},
|
|
176
|
+
({ collection }) =>
|
|
177
|
+
collection
|
|
178
|
+
.sub('stats', (stats) =>
|
|
179
|
+
stats
|
|
180
|
+
.get({
|
|
181
|
+
outputSchema: z.object({ total: z.number() }),
|
|
182
|
+
description: 'Extra endpoint alongside CRUD',
|
|
183
|
+
})
|
|
184
|
+
.done(),
|
|
185
|
+
)
|
|
186
|
+
.done(),
|
|
187
|
+
)
|
|
188
|
+
.done();
|
|
189
|
+
|
|
190
|
+
const registry = finalize(leaves);
|
|
191
|
+
// registry.byKey now includes the CRUD + extras routes with full types
|
|
59
192
|
```
|
|
60
193
|
|
|
61
|
-
|
|
194
|
+
- Generated routes (unless disabled): GET feed list, POST create (requires `create.bodySchema`), GET item, PATCH update (requires `update.bodySchema`), DELETE remove.
|
|
195
|
+
- Defaults: list output `{ items: Item[], nextCursor?: string }`, remove output `{ ok: true }`.
|
|
196
|
+
- Pass `paramSchema` as a value schema; a compatible `z.object({ <name>Id: schema })` also works at runtime.
|
|
197
|
+
- `resourceWithCrud('/v1', {})` is a convenience wrapper if you want the `.crud` method available immediately.
|
|
198
|
+
|
|
199
|
+
### Socket event contracts
|
|
200
|
+
|
|
201
|
+
Share a typed event map between client and server.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { defineSocketEvents, Payload } from '@emeryld/rrroutes-contract';
|
|
205
|
+
import { z } from 'zod';
|
|
206
|
+
|
|
207
|
+
const { config, events } = defineSocketEvents(
|
|
208
|
+
{
|
|
209
|
+
joinMetaMessage: z.object({ room: z.string() }),
|
|
210
|
+
leaveMetaMessage: z.object({ room: z.string() }),
|
|
211
|
+
pingPayload: z.object({ clientEcho: z.object({ sentAt: z.string() }) }),
|
|
212
|
+
pongPayload: z.object({ clientEcho: z.object({ sentAt: z.string() }).optional(), sinceMs: z.number().optional() }),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
'chat:message': { message: z.object({ roomId: z.string(), text: z.string(), userId: z.string() }) },
|
|
216
|
+
'typing:update': { message: z.object({ roomId: z.string(), userId: z.string(), typing: z.boolean() }) },
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Typed payload extraction
|
|
221
|
+
type ChatPayload = Payload<typeof events, 'chat:message'>;
|
|
222
|
+
// ChatPayload -> { roomId: string; text: string; userId: string }
|
|
223
|
+
|
|
224
|
+
// Server-side guard example
|
|
225
|
+
function onChatMessage(raw: unknown) {
|
|
226
|
+
const parsed = events['chat:message'].message.parse(raw);
|
|
227
|
+
// parsed is strongly typed; safe to broadcast
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`config` mirrors the system events used by the socket client/server packages; `events` holds your app-specific payload schemas.
|
|
232
|
+
|
|
233
|
+
### Common patterns/recipes
|
|
234
|
+
|
|
235
|
+
- **Module-per-area:** export leaf tuples per domain (`usersLeaves`, `projectsLeaves`), then spread before `finalize([...usersLeaves, ...projectsLeaves])`.
|
|
236
|
+
- **Shared defaults:** wrap `resource('/api')` in your own helper that immediately calls `.with(...)` for cross-cutting flags you add via declaration merging (e.g., auth tags, tracing hints).
|
|
237
|
+
- **React Query integration:** use `buildCacheKey` + `queryClient.invalidateQueries({ queryKey })` to keep caches in sync, or rely on the client package’s helpers which wrap the same logic.
|
|
238
|
+
- **Error handling at boundaries:** catch `compilePath` errors when interpolating user-provided params; surface a 400 instead of crashing your handler.
|
|
239
|
+
|
|
240
|
+
### Edge cases and notes
|
|
241
|
+
|
|
242
|
+
- `paramsSchema` on a method overrides the merged schema from `routeParameter`—useful when you need stricter validation per verb.
|
|
243
|
+
- `bodyFiles` marks a route as multipart; servers can attach upload middleware, and clients should send `FormData`.
|
|
244
|
+
- CRUD helper only emits create/update routes when the matching `bodySchema` is provided; delete can be disabled via `enable.remove: false`.
|
|
245
|
+
- Feed-only behavior (`feed: true`) is intended for GET endpoints; clients treat them as infinite queries.
|
|
246
|
+
|
|
247
|
+
## Scripts (monorepo)
|
|
248
|
+
|
|
249
|
+
Run from the repo root:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
pnpm --filter @emeryld/rrroutes-contract build # tsup + d.ts
|
|
253
|
+
pnpm --filter @emeryld/rrroutes-contract typecheck
|
|
254
|
+
pnpm --filter @emeryld/rrroutes-contract test # optional Jest suite
|
|
255
|
+
```
|