@meith/api 0.16.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.md +165 -0
- package/package.json +24 -0
- package/src/index.ts +80 -0
- package/src/openapi.ts +208 -0
- package/src/rate-limit.ts +44 -0
- package/src/reference.ts +251 -0
- package/src/routes.ts +516 -0
- package/src/schema.ts +337 -0
- package/src/tokens.ts +126 -0
- package/src/webhooks.ts +96 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boolean,
|
|
3
|
+
choice,
|
|
4
|
+
envelope,
|
|
5
|
+
integer,
|
|
6
|
+
list,
|
|
7
|
+
MESSAGE_FOLDERS,
|
|
8
|
+
object,
|
|
9
|
+
page,
|
|
10
|
+
ref,
|
|
11
|
+
type Schema,
|
|
12
|
+
SUBSCRIPTION_MODES,
|
|
13
|
+
SUBSCRIPTION_TARGETS,
|
|
14
|
+
text,
|
|
15
|
+
timestamp,
|
|
16
|
+
} from './schema'
|
|
17
|
+
import type { Scope } from './tokens'
|
|
18
|
+
|
|
19
|
+
export type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
20
|
+
|
|
21
|
+
export interface ParamSpec {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly in: 'path' | 'query'
|
|
24
|
+
readonly schema: Schema
|
|
25
|
+
readonly required?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResponseSpec {
|
|
29
|
+
readonly status: number
|
|
30
|
+
readonly schema: Schema
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RouteSpec {
|
|
34
|
+
readonly method: Method
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly scope: Scope
|
|
37
|
+
readonly summary: string
|
|
38
|
+
readonly cost: number
|
|
39
|
+
readonly authenticated: boolean
|
|
40
|
+
readonly params?: readonly ParamSpec[]
|
|
41
|
+
readonly request?: Schema
|
|
42
|
+
readonly response: ResponseSpec
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LIMIT: ParamSpec = {
|
|
46
|
+
name: 'limit',
|
|
47
|
+
in: 'query',
|
|
48
|
+
schema: integer('How many rows to return. Defaults to 25, capped at 100.'),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const AFTER_CURSOR: ParamSpec = {
|
|
52
|
+
name: 'after',
|
|
53
|
+
in: 'query',
|
|
54
|
+
schema: text('The `nextCursor` from the previous page.'),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pathId(name: string, of: string): ParamSpec {
|
|
58
|
+
return { name, in: 'path', schema: integer(of), required: true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ROUTES = [
|
|
62
|
+
{
|
|
63
|
+
method: 'GET',
|
|
64
|
+
path: '/me',
|
|
65
|
+
scope: 'members:read',
|
|
66
|
+
summary: 'The token’s owner, and the scopes this token carries.',
|
|
67
|
+
cost: 1,
|
|
68
|
+
authenticated: true,
|
|
69
|
+
response: { status: 200, schema: envelope(ref('Identity')) },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
method: 'GET',
|
|
73
|
+
path: '/forums',
|
|
74
|
+
scope: 'forums:read',
|
|
75
|
+
summary: 'Every forum the caller may see, as a flat list with parent ids.',
|
|
76
|
+
cost: 1,
|
|
77
|
+
authenticated: false,
|
|
78
|
+
response: { status: 200, schema: object({ data: list(ref('Forum')) }) },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: 'GET',
|
|
82
|
+
path: '/forums/:forumId/threads',
|
|
83
|
+
scope: 'threads:read',
|
|
84
|
+
summary: 'Threads in a forum, newest activity first, keyset-paged.',
|
|
85
|
+
cost: 1,
|
|
86
|
+
authenticated: false,
|
|
87
|
+
params: [pathId('forumId', 'The forum to list.'), LIMIT, AFTER_CURSOR],
|
|
88
|
+
response: { status: 200, schema: page(ref('Thread'), 'nextCursor') },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
method: 'POST',
|
|
92
|
+
path: '/forums/:forumId/threads',
|
|
93
|
+
scope: 'threads:write',
|
|
94
|
+
summary:
|
|
95
|
+
'Start a thread. Subject to the same flood control, approval queue and word ' +
|
|
96
|
+
'limits as the web form.',
|
|
97
|
+
cost: 10,
|
|
98
|
+
authenticated: true,
|
|
99
|
+
params: [pathId('forumId', 'The forum to post in.')],
|
|
100
|
+
request: object(
|
|
101
|
+
{
|
|
102
|
+
title: text('The thread’s title.'),
|
|
103
|
+
message: text('The first post’s body, as Markdown.'),
|
|
104
|
+
prefixId: integer('A thread prefix to apply, if the forum offers any.', {
|
|
105
|
+
nullable: true,
|
|
106
|
+
}),
|
|
107
|
+
subscribe: boolean('Follow the thread after starting it.'),
|
|
108
|
+
},
|
|
109
|
+
['title', 'message'],
|
|
110
|
+
),
|
|
111
|
+
response: {
|
|
112
|
+
status: 201,
|
|
113
|
+
schema: envelope(
|
|
114
|
+
object({
|
|
115
|
+
id: integer('The new thread’s id.'),
|
|
116
|
+
postId: integer('The first post’s id.'),
|
|
117
|
+
slug: text('The URL-safe form of the title.'),
|
|
118
|
+
visibility: choice('`unapproved` when the post went to the moderation queue.', [
|
|
119
|
+
'visible',
|
|
120
|
+
'unapproved',
|
|
121
|
+
]),
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
method: 'GET',
|
|
128
|
+
path: '/threads/:threadId',
|
|
129
|
+
scope: 'threads:read',
|
|
130
|
+
summary: 'One thread’s metadata.',
|
|
131
|
+
cost: 1,
|
|
132
|
+
authenticated: false,
|
|
133
|
+
params: [pathId('threadId', 'The thread to read.')],
|
|
134
|
+
response: { status: 200, schema: envelope(ref('Thread')) },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
method: 'GET',
|
|
138
|
+
path: '/threads/:threadId/posts',
|
|
139
|
+
scope: 'posts:read',
|
|
140
|
+
summary: 'Posts in a thread, oldest first, keyset-paged.',
|
|
141
|
+
cost: 1,
|
|
142
|
+
authenticated: false,
|
|
143
|
+
params: [
|
|
144
|
+
pathId('threadId', 'The thread to read.'),
|
|
145
|
+
LIMIT,
|
|
146
|
+
{ name: 'after', in: 'query', schema: integer('The `nextAfterId` from the previous page.') },
|
|
147
|
+
],
|
|
148
|
+
response: { status: 200, schema: page(ref('Post'), 'nextAfterId') },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
method: 'POST',
|
|
152
|
+
path: '/threads/:threadId/posts',
|
|
153
|
+
scope: 'posts:write',
|
|
154
|
+
summary: 'Post a reply. Subject to the same flood control and moderation as the web form.',
|
|
155
|
+
cost: 5,
|
|
156
|
+
authenticated: true,
|
|
157
|
+
params: [pathId('threadId', 'The thread to reply in.')],
|
|
158
|
+
request: object(
|
|
159
|
+
{
|
|
160
|
+
message: text('The reply’s body, as Markdown.'),
|
|
161
|
+
subscribe: boolean('Follow the thread after replying.'),
|
|
162
|
+
},
|
|
163
|
+
['message'],
|
|
164
|
+
),
|
|
165
|
+
response: {
|
|
166
|
+
status: 201,
|
|
167
|
+
schema: envelope(
|
|
168
|
+
object({
|
|
169
|
+
id: integer('The new post’s id.'),
|
|
170
|
+
threadId: integer('The thread it landed in.'),
|
|
171
|
+
visibility: choice('`unapproved` when the post went to the moderation queue.', [
|
|
172
|
+
'visible',
|
|
173
|
+
'unapproved',
|
|
174
|
+
]),
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
method: 'PATCH',
|
|
181
|
+
path: '/threads/:threadId/posts/:postId',
|
|
182
|
+
scope: 'posts:write',
|
|
183
|
+
summary:
|
|
184
|
+
'Edit a post. The owner’s permissions decide whether that is their own post, ' +
|
|
185
|
+
'anybody’s, and whether the edit window has closed.',
|
|
186
|
+
cost: 5,
|
|
187
|
+
authenticated: true,
|
|
188
|
+
params: [
|
|
189
|
+
pathId('threadId', 'The thread the post is in.'),
|
|
190
|
+
pathId('postId', 'The post to edit.'),
|
|
191
|
+
],
|
|
192
|
+
request: object(
|
|
193
|
+
{
|
|
194
|
+
message: text('The replacement body, as Markdown.'),
|
|
195
|
+
reason: text('An edit reason, shown under the post.'),
|
|
196
|
+
},
|
|
197
|
+
['message'],
|
|
198
|
+
),
|
|
199
|
+
response: {
|
|
200
|
+
status: 200,
|
|
201
|
+
schema: envelope(
|
|
202
|
+
object({
|
|
203
|
+
id: integer('The edited post’s id.'),
|
|
204
|
+
threadId: integer('The thread it is in.'),
|
|
205
|
+
changed: boolean('False when the submitted body matched what was already there.'),
|
|
206
|
+
heldForApproval: boolean('Whether the edit sent the post back to the queue.'),
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
method: 'DELETE',
|
|
213
|
+
path: '/threads/:threadId/posts/:postId',
|
|
214
|
+
scope: 'posts:write',
|
|
215
|
+
summary:
|
|
216
|
+
'Remove a post. This is the board’s soft delete — the same one the web form ' +
|
|
217
|
+
'performs, recoverable by a moderator.',
|
|
218
|
+
cost: 5,
|
|
219
|
+
authenticated: true,
|
|
220
|
+
params: [
|
|
221
|
+
pathId('threadId', 'The thread the post is in.'),
|
|
222
|
+
pathId('postId', 'The post to remove.'),
|
|
223
|
+
],
|
|
224
|
+
response: {
|
|
225
|
+
status: 200,
|
|
226
|
+
schema: envelope(
|
|
227
|
+
object({
|
|
228
|
+
id: integer('The removed post’s id.'),
|
|
229
|
+
threadId: integer('The thread it was in.'),
|
|
230
|
+
changed: boolean('False when the post was already removed.'),
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
method: 'GET',
|
|
237
|
+
path: '/members/:userId',
|
|
238
|
+
scope: 'members:read',
|
|
239
|
+
summary: 'A member’s public profile.',
|
|
240
|
+
cost: 1,
|
|
241
|
+
authenticated: false,
|
|
242
|
+
params: [pathId('userId', 'The member to read.')],
|
|
243
|
+
response: { status: 200, schema: envelope(ref('Member')) },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
method: 'POST',
|
|
247
|
+
path: '/members/:userId/reputation',
|
|
248
|
+
scope: 'reputation:write',
|
|
249
|
+
summary:
|
|
250
|
+
'Rate a member, optionally against one of their posts. The board’s reputation ' +
|
|
251
|
+
'settings decide whether negative points and empty comments are allowed.',
|
|
252
|
+
cost: 5,
|
|
253
|
+
authenticated: true,
|
|
254
|
+
params: [pathId('userId', 'The member being rated.')],
|
|
255
|
+
request: object(
|
|
256
|
+
{
|
|
257
|
+
points: integer('The rating, within what the board allows.'),
|
|
258
|
+
comment: text('Why. Some boards require one.'),
|
|
259
|
+
postId: integer('The post being rated, if the rating is about one.', { nullable: true }),
|
|
260
|
+
},
|
|
261
|
+
['points'],
|
|
262
|
+
),
|
|
263
|
+
response: {
|
|
264
|
+
status: 201,
|
|
265
|
+
schema: envelope(object({ userId: integer('The member who was rated.') })),
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
method: 'GET',
|
|
270
|
+
path: '/messages',
|
|
271
|
+
scope: 'messages:read',
|
|
272
|
+
summary: 'One folder of the caller’s private messages. Listing marks nothing read.',
|
|
273
|
+
cost: 1,
|
|
274
|
+
authenticated: true,
|
|
275
|
+
params: [
|
|
276
|
+
{
|
|
277
|
+
name: 'folder',
|
|
278
|
+
in: 'query',
|
|
279
|
+
schema: choice('Which folder to list. Defaults to `inbox`.', MESSAGE_FOLDERS),
|
|
280
|
+
},
|
|
281
|
+
{ name: 'before', in: 'query', schema: integer('The `nextBefore` from the previous page.') },
|
|
282
|
+
],
|
|
283
|
+
response: { status: 200, schema: page(ref('MessageSummary'), 'nextBefore') },
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
method: 'POST',
|
|
287
|
+
path: '/messages',
|
|
288
|
+
scope: 'messages:write',
|
|
289
|
+
summary:
|
|
290
|
+
'Send a private message. Recipient quotas, ignore lists and the board’s ' +
|
|
291
|
+
'message rate limits all apply.',
|
|
292
|
+
cost: 10,
|
|
293
|
+
authenticated: true,
|
|
294
|
+
request: object(
|
|
295
|
+
{
|
|
296
|
+
to: text('Recipient usernames, separated by semicolons.'),
|
|
297
|
+
bcc: text('Blind recipients, separated by semicolons.'),
|
|
298
|
+
subject: text('The subject line.'),
|
|
299
|
+
message: text('The body, as Markdown.'),
|
|
300
|
+
receiptRequested: boolean('Ask to be told when it is read.'),
|
|
301
|
+
replyToId: integer('The message this replies to.', { nullable: true }),
|
|
302
|
+
},
|
|
303
|
+
['to', 'subject', 'message'],
|
|
304
|
+
),
|
|
305
|
+
response: {
|
|
306
|
+
status: 201,
|
|
307
|
+
schema: envelope(object({ id: integer('The sent message’s id.') })),
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
method: 'GET',
|
|
312
|
+
path: '/messages/:messageId',
|
|
313
|
+
scope: 'messages:read',
|
|
314
|
+
summary:
|
|
315
|
+
'One private message the caller is on. Opening it marks it read and, if the ' +
|
|
316
|
+
'sender asked for a receipt, tells them.',
|
|
317
|
+
cost: 1,
|
|
318
|
+
authenticated: true,
|
|
319
|
+
params: [pathId('messageId', 'The message to open.')],
|
|
320
|
+
response: { status: 200, schema: envelope(ref('Message')) },
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
method: 'GET',
|
|
324
|
+
path: '/threads/:threadId/poll',
|
|
325
|
+
scope: 'threads:read',
|
|
326
|
+
summary: 'A thread’s poll, with the running totals and the caller’s own vote.',
|
|
327
|
+
cost: 1,
|
|
328
|
+
authenticated: false,
|
|
329
|
+
params: [pathId('threadId', 'The thread whose poll to read.')],
|
|
330
|
+
response: { status: 200, schema: envelope(ref('Poll')) },
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
method: 'POST',
|
|
334
|
+
path: '/polls/:pollId/votes',
|
|
335
|
+
scope: 'polls:write',
|
|
336
|
+
summary: 'Vote in a thread’s poll.',
|
|
337
|
+
cost: 5,
|
|
338
|
+
authenticated: true,
|
|
339
|
+
params: [pathId('pollId', 'The poll to vote in.')],
|
|
340
|
+
request: object(
|
|
341
|
+
{
|
|
342
|
+
threadId: integer('The thread the poll is attached to.'),
|
|
343
|
+
optionId: integer('One option being voted for. Use `optionIds` for a multi-choice poll.'),
|
|
344
|
+
optionIds: list(
|
|
345
|
+
integer('An option being voted for.'),
|
|
346
|
+
'Every option being voted for, up to the poll’s `maxOptions`.',
|
|
347
|
+
),
|
|
348
|
+
},
|
|
349
|
+
['threadId'],
|
|
350
|
+
),
|
|
351
|
+
response: {
|
|
352
|
+
status: 201,
|
|
353
|
+
schema: envelope(object({ pollId: integer('The poll voted in.') })),
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
method: 'PATCH',
|
|
358
|
+
path: '/polls/:pollId',
|
|
359
|
+
scope: 'polls:write',
|
|
360
|
+
summary: 'Edit a poll the caller wrote, or any poll they moderate.',
|
|
361
|
+
cost: 5,
|
|
362
|
+
authenticated: true,
|
|
363
|
+
params: [pathId('pollId', 'The poll to edit.')],
|
|
364
|
+
request: object(
|
|
365
|
+
{
|
|
366
|
+
threadId: integer('The thread the poll is attached to.'),
|
|
367
|
+
question: text('What the poll asks.'),
|
|
368
|
+
options: list(
|
|
369
|
+
object({
|
|
370
|
+
id: integer('The option to keep, or omit to add a new one.'),
|
|
371
|
+
label: text('The option as it is shown.'),
|
|
372
|
+
}),
|
|
373
|
+
'The options the poll should end up with, in order. Omitting one removes it.',
|
|
374
|
+
),
|
|
375
|
+
closesAt: timestamp('When voting closes, or `null` if it does not.', true),
|
|
376
|
+
maxOptions: integer('How many options one member may pick. 0 means no limit.'),
|
|
377
|
+
allowRevote: boolean('Whether a member may change their vote.'),
|
|
378
|
+
publicVotes: boolean('Whether the voters are named on each option.'),
|
|
379
|
+
},
|
|
380
|
+
['threadId', 'question', 'options'],
|
|
381
|
+
),
|
|
382
|
+
response: { status: 200, schema: envelope(ref('Poll')) },
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
method: 'GET',
|
|
386
|
+
path: '/subscriptions',
|
|
387
|
+
scope: 'subscriptions:read',
|
|
388
|
+
summary: 'Everything the caller follows, filtered to forums they may still see.',
|
|
389
|
+
cost: 1,
|
|
390
|
+
authenticated: true,
|
|
391
|
+
response: { status: 200, schema: object({ data: list(ref('Subscription')) }) },
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
method: 'POST',
|
|
395
|
+
path: '/subscriptions',
|
|
396
|
+
scope: 'subscriptions:write',
|
|
397
|
+
summary: 'Follow a thread or a forum.',
|
|
398
|
+
cost: 2,
|
|
399
|
+
authenticated: true,
|
|
400
|
+
request: object(
|
|
401
|
+
{
|
|
402
|
+
target: choice('What to follow.', SUBSCRIPTION_TARGETS),
|
|
403
|
+
targetId: integer('The thread or forum to follow.'),
|
|
404
|
+
mode: choice('How to be told about new posts. Defaults to `instant`.', SUBSCRIPTION_MODES),
|
|
405
|
+
},
|
|
406
|
+
['target', 'targetId'],
|
|
407
|
+
),
|
|
408
|
+
response: {
|
|
409
|
+
status: 201,
|
|
410
|
+
schema: envelope(
|
|
411
|
+
object({
|
|
412
|
+
target: choice('What is followed.', SUBSCRIPTION_TARGETS),
|
|
413
|
+
targetId: integer('What is followed.'),
|
|
414
|
+
mode: choice('How the caller will be told.', SUBSCRIPTION_MODES),
|
|
415
|
+
}),
|
|
416
|
+
),
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
method: 'DELETE',
|
|
421
|
+
path: '/subscriptions/:target/:targetId',
|
|
422
|
+
scope: 'subscriptions:write',
|
|
423
|
+
summary: 'Stop following a thread or a forum.',
|
|
424
|
+
cost: 2,
|
|
425
|
+
authenticated: true,
|
|
426
|
+
params: [
|
|
427
|
+
{
|
|
428
|
+
name: 'target',
|
|
429
|
+
in: 'path',
|
|
430
|
+
schema: choice('What to stop following.', SUBSCRIPTION_TARGETS),
|
|
431
|
+
required: true,
|
|
432
|
+
},
|
|
433
|
+
pathId('targetId', 'The thread or forum to stop following.'),
|
|
434
|
+
],
|
|
435
|
+
response: {
|
|
436
|
+
status: 200,
|
|
437
|
+
schema: envelope(
|
|
438
|
+
object({
|
|
439
|
+
target: choice('What is no longer followed.', SUBSCRIPTION_TARGETS),
|
|
440
|
+
targetId: integer('What is no longer followed.'),
|
|
441
|
+
}),
|
|
442
|
+
),
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
method: 'GET',
|
|
447
|
+
path: '/search',
|
|
448
|
+
scope: 'search:read',
|
|
449
|
+
summary:
|
|
450
|
+
'Full-text search, filtered to what the caller may read. ' +
|
|
451
|
+
'Narrow it with `forum`, `by`, `when`, `in` and `show`, and order it with `sort`.',
|
|
452
|
+
cost: 10,
|
|
453
|
+
authenticated: false,
|
|
454
|
+
params: [
|
|
455
|
+
{ name: 'q', in: 'query', schema: text('The search term.'), required: true },
|
|
456
|
+
{ name: 'forum', in: 'query', schema: integer('Restrict to a forum. Repeatable.') },
|
|
457
|
+
{ name: 'by', in: 'query', schema: integer('Restrict to an author’s user id. Repeatable.') },
|
|
458
|
+
{ name: 'when', in: 'query', schema: text('Restrict to a period, such as `week`.') },
|
|
459
|
+
{ name: 'in', in: 'query', schema: text('Match `titles`, `posts` or both.') },
|
|
460
|
+
{ name: 'show', in: 'query', schema: text('Group results as posts or as threads.') },
|
|
461
|
+
{ name: 'sort', in: 'query', schema: text('Order the results.') },
|
|
462
|
+
LIMIT,
|
|
463
|
+
AFTER_CURSOR,
|
|
464
|
+
],
|
|
465
|
+
response: { status: 200, schema: page(ref('SearchHit'), 'nextCursor') },
|
|
466
|
+
},
|
|
467
|
+
] as const satisfies readonly RouteSpec[]
|
|
468
|
+
|
|
469
|
+
export type RouteKey = `${Method} ${string}`
|
|
470
|
+
|
|
471
|
+
export function routeKey(route: Pick<RouteSpec, 'method' | 'path'>): RouteKey {
|
|
472
|
+
return `${route.method} ${route.path}`
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function matchRoute(
|
|
476
|
+
method: string,
|
|
477
|
+
path: string,
|
|
478
|
+
): { readonly route: RouteSpec; readonly params: Readonly<Record<string, string>> } | null {
|
|
479
|
+
const parts = path.split('/').filter((part) => part !== '')
|
|
480
|
+
|
|
481
|
+
for (const route of ROUTES) {
|
|
482
|
+
if (route.method !== method) continue
|
|
483
|
+
|
|
484
|
+
const template = route.path.split('/').filter((part) => part !== '')
|
|
485
|
+
if (template.length !== parts.length) continue
|
|
486
|
+
|
|
487
|
+
const params: Record<string, string> = {}
|
|
488
|
+
let matched = true
|
|
489
|
+
|
|
490
|
+
for (const [index, segment] of template.entries()) {
|
|
491
|
+
const actual = parts[index]!
|
|
492
|
+
if (segment.startsWith(':')) {
|
|
493
|
+
if (actual === '') {
|
|
494
|
+
matched = false
|
|
495
|
+
break
|
|
496
|
+
}
|
|
497
|
+
params[segment.slice(1)] = actual
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
if (segment !== actual) {
|
|
501
|
+
matched = false
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (matched) return { route, params }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function idParam(value: string | undefined): number | null {
|
|
513
|
+
if (value === undefined || !/^\d+$/.test(value)) return null
|
|
514
|
+
const parsed = Number(value)
|
|
515
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
|
516
|
+
}
|