@mpen/routekit 0.1.0 → 0.1.2

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.
Files changed (133) hide show
  1. package/dist/bin.d.mts +4 -0
  2. package/dist/client/react.d.mts +178 -0
  3. package/dist/client/react.mjs +142 -0
  4. package/dist/client.d.mts +433 -0
  5. package/dist/client.mjs +264 -0
  6. package/dist/content-BuDOmhH_.mjs +102 -0
  7. package/dist/core-CzUCxvGk.d.mts +140 -0
  8. package/dist/core-DbmQauwS.mjs +81 -0
  9. package/dist/handlers.d.mts +72 -0
  10. package/dist/handlers.mjs +153 -0
  11. package/dist/index.d.mts +3 -0
  12. package/dist/index.mjs +1152 -0
  13. package/dist/middleware.d.mts +388 -0
  14. package/dist/middleware.mjs +1222 -0
  15. package/dist/request-Dn0zc-xm.mjs +1025 -0
  16. package/dist/response/content.d.mts +79 -0
  17. package/dist/response/content.mjs +2 -0
  18. package/dist/response/json-rpc.d.mts +1 -0
  19. package/dist/response/json-rpc.mjs +1 -0
  20. package/dist/response/problem/valibot.d.mts +230 -0
  21. package/dist/response/problem/valibot.mjs +258 -0
  22. package/dist/response/problem.d.mts +415 -0
  23. package/dist/response/problem.mjs +183 -0
  24. package/dist/response/status.d.mts +45 -0
  25. package/dist/response/status.mjs +2 -0
  26. package/dist/responses-B379Ep9Y.d.mts +296 -0
  27. package/dist/responses-BpVrgeYi.mjs +101 -0
  28. package/dist/router-Cwb7ak0J.d.mts +1819 -0
  29. package/dist/routes.d.mts +282 -0
  30. package/dist/routes.mjs +311 -0
  31. package/dist/status-C-8mw-FB.mjs +59 -0
  32. package/dist/valibot-D7liFYyB.d.mts +290 -0
  33. package/dist/valibot-Du97X-TS.mjs +326 -0
  34. package/package.json +8 -2
  35. package/src/bin/gen-api-client.test.ts +0 -70
  36. package/src/bin/gen-api-client.ts +0 -986
  37. package/src/client/headers.ts +0 -31
  38. package/src/client/index.ts +0 -8
  39. package/src/client/promise.ts +0 -11
  40. package/src/client/react/index.test.tsx +0 -266
  41. package/src/client/react/index.ts +0 -431
  42. package/src/client/responses.test.ts +0 -151
  43. package/src/client/responses.ts +0 -278
  44. package/src/client/transport.ts +0 -74
  45. package/src/client/transports/body-codec.ts +0 -61
  46. package/src/client/transports/fetch.ts +0 -113
  47. package/src/client/tsconfig.json +0 -9
  48. package/src/client/types.ts +0 -15
  49. package/src/client/url.ts +0 -31
  50. package/src/index.ts +0 -63
  51. package/src/router/fetch-types.ts +0 -13
  52. package/src/router/handlers/index.ts +0 -2
  53. package/src/router/handlers/openapi/index.ts +0 -2
  54. package/src/router/handlers/openapi/openapi.ts +0 -293
  55. package/src/router/integration/zod-openapi.test.ts +0 -74
  56. package/src/router/lib/charset.test.ts +0 -22
  57. package/src/router/lib/charset.ts +0 -133
  58. package/src/router/lib/collections.ts +0 -3
  59. package/src/router/lib/format.test.ts +0 -67
  60. package/src/router/lib/format.ts +0 -35
  61. package/src/router/lib/host.ts +0 -4
  62. package/src/router/lib/json-schema.ts +0 -6
  63. package/src/router/lib/media-type.test.ts +0 -122
  64. package/src/router/lib/media-type.ts +0 -289
  65. package/src/router/lib/pathname.test.ts +0 -18
  66. package/src/router/lib/pathname.ts +0 -19
  67. package/src/router/lib/route-names.ts +0 -70
  68. package/src/router/lib/route-normalize.test.ts +0 -36
  69. package/src/router/lib/route-normalize.ts +0 -67
  70. package/src/router/lib/schema-merge.ts +0 -56
  71. package/src/router/middleware/accept-ctx.test.ts +0 -33
  72. package/src/router/middleware/accept-ctx.ts +0 -12
  73. package/src/router/middleware/body-limit.test.ts +0 -112
  74. package/src/router/middleware/body-limit.ts +0 -121
  75. package/src/router/middleware/content-type-context.ts +0 -0
  76. package/src/router/middleware/cors.test.ts +0 -269
  77. package/src/router/middleware/cors.ts +0 -490
  78. package/src/router/middleware/csrf.test.ts +0 -106
  79. package/src/router/middleware/csrf.ts +0 -192
  80. package/src/router/middleware/define.ts +0 -249
  81. package/src/router/middleware/index.ts +0 -34
  82. package/src/router/middleware/jsxhtml-response.ts +0 -0
  83. package/src/router/middleware/oas-swagger.ts +0 -0
  84. package/src/router/middleware/rate-limit.test.ts +0 -886
  85. package/src/router/middleware/rate-limit.ts +0 -920
  86. package/src/router/middleware/request-id-ctx.test.ts +0 -183
  87. package/src/router/middleware/request-id-ctx.ts +0 -135
  88. package/src/router/middleware/request-logger-format.test.ts +0 -16
  89. package/src/router/middleware/request-logger-format.ts +0 -269
  90. package/src/router/middleware/request-logger.test.ts +0 -267
  91. package/src/router/middleware/request-logger.ts +0 -131
  92. package/src/router/middleware/start-time-ctx.ts +0 -5
  93. package/src/router/request.ts +0 -611
  94. package/src/router/response/core.ts +0 -181
  95. package/src/router/response/directives.ts +0 -233
  96. package/src/router/response/formats/content/bodyless.ts +0 -54
  97. package/src/router/response/formats/content/content.ts +0 -79
  98. package/src/router/response/formats/content/index.ts +0 -2
  99. package/src/router/response/formats/json-rpc/index.ts +0 -2
  100. package/src/router/response/formats/problem/badRequest.ts +0 -90
  101. package/src/router/response/formats/problem/conflict.ts +0 -90
  102. package/src/router/response/formats/problem/created.ts +0 -40
  103. package/src/router/response/formats/problem/index.ts +0 -27
  104. package/src/router/response/formats/problem/notFound.ts +0 -90
  105. package/src/router/response/formats/problem/permissionDenied.ts +0 -90
  106. package/src/router/response/formats/problem/problem.test.ts +0 -888
  107. package/src/router/response/formats/problem/rateLimited.ts +0 -90
  108. package/src/router/response/formats/problem/responses.ts +0 -219
  109. package/src/router/response/formats/problem/root-errors.ts +0 -48
  110. package/src/router/response/formats/problem/sessionExpired.ts +0 -90
  111. package/src/router/response/formats/problem/types.ts +0 -170
  112. package/src/router/response/formats/problem/unauthenticated.ts +0 -90
  113. package/src/router/response/formats/problem/valibot.ts +0 -410
  114. package/src/router/response/formats/status/index.ts +0 -1
  115. package/src/router/response/formats/status/responses.ts +0 -59
  116. package/src/router/response/formats/status/status.test.ts +0 -21
  117. package/src/router/response/framers.ts +0 -85
  118. package/src/router/response/index.ts +0 -28
  119. package/src/router/response/openapi.test.ts +0 -96
  120. package/src/router/response/openapi.ts +0 -1
  121. package/src/router/response/serializers.ts +0 -66
  122. package/src/router/response/stream.ts +0 -35
  123. package/src/router/router.test.ts +0 -1571
  124. package/src/router/router.ts +0 -1965
  125. package/src/router/routes/index.ts +0 -46
  126. package/src/router/routes/valibot/index.ts +0 -18
  127. package/src/router/routes/valibot/valibot.ts +0 -1393
  128. package/src/router/routes/valibot.test.ts +0 -286
  129. package/src/router/routes/zod/index.ts +0 -18
  130. package/src/router/routes/zod/zod.ts +0 -1318
  131. package/src/router/routes/zod.test.ts +0 -280
  132. package/src/router/server-interface.ts +0 -31
  133. package/src/router/types.ts +0 -657
@@ -1,986 +0,0 @@
1
- #!/usr/bin/env -S bun
2
- import path from 'node:path'
3
- import fs from 'node:fs'
4
- import { pathToFileURL } from 'node:url'
5
- import { parseArgs } from 'util'
6
- import { $ } from 'bun'
7
- import { compile } from 'json-schema-to-typescript'
8
- import { HttpMethod } from '@mpen/http'
9
- import type { JsonSchema, NormalizedRoute, RouteSchema } from '../router/types'
10
-
11
- const DEFAULT_RESPONSE_TYPE = 'ApiResponsePromise'
12
- const DEFAULT_FORMAT = 'rk-api-client'
13
- const OUTPUT_FORMATS = ['rk-api-client', 'ts-query-rk-problem'] as const
14
-
15
- type OutputFormat = (typeof OUTPUT_FORMATS)[number]
16
-
17
- type ExtractedRouteMeta = {
18
- name: string[]
19
- method: HttpMethod
20
- path: string
21
- requestSchema?: RouteSchema['request']
22
- responseBodySchemas?: NonNullable<RouteSchema['response']>['body']
23
- }
24
-
25
- type ProcessedRouteMeta = ExtractedRouteMeta & {
26
- typeBase: string
27
- pathParams: string[]
28
- }
29
-
30
- type RouteNode = {
31
- routes: ProcessedRouteMeta[]
32
- children: Map<string, RouteNode>
33
- }
34
-
35
- type ImportType = {
36
- names: string[]
37
- module: string
38
- }
39
-
40
- type BuildOptions = {
41
- clientName: string
42
- responseType: string
43
- importTypes: ImportType[]
44
- commandText: string
45
- }
46
-
47
- type GeneratedRouteTypes = {
48
- route: ProcessedRouteMeta
49
- pathTypeSource?: string
50
- queryTypeSource?: string
51
- requestTypeSource?: string
52
- responseTypesByStatusSource?: string
53
- responseTypeSources: string[]
54
- }
55
-
56
- type RoutableModule = {
57
- getRoutes(): NormalizedRoute<any>[]
58
- }
59
-
60
- function printHelp(): void {
61
- console.log(`Usage: bun run packages/routekit/src/bin/gen-api-client.ts <router-file> [options]
62
-
63
- Generate a typed API client from a routekit router module.
64
-
65
- Arguments:
66
- router-file Router module that exports a router with getRoutes()
67
-
68
- Options:
69
- -o, --output <file> File to write. Prints to stdout when omitted.
70
- -w, --write Write to <router-file>.gen.ts beside the router file.
71
- -p, --pretty Format written output with Prettier.
72
- -f, --format <format> Output format: rk-api-client or ts-query-rk-problem. Defaults to rk-api-client.
73
- --client-name <Name> Generated client class name. Defaults to ApiClient.
74
- --import-type <Type:module> Import a type used by generated schemas. Can be repeated.
75
- --response-type <Type> Generic response wrapper type. Defaults to ApiResponsePromise.
76
- --help Show this help message.`)
77
- }
78
-
79
- function routeTypeBaseName(route: ExtractedRouteMeta): string {
80
- const parts = route.name.length > 0 ? route.name : ['index']
81
- return upperFirst(route.method.toLowerCase()) + parts.map(upperFirst).join('')
82
- }
83
-
84
- function getPathParamNames(routePath: string): string[] {
85
- const matches = routePath.match(/:([a-zA-Z0-9_]+)/g) ?? []
86
- return matches.map((match) => match.slice(1))
87
- }
88
-
89
- function buildRouteTree(routes: ProcessedRouteMeta[]): RouteNode {
90
- const root: RouteNode = { routes: [], children: new Map() }
91
- for (const route of routes) {
92
- let node = root
93
- for (const segment of route.name) {
94
- if (!node.children.has(segment)) {
95
- node.children.set(segment, { routes: [], children: new Map() })
96
- }
97
- node = node.children.get(segment)!
98
- }
99
- node.routes.push(route)
100
- }
101
- return root
102
- }
103
-
104
- function classNameForParts(parts: string[], baseName: string): string {
105
- if (parts.length === 0) return baseName
106
- return `${baseName}_${parts.map(upperFirst).join('_')}`
107
- }
108
-
109
- function parseImportTypeOption(value: string): ImportType {
110
- const colonIdx = value.indexOf(':')
111
- if (colonIdx === -1) {
112
- throw new Error(`Invalid --import-type value: "${value}"`)
113
- }
114
- const names = value.slice(0, colonIdx)
115
- const module = value.slice(colonIdx + 1).trim()
116
- const parsedNames = names
117
- .split(',')
118
- .map((name) => name.trim())
119
- .filter(Boolean)
120
- if (parsedNames.length === 0 || module.length === 0) {
121
- throw new Error(`Invalid --import-type value: "${value}"`)
122
- }
123
- return { names: parsedNames, module }
124
- }
125
-
126
- function normalizeClientName(name: string | undefined): string {
127
- if (!name) return 'ApiClient'
128
- return (
129
- name
130
- .replace(/[^A-Za-z0-9]+/g, ' ')
131
- .trim()
132
- .split(/\s+/)
133
- .map(upperFirst)
134
- .join('') || 'ApiClient'
135
- )
136
- }
137
-
138
- function parseOutputFormat(value: string | undefined): OutputFormat {
139
- const format = (value?.trim() || DEFAULT_FORMAT) as OutputFormat
140
- if ((OUTPUT_FORMATS as readonly string[]).includes(format)) return format
141
- throw new Error(
142
- `Invalid --format value: "${value}". Expected one of: ${OUTPUT_FORMATS.join(', ')}`,
143
- )
144
- }
145
-
146
- function upperFirst(str: string): string {
147
- return str.slice(0, 1).toUpperCase() + str.slice(1)
148
- }
149
-
150
- function patternToUrlTemplate(routePath: string, pathVar = 'path'): string {
151
- const templated = routePath.replace(
152
- /:([a-zA-Z0-9_]+)/g,
153
- `\${encodeURIComponent(String(${pathVar}.$1))}`,
154
- )
155
- if (templated.includes('${')) {
156
- return '`' + templated + '`'
157
- }
158
- return `"${routePath}"`
159
- }
160
-
161
- function isUnknown(text: string): boolean {
162
- return text === 'unknown' || text === 'any'
163
- }
164
-
165
- function isUnconstrainedJsonSchema(schema: JsonSchema): boolean {
166
- return (
167
- schema === true ||
168
- (schema !== false &&
169
- typeof schema === 'object' &&
170
- !Array.isArray(schema) &&
171
- Object.keys(schema).length === 0)
172
- )
173
- }
174
-
175
- function isNeverJsonSchema(schema: JsonSchema): boolean {
176
- return (
177
- schema === false ||
178
- (schema !== true &&
179
- typeof schema === 'object' &&
180
- !Array.isArray(schema) &&
181
- Object.keys(schema).length === 1 &&
182
- typeof schema.not === 'object' &&
183
- schema.not !== null &&
184
- !Array.isArray(schema.not) &&
185
- Object.keys(schema.not).length === 0)
186
- )
187
- }
188
-
189
- function isJsonSchemaObject(schema: JsonSchema): schema is Record<string, unknown> {
190
- return (
191
- schema !== true && schema !== false && typeof schema === 'object' && !Array.isArray(schema)
192
- )
193
- }
194
-
195
- function isNumericStringSchema(schema: unknown): boolean {
196
- if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false
197
- const schemaObject = schema as Record<string, unknown>
198
- return (
199
- schemaObject.type === 'string' &&
200
- typeof schemaObject.pattern === 'string' &&
201
- ['^\\d+$', '^[0-9]+$'].includes(schemaObject.pattern)
202
- )
203
- }
204
-
205
- function widenNumericStringPathParams(schema: JsonSchema, pathParams: string[]): JsonSchema {
206
- if (!isJsonSchemaObject(schema)) return schema
207
- const properties = schema.properties
208
- if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
209
- return schema
210
- }
211
-
212
- const nextProperties: Record<string, unknown> = { ...properties }
213
- let widened = false
214
- for (const pathParam of pathParams) {
215
- const propertySchema = nextProperties[pathParam]
216
- if (!isNumericStringSchema(propertySchema)) continue
217
- nextProperties[pathParam] = {
218
- anyOf: [propertySchema, { type: 'number' }],
219
- }
220
- widened = true
221
- }
222
-
223
- if (!widened) return schema
224
- return {
225
- ...schema,
226
- properties: nextProperties,
227
- }
228
- }
229
-
230
- function normalizeMethod(routeMethod: NormalizedRoute<any>['method']): HttpMethod[] {
231
- if (!routeMethod) return [HttpMethod.GET]
232
- return Array.isArray(routeMethod) ? routeMethod : [routeMethod]
233
- }
234
-
235
- function extractRoutes(routes: NormalizedRoute<any>[]): ExtractedRouteMeta[] {
236
- const extracted: ExtractedRouteMeta[] = []
237
- for (const route of routes) {
238
- for (const method of normalizeMethod(route.method)) {
239
- extracted.push({
240
- name: route.name,
241
- method,
242
- path: route.path.pathname,
243
- ...(route.schema?.request ? { requestSchema: route.schema.request } : {}),
244
- ...(route.schema?.response?.body
245
- ? { responseBodySchemas: route.schema.response.body }
246
- : {}),
247
- })
248
- }
249
- }
250
- return extracted
251
- }
252
-
253
- function resolveRouterModule(module: Record<string, unknown>): RoutableModule {
254
- const candidates = [module.default, module.router, ...Object.values(module)]
255
- for (const candidate of candidates) {
256
- if (
257
- candidate &&
258
- typeof candidate === 'object' &&
259
- typeof (candidate as RoutableModule).getRoutes === 'function'
260
- ) {
261
- return candidate as RoutableModule
262
- }
263
- }
264
- throw new Error('Unable to find an exported router with a getRoutes() method')
265
- }
266
-
267
- async function loadRuntimeRoutes(routerPath: string): Promise<NormalizedRoute<any>[]> {
268
- const moduleUrl = pathToFileURL(routerPath).href
269
- const module = await import(moduleUrl)
270
- const router = resolveRouterModule(module as Record<string, unknown>)
271
- return router.getRoutes()
272
- }
273
-
274
- async function compileSchemaType(name: string, schema: JsonSchema): Promise<string> {
275
- if (isUnconstrainedJsonSchema(schema)) {
276
- return `export type ${name} = unknown`
277
- }
278
- if (isNeverJsonSchema(schema)) {
279
- return `export type ${name} = never`
280
- }
281
-
282
- const source = (
283
- await compile(schema as Record<string, unknown>, name, {
284
- bannerComment: '',
285
- additionalProperties: false,
286
- style: {
287
- singleQuote: true,
288
- },
289
- })
290
- ).trim()
291
- return source.replace(/\bNoName\b/g, `${name}Schema`)
292
- }
293
-
294
- async function formatWithPrettier(source: string, outputPath: string): Promise<string> {
295
- let prettier
296
- try {
297
- prettier = await import('prettier')
298
- } catch (cause) {
299
- throw new Error('The --pretty option requires prettier to be installed.', { cause })
300
- }
301
-
302
- const options = (await prettier.resolveConfig(outputPath)) ?? {}
303
- return prettier.format(source, { ...options, filepath: outputPath })
304
- }
305
-
306
- function responseStatusAlias(typeBase: string, status: string): string {
307
- return `${typeBase}Response${status}`
308
- }
309
-
310
- function isSuccessfulStatus(status: string): boolean {
311
- const statusNumber = Number(status)
312
- return Number.isInteger(statusNumber) && statusNumber >= 200 && statusNumber < 300
313
- }
314
-
315
- function responseStatusAliases(
316
- route: ProcessedRouteMeta,
317
- statusFilter: (status: string) => boolean,
318
- ): string[] {
319
- if (!route.responseBodySchemas) return []
320
- return Object.entries(route.responseBodySchemas)
321
- .filter(([status, responseSchema]) => responseSchema !== undefined && statusFilter(status))
322
- .map(([status]) => responseStatusAlias(route.typeBase, status))
323
- }
324
-
325
- function unionType(types: string[], fallback: string): string {
326
- if (types.length === 0) return fallback
327
- if (types.length === 1) return types[0]!
328
- return types.join(' | ')
329
- }
330
-
331
- async function generateRouteTypes(route: ProcessedRouteMeta): Promise<GeneratedRouteTypes> {
332
- const generated: GeneratedRouteTypes = {
333
- route,
334
- responseTypeSources: [],
335
- }
336
-
337
- if (route.requestSchema?.path) {
338
- generated.pathTypeSource = await compileSchemaType(
339
- `${route.typeBase}PathParams`,
340
- widenNumericStringPathParams(route.requestSchema.path, route.pathParams),
341
- )
342
- }
343
- if (route.requestSchema?.query) {
344
- generated.queryTypeSource = await compileSchemaType(
345
- `${route.typeBase}Query`,
346
- route.requestSchema.query,
347
- )
348
- }
349
- if (route.requestSchema?.body !== undefined) {
350
- generated.requestTypeSource = await compileSchemaType(
351
- `${route.typeBase}Request`,
352
- route.requestSchema.body,
353
- )
354
- }
355
-
356
- if (route.responseBodySchemas && Object.keys(route.responseBodySchemas).length > 0) {
357
- const responseTypesByStatus: string[] = []
358
- for (const [status, responseSchema] of Object.entries(route.responseBodySchemas)) {
359
- if (responseSchema === undefined) continue
360
- const alias = responseStatusAlias(route.typeBase, status)
361
- generated.responseTypeSources.push(await compileSchemaType(alias, responseSchema))
362
- responseTypesByStatus.push(` ${JSON.stringify(status)}: ${alias}`)
363
- }
364
- generated.responseTypesByStatusSource =
365
- responseTypesByStatus.length > 0
366
- ? [
367
- `export interface ${route.typeBase}ResponsesByStatus {`,
368
- ...responseTypesByStatus,
369
- `}`,
370
- `export type ${route.typeBase}Response = ${route.typeBase}ResponsesByStatus[keyof ${route.typeBase}ResponsesByStatus]`,
371
- ].join('\n')
372
- : `export type ${route.typeBase}Response = unknown`
373
- } else {
374
- const fallback = route.method === HttpMethod.HEAD ? 'never' : 'unknown'
375
- generated.responseTypesByStatusSource = `export type ${route.typeBase}Response = ${fallback}`
376
- }
377
-
378
- return generated
379
- }
380
-
381
- function buildMethodLines(
382
- route: ProcessedRouteMeta,
383
- options: BuildOptions,
384
- indent: string,
385
- ): string[] {
386
- const lines: string[] = []
387
- const methodName = route.method.toLowerCase()
388
- const bodyType =
389
- route.requestSchema?.body !== undefined ? `${route.typeBase}Request` : undefined
390
- const queryType = route.requestSchema?.query ? `${route.typeBase}Query` : undefined
391
- const hasPathParams = route.pathParams.length > 0
392
- const hasSinglePathParam = route.pathParams.length === 1
393
- let pathVar = 'path'
394
-
395
- if (hasPathParams) {
396
- if (hasSinglePathParam) {
397
- pathVar = '_path'
398
- }
399
- }
400
-
401
- const shouldResolveApiResponse = options.responseType === DEFAULT_RESPONSE_TYPE
402
- const hasResponsesByStatus =
403
- !!route.responseBodySchemas && Object.keys(route.responseBodySchemas).length > 0
404
- const returnType =
405
- shouldResolveApiResponse && hasResponsesByStatus
406
- ? `ApiResponseByStatusPromise<${route.typeBase}ResponsesByStatus>`
407
- : `${options.responseType}<${route.typeBase}Response>`
408
- const params: string[] = []
409
- if (hasPathParams) {
410
- let pathParamType = pathTypeForRoute(route)
411
- if (hasSinglePathParam) {
412
- const singleParamType = route.requestSchema?.path
413
- ? `SinglePathParam<${route.typeBase}PathParams, "${route.pathParams[0]}">`
414
- : 'any'
415
- pathParamType = `${pathParamType} | ${singleParamType}`
416
- }
417
- params.push(`path: ${pathParamType}`)
418
- }
419
- if (queryType) params.push(`query: ${queryType}`)
420
- if (bodyType) params.push(`body: ${bodyType}`)
421
-
422
- lines.push(`${indent}${methodName} = (${params.join(', ')}): ${returnType} => {`)
423
- if (hasSinglePathParam) {
424
- lines.push(
425
- `${indent} const _path = typeof path === 'object' && path !== null && !Array.isArray(path) ? path : { ${route.pathParams[0]}: path } as any`,
426
- )
427
- }
428
- const urlExpr = patternToUrlTemplate(route.path, pathVar)
429
- const finalUrlExpr = queryType ? `withQuery(${urlExpr}, query)` : urlExpr
430
- const resolverExpression =
431
- shouldResolveApiResponse && hasResponsesByStatus
432
- ? `resolveApiResponseByStatus<${route.typeBase}ResponsesByStatus>`
433
- : 'resolveApiResponse'
434
- lines.push(
435
- `${indent} return ${shouldResolveApiResponse ? `${resolverExpression}(` : ''}this.transport.request({`,
436
- )
437
- lines.push(`${indent} url: ${finalUrlExpr},`)
438
- lines.push(`${indent} init: {`)
439
- lines.push(`${indent} method: "${route.method}",`)
440
- lines.push(`${indent} },`)
441
- if (bodyType) {
442
- lines.push(`${indent} body,`)
443
- }
444
- if (shouldResolveApiResponse) {
445
- lines.push(`${indent} }))`)
446
- } else {
447
- lines.push(`${indent} }) as ${returnType}`)
448
- }
449
- lines.push(`${indent}}`)
450
-
451
- return lines
452
- }
453
-
454
- function pathTypeForRoute(route: ProcessedRouteMeta): string {
455
- return route.requestSchema?.path ? `${route.typeBase}PathParams` : 'any'
456
- }
457
-
458
- function pathParameterTypeForRoute(route: ProcessedRouteMeta): string {
459
- let pathParamType = pathTypeForRoute(route)
460
- if (route.pathParams.length === 1) {
461
- const singleParamType = route.requestSchema?.path
462
- ? `SinglePathParam<${route.typeBase}PathParams, "${route.pathParams[0]}">`
463
- : 'any'
464
- pathParamType = `${pathParamType} | ${singleParamType}`
465
- }
466
- return pathParamType
467
- }
468
-
469
- type RouteRequestComponent = {
470
- name: 'path' | 'query' | 'body'
471
- type: string
472
- }
473
-
474
- function routeRequestComponents(route: ProcessedRouteMeta): RouteRequestComponent[] {
475
- const components: RouteRequestComponent[] = []
476
- if (route.pathParams.length > 0) {
477
- components.push({ name: 'path', type: pathParameterTypeForRoute(route) })
478
- }
479
- if (route.requestSchema?.query) {
480
- components.push({ name: 'query', type: `${route.typeBase}Query` })
481
- }
482
- if (route.requestSchema?.body !== undefined) {
483
- components.push({ name: 'body', type: `${route.typeBase}Request` })
484
- }
485
- return components
486
- }
487
-
488
- function emitClass(node: RouteNode, parts: string[], options: BuildOptions, lines: string[]): void {
489
- const className = classNameForParts(parts, options.clientName)
490
- const exportKeyword = parts.length === 0 ? 'export ' : ''
491
- const childNames = Array.from(node.children.keys())
492
-
493
- lines.push(`${exportKeyword}class ${className} {`)
494
- if (parts.length === 0) {
495
- lines.push(` private readonly transport: ClientTransport`)
496
- }
497
-
498
- for (const childName of childNames) {
499
- const childClass = classNameForParts([...parts, childName], options.clientName)
500
- lines.push(` private _${childName}?: ${childClass}`)
501
- }
502
-
503
- if (parts.length === 0 || childNames.length > 0) {
504
- lines.push(``)
505
- }
506
-
507
- if (parts.length === 0) {
508
- lines.push(` constructor(transport?: ClientTransport) {`)
509
- lines.push(` this.transport = transport ?? new FetchTransport()`)
510
- lines.push(` }`)
511
- } else {
512
- lines.push(` constructor(private readonly transport: ClientTransport) {}`)
513
- }
514
-
515
- if (node.children.size > 0) {
516
- lines.push(``)
517
- childNames.forEach((childName, idx) => {
518
- const childClass = classNameForParts([...parts, childName], options.clientName)
519
- lines.push(` get ${childName}(): ${childClass} {`)
520
- lines.push(` return (this._${childName} ??= new ${childClass}(this.transport))`)
521
- lines.push(` }`)
522
- if (idx !== childNames.length - 1 || node.routes.length > 0) {
523
- lines.push(``)
524
- }
525
- })
526
- }
527
-
528
- if (node.routes.length > 0) {
529
- node.routes.forEach((route, idx) => {
530
- const methodLines = buildMethodLines(route, options, ' ')
531
- for (const line of methodLines) lines.push(line)
532
- if (idx !== node.routes.length - 1) lines.push(``)
533
- })
534
- }
535
-
536
- lines.push(`}`)
537
- lines.push(``)
538
-
539
- for (const [childName, childNode] of node.children) {
540
- emitClass(childNode, [...parts, childName], options, lines)
541
- }
542
- }
543
-
544
- function emitGeneratedRouteTypeSources(
545
- generatedTypes: GeneratedRouteTypes[],
546
- lines: string[],
547
- ): void {
548
- for (const generated of generatedTypes) {
549
- if (generated.pathTypeSource && !isUnknown(generated.pathTypeSource))
550
- lines.push(generated.pathTypeSource, ``)
551
- if (generated.queryTypeSource && !isUnknown(generated.queryTypeSource))
552
- lines.push(generated.queryTypeSource, ``)
553
- if (generated.requestTypeSource && !isUnknown(generated.requestTypeSource))
554
- lines.push(generated.requestTypeSource, ``)
555
- for (const responseTypeSource of generated.responseTypeSources) {
556
- if (!isUnknown(responseTypeSource)) lines.push(responseTypeSource, ``)
557
- }
558
- if (generated.responseTypesByStatusSource)
559
- lines.push(generated.responseTypesByStatusSource, ``)
560
- }
561
- }
562
-
563
- function routeSuccessBodyType(route: ProcessedRouteMeta): string {
564
- return unionType(responseStatusAliases(route, isSuccessfulStatus), 'unknown')
565
- }
566
-
567
- function routeProblemBodyType(route: ProcessedRouteMeta): string {
568
- return unionType(
569
- responseStatusAliases(route, (status) => !isSuccessfulStatus(status)),
570
- 'unknown',
571
- )
572
- }
573
-
574
- function isQueryRoute(route: ProcessedRouteMeta): boolean {
575
- return route.method === HttpMethod.GET || route.method === HttpMethod.HEAD
576
- }
577
-
578
- function emitTsQueryRouteTypeAliases(generatedTypes: GeneratedRouteTypes[], lines: string[]): void {
579
- for (const { route } of generatedTypes) {
580
- const successBodyType = routeSuccessBodyType(route)
581
- const problemBodyType = routeProblemBodyType(route)
582
- lines.push(
583
- `export type ${route.typeBase}Data = RoutekitProblemSuccessData<${successBodyType}>`,
584
- )
585
- lines.push(`export type ${route.typeBase}Problem = ${problemBodyType}`)
586
- lines.push(
587
- `export type ${route.typeBase}Error = RoutekitProblemError<${route.typeBase}Problem>`,
588
- )
589
- lines.push(``)
590
-
591
- const components = routeRequestComponents(route)
592
- if (!isQueryRoute(route) && components.length > 1) {
593
- lines.push(`export interface ${route.typeBase}Variables {`)
594
- for (const component of components) {
595
- lines.push(` ${component.name}: ${component.type}`)
596
- }
597
- lines.push(`}`)
598
- lines.push(``)
599
- }
600
- }
601
- }
602
-
603
- function buildTransportRequestLines(
604
- route: ProcessedRouteMeta,
605
- indent: string,
606
- pathVar: string,
607
- queryVar: string | undefined,
608
- bodyVar: string | undefined,
609
- ): string[] {
610
- const lines: string[] = []
611
- const urlExpr = patternToUrlTemplate(route.path, pathVar)
612
- const finalUrlExpr = queryVar ? `withQuery(${urlExpr}, ${queryVar})` : urlExpr
613
-
614
- lines.push(`${indent}transport.request({`)
615
- lines.push(`${indent} url: ${finalUrlExpr},`)
616
- lines.push(`${indent} init: {`)
617
- lines.push(`${indent} method: "${route.method}",`)
618
- lines.push(`${indent} },`)
619
- if (bodyVar) {
620
- lines.push(`${indent} body: ${bodyVar},`)
621
- }
622
- lines.push(`${indent}})`)
623
- return lines
624
- }
625
-
626
- function pushSinglePathParamNormalization(
627
- route: ProcessedRouteMeta,
628
- lines: string[],
629
- indent: string,
630
- ): string {
631
- if (route.pathParams.length !== 1) return 'path'
632
-
633
- lines.push(
634
- `${indent}const _path = typeof path === 'object' && path !== null && !Array.isArray(path) ? path : { ${route.pathParams[0]}: path } as any`,
635
- )
636
- return '_path'
637
- }
638
-
639
- function tsQueryKey(route: ProcessedRouteMeta, components: string[]): string {
640
- return [
641
- JSON.stringify('routekit'),
642
- JSON.stringify(route.method),
643
- JSON.stringify(route.path),
644
- ...components,
645
- ].join(', ')
646
- }
647
-
648
- function emitQueryHelper(route: ProcessedRouteMeta, lines: string[], indent: string): void {
649
- const components = routeRequestComponents(route)
650
- const params = components.map((component) => `${component.name}: ${component.type}`).join(', ')
651
- const methodName = route.method.toLowerCase()
652
- const successBodyType = routeSuccessBodyType(route)
653
- const queryKeyComponents: string[] = []
654
-
655
- lines.push(`${indent}${methodName}: (${params}) => {`)
656
- const pathVar = pushSinglePathParamNormalization(route, lines, `${indent} `)
657
- for (const component of components) {
658
- queryKeyComponents.push(component.name === 'path' ? pathVar : component.name)
659
- }
660
- lines.push(`${indent} return queryOptions<${route.typeBase}Data, ${route.typeBase}Error>({`)
661
- lines.push(`${indent} queryKey: [${tsQueryKey(route, queryKeyComponents)}] as const,`)
662
- lines.push(`${indent} queryFn: (): Promise<${route.typeBase}Data> => {`)
663
- lines.push(
664
- `${indent} return resolveRoutekitProblemData<${successBodyType}, ${route.typeBase}Problem>(`,
665
- )
666
- for (const requestLine of buildTransportRequestLines(
667
- route,
668
- `${indent} `,
669
- pathVar,
670
- route.requestSchema?.query ? 'query' : undefined,
671
- route.requestSchema?.body !== undefined ? 'body' : undefined,
672
- )) {
673
- lines.push(requestLine)
674
- }
675
- lines.push(`${indent} )`)
676
- lines.push(`${indent} },`)
677
- lines.push(`${indent} })`)
678
- lines.push(`${indent}},`)
679
- }
680
-
681
- function emitMutationHelper(route: ProcessedRouteMeta, lines: string[], indent: string): void {
682
- const components = routeRequestComponents(route)
683
- const methodName = route.method.toLowerCase()
684
- const successBodyType = routeSuccessBodyType(route)
685
- const variablesType =
686
- components.length === 0
687
- ? 'void'
688
- : components.length === 1
689
- ? components[0]!.type
690
- : `${route.typeBase}Variables`
691
-
692
- lines.push(`${indent}${methodName}: () => {`)
693
- lines.push(
694
- `${indent} return mutationOptions<${route.typeBase}Data, ${route.typeBase}Error, ${variablesType}>({`,
695
- )
696
- lines.push(`${indent} mutationKey: [${tsQueryKey(route, [])}] as const,`)
697
-
698
- let mutationParam = ''
699
- if (components.length === 1) {
700
- const component = components[0]!
701
- mutationParam = `${component.name}: ${component.type}`
702
- } else if (components.length > 1) {
703
- mutationParam = `variables: ${route.typeBase}Variables`
704
- }
705
-
706
- lines.push(
707
- `${indent} mutationFn: (${mutationParam}): Promise<${route.typeBase}Data> => {`,
708
- )
709
-
710
- if (components.length > 1) {
711
- for (const component of components) {
712
- lines.push(`${indent} const ${component.name} = variables.${component.name}`)
713
- }
714
- }
715
-
716
- const pathVar =
717
- components.some((component) => component.name === 'path') && route.pathParams.length === 1
718
- ? pushSinglePathParamNormalization(route, lines, `${indent} `)
719
- : 'path'
720
-
721
- lines.push(
722
- `${indent} return resolveRoutekitProblemData<${successBodyType}, ${route.typeBase}Problem>(`,
723
- )
724
- for (const requestLine of buildTransportRequestLines(
725
- route,
726
- `${indent} `,
727
- pathVar,
728
- route.requestSchema?.query ? 'query' : undefined,
729
- route.requestSchema?.body !== undefined ? 'body' : undefined,
730
- )) {
731
- lines.push(requestLine)
732
- }
733
- lines.push(`${indent} )`)
734
- lines.push(`${indent} },`)
735
- lines.push(`${indent} })`)
736
- lines.push(`${indent}},`)
737
- }
738
-
739
- function emitTsQueryNodeProperties(node: RouteNode, lines: string[], indent: string): void {
740
- for (const [childName, childNode] of node.children) {
741
- lines.push(`${indent}${childName}: {`)
742
- emitTsQueryNodeProperties(childNode, lines, `${indent} `)
743
- lines.push(`${indent}},`)
744
- }
745
-
746
- for (const route of node.routes) {
747
- if (isQueryRoute(route)) {
748
- emitQueryHelper(route, lines, indent)
749
- } else {
750
- emitMutationHelper(route, lines, indent)
751
- }
752
- }
753
- }
754
-
755
- async function buildApiClientSource(
756
- routes: ExtractedRouteMeta[],
757
- options: BuildOptions,
758
- ): Promise<string> {
759
- const processedRoutes: ProcessedRouteMeta[] = routes.map((route) => ({
760
- ...route,
761
- typeBase: routeTypeBaseName(route),
762
- pathParams: getPathParamNames(route.path),
763
- }))
764
- const needsSinglePathHelper = processedRoutes.some((route) => route.pathParams.length === 1)
765
- const needsQueryHelper = processedRoutes.some((route) => route.requestSchema?.query)
766
- const needsResponseByStatusHelper = processedRoutes.some(
767
- (route) => route.responseBodySchemas && Object.keys(route.responseBodySchemas).length > 0,
768
- )
769
- const needsDefaultResponsePromise = processedRoutes.some(
770
- (route) =>
771
- !route.responseBodySchemas || Object.keys(route.responseBodySchemas).length === 0,
772
- )
773
- const generatedTypes = await Promise.all(processedRoutes.map(generateRouteTypes))
774
-
775
- const lines: string[] = []
776
- lines.push(`// Do not modify this file. It was auto-generated with the following command:`)
777
- lines.push(`// $ ${options.commandText}`)
778
- lines.push(``)
779
-
780
- for (const importType of options.importTypes) {
781
- lines.push(`import type { ${importType.names.join(', ')} } from '${importType.module}'`)
782
- }
783
-
784
- const clientImports = [
785
- 'FetchTransport',
786
- options.responseType === DEFAULT_RESPONSE_TYPE && needsDefaultResponsePromise
787
- ? 'resolveApiResponse'
788
- : undefined,
789
- options.responseType === DEFAULT_RESPONSE_TYPE && needsResponseByStatusHelper
790
- ? 'resolveApiResponseByStatus'
791
- : undefined,
792
- needsQueryHelper ? 'withQuery' : undefined,
793
- 'type ClientTransport',
794
- options.responseType === DEFAULT_RESPONSE_TYPE && needsResponseByStatusHelper
795
- ? 'type ApiResponseByStatusPromise'
796
- : undefined,
797
- options.responseType === DEFAULT_RESPONSE_TYPE && needsDefaultResponsePromise
798
- ? `type ${options.responseType}`
799
- : undefined,
800
- needsSinglePathHelper ? 'type SinglePathParam' : undefined,
801
- ].filter(Boolean)
802
- lines.push(`import { ${clientImports.join(', ')} } from '@mpen/routekit/client'`)
803
-
804
- if (options.importTypes.length > 0) {
805
- lines.push(``)
806
- }
807
- lines.push(``)
808
-
809
- lines.push(``)
810
- emitGeneratedRouteTypeSources(generatedTypes, lines)
811
-
812
- const tree = buildRouteTree(processedRoutes)
813
- emitClass(tree, [], options, lines)
814
-
815
- return lines.join('\n')
816
- }
817
-
818
- async function buildTsQueryRkProblemSource(
819
- routes: ExtractedRouteMeta[],
820
- options: Pick<BuildOptions, 'importTypes' | 'commandText'>,
821
- ): Promise<string> {
822
- const processedRoutes: ProcessedRouteMeta[] = routes.map((route) => ({
823
- ...route,
824
- typeBase: routeTypeBaseName(route),
825
- pathParams: getPathParamNames(route.path),
826
- }))
827
- const needsSinglePathHelper = processedRoutes.some((route) => route.pathParams.length === 1)
828
- const needsQueryHelper = processedRoutes.some((route) => route.requestSchema?.query)
829
- const generatedTypes = await Promise.all(processedRoutes.map(generateRouteTypes))
830
-
831
- const lines: string[] = []
832
- lines.push(`// Do not modify this file. It was auto-generated with the following command:`)
833
- lines.push(`// $ ${options.commandText}`)
834
- lines.push(``)
835
-
836
- for (const importType of options.importTypes) {
837
- lines.push(`import type { ${importType.names.join(', ')} } from '${importType.module}'`)
838
- }
839
-
840
- lines.push(`import { mutationOptions, queryOptions } from '@tanstack/react-query'`)
841
-
842
- const clientImports = [
843
- 'FetchTransport',
844
- 'resolveRoutekitProblemData',
845
- needsQueryHelper ? 'withQuery' : undefined,
846
- 'type ClientTransport',
847
- 'type RoutekitProblemError',
848
- 'type RoutekitProblemSuccessData',
849
- needsSinglePathHelper ? 'type SinglePathParam' : undefined,
850
- ].filter(Boolean)
851
- lines.push(`import { ${clientImports.join(', ')} } from '@mpen/routekit/client'`)
852
- lines.push(``)
853
-
854
- emitGeneratedRouteTypeSources(generatedTypes, lines)
855
- emitTsQueryRouteTypeAliases(generatedTypes, lines)
856
-
857
- const tree = buildRouteTree(processedRoutes)
858
- lines.push(
859
- `export function createApiQueryHelpers(transport: ClientTransport = new FetchTransport()) {`,
860
- )
861
- lines.push(` return {`)
862
- emitTsQueryNodeProperties(tree, lines, ` `)
863
- lines.push(` }`)
864
- lines.push(`}`)
865
- lines.push(``)
866
-
867
- return lines.join('\n')
868
- }
869
-
870
- export async function main() {
871
- const { positionals, values } = parseArgs({
872
- allowPositionals: true,
873
- strict: true,
874
- options: {
875
- output: {
876
- type: 'string',
877
- short: 'o',
878
- },
879
- write: {
880
- type: 'boolean',
881
- short: 'w',
882
- },
883
- pretty: {
884
- type: 'boolean',
885
- short: 'p',
886
- },
887
- format: {
888
- type: 'string',
889
- short: 'f',
890
- },
891
- 'client-name': {
892
- type: 'string',
893
- },
894
- 'import-type': {
895
- type: 'string',
896
- multiple: true,
897
- },
898
- 'response-type': {
899
- type: 'string',
900
- },
901
- help: {
902
- type: 'boolean',
903
- },
904
- },
905
- })
906
-
907
- if (values.help) {
908
- printHelp()
909
- return
910
- }
911
-
912
- const [routerPathArg] = positionals
913
- if (!routerPathArg) {
914
- printHelp()
915
- process.exit(1)
916
- }
917
-
918
- const format = parseOutputFormat(
919
- (values as Record<string, string | string[] | undefined>).format as string | undefined,
920
- )
921
- const clientName = normalizeClientName(
922
- (values as Record<string, string | string[] | undefined>)['client-name'] as
923
- | string
924
- | undefined,
925
- )
926
- const responseType =
927
- (
928
- (values as Record<string, string | string[] | undefined>)['response-type'] as
929
- | string
930
- | undefined
931
- )?.trim() || DEFAULT_RESPONSE_TYPE
932
- const importTypes =
933
- (
934
- (values as Record<string, string | string[] | undefined>)['import-type'] as
935
- | string[]
936
- | undefined
937
- )?.map(parseImportTypeOption) ?? []
938
-
939
- const routerPath = path.resolve(routerPathArg)
940
- let outputPath: string | undefined
941
- if (values.output) {
942
- outputPath = path.resolve(values.output as string)
943
- } else if (values.write) {
944
- outputPath = path.join(
945
- path.dirname(routerPath),
946
- `${path.basename(routerPath, path.extname(routerPath))}.gen.ts`,
947
- )
948
- }
949
-
950
- const routes = extractRoutes(await loadRuntimeRoutes(routerPath))
951
- const rawArgs = process.argv.slice(1)
952
- if (rawArgs[0] && path.isAbsolute(rawArgs[0])) {
953
- rawArgs[0] = path.relative(process.cwd(), rawArgs[0]).replace(/\\/g, '/')
954
- }
955
- const commandText = ['bun', ...rawArgs.map((arg) => $.escape(arg))].join(' ')
956
-
957
- let client =
958
- format === 'rk-api-client'
959
- ? await buildApiClientSource(routes, {
960
- clientName,
961
- responseType,
962
- importTypes,
963
- commandText,
964
- })
965
- : await buildTsQueryRkProblemSource(routes, {
966
- importTypes,
967
- commandText,
968
- })
969
-
970
- if (outputPath) {
971
- if (values.pretty) {
972
- client = await formatWithPrettier(client, outputPath)
973
- }
974
- fs.writeFileSync(outputPath, client, 'utf8')
975
- console.log(`Wrote API client to ${path.relative(process.cwd(), outputPath)}`)
976
- } else {
977
- console.log(client)
978
- }
979
- }
980
-
981
- if (import.meta.main) {
982
- main().catch((err) => {
983
- console.error(err)
984
- process.exit(1)
985
- })
986
- }