@strav/cli 0.1.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.
@@ -0,0 +1,986 @@
1
+ import { join } from 'node:path'
2
+ import { Archetype } from '@stravigor/database/schema/types'
3
+ import type { SchemaDefinition } from '@stravigor/database/schema/types'
4
+ import type {
5
+ DatabaseRepresentation,
6
+ TableDefinition,
7
+ ColumnDefinition,
8
+ } from '@stravigor/database/schema/database_representation'
9
+ import type { FieldDefinition, FieldValidator } from '@stravigor/database/schema/field_definition'
10
+ import type { PostgreSQLCustomType } from '@stravigor/database/schema/postgres'
11
+ import {
12
+ toSnakeCase,
13
+ toCamelCase,
14
+ toPascalCase,
15
+ pluralize,
16
+ } from '@stravigor/kernel/helpers/strings'
17
+ import type { GeneratedFile } from './model_generator.ts'
18
+ import type { GeneratorConfig, GeneratorPaths } from './config.ts'
19
+ import { resolvePaths } from './config.ts'
20
+ import { ApiRouting, toRouteSegment, toChildSegment } from './route_generator.ts'
21
+ import type { ApiRoutingConfig } from './route_generator.ts'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Archetype behaviour (mirrored from api_generator.ts)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ARCHETYPE_CONTROLLER: Record<Archetype, string[]> = {
28
+ [Archetype.Entity]: ['index', 'show', 'store', 'update', 'destroy'],
29
+ [Archetype.Attribute]: ['index', 'show', 'store', 'update', 'destroy'],
30
+ [Archetype.Contribution]: ['index', 'show', 'store', 'update', 'destroy'],
31
+ [Archetype.Reference]: ['index', 'show', 'store', 'update', 'destroy'],
32
+ [Archetype.Component]: ['index', 'show', 'update'],
33
+ [Archetype.Event]: ['index', 'show', 'store'],
34
+ [Archetype.Configuration]: ['show', 'update', 'destroy'],
35
+ [Archetype.Association]: [],
36
+ }
37
+
38
+ const DEPENDENT_ARCHETYPES: Set<Archetype> = new Set([
39
+ Archetype.Component,
40
+ Archetype.Attribute,
41
+ Archetype.Event,
42
+ Archetype.Configuration,
43
+ Archetype.Contribution,
44
+ ])
45
+
46
+ const SYSTEM_COLUMNS = new Set(['id', 'created_at', 'updated_at', 'deleted_at'])
47
+
48
+ const ARCHETYPE_DESCRIPTIONS: Record<Archetype, string> = {
49
+ [Archetype.Entity]: 'A standalone entity with full CRUD operations and soft delete support.',
50
+ [Archetype.Contribution]:
51
+ 'A user-contributed resource under a parent entity. Full CRUD with soft delete.',
52
+ [Archetype.Reference]:
53
+ 'A lookup/reference table with full CRUD. No soft delete &mdash; records are permanently removed.',
54
+ [Archetype.Attribute]: 'A dependent attribute of a parent entity. Full CRUD with soft delete.',
55
+ [Archetype.Component]:
56
+ 'A tightly-coupled component of a parent entity. Can be listed, viewed, and updated &mdash; but not independently created or deleted.',
57
+ [Archetype.Event]:
58
+ 'An append-only event log under a parent entity. Events can be listed, viewed, and appended &mdash; but never updated or deleted.',
59
+ [Archetype.Configuration]:
60
+ 'A singleton configuration record under a parent entity. One record per parent. Supports show, upsert, and reset.',
61
+ [Archetype.Association]: 'A join table linking two entities.',
62
+ }
63
+
64
+ const API_DEFAULTS: ApiRoutingConfig = {
65
+ routing: ApiRouting.Prefix,
66
+ prefix: '/api',
67
+ subdomain: 'api',
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // DocGenerator
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export default class DocGenerator {
75
+ private apiConfig: ApiRoutingConfig
76
+ private paths: GeneratorPaths
77
+ private schemaMap: Map<string, SchemaDefinition>
78
+
79
+ constructor(
80
+ private schemas: SchemaDefinition[],
81
+ private representation: DatabaseRepresentation,
82
+ config?: GeneratorConfig,
83
+ apiConfig?: Partial<ApiRoutingConfig>
84
+ ) {
85
+ this.apiConfig = { ...API_DEFAULTS, ...apiConfig }
86
+ this.paths = resolvePaths(config)
87
+ this.schemaMap = new Map(schemas.map(s => [s.name, s]))
88
+ }
89
+
90
+ generate(): GeneratedFile[] {
91
+ return [this.generateIndexPage()]
92
+ }
93
+
94
+ async writeAll(): Promise<GeneratedFile[]> {
95
+ const files = this.generate()
96
+ for (const file of files) {
97
+ await Bun.write(file.path, file.content)
98
+ }
99
+ return files
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Main page
104
+ // ---------------------------------------------------------------------------
105
+
106
+ private generateIndexPage(): GeneratedFile {
107
+ const routable = this.schemas.filter(s => s.archetype !== Archetype.Association)
108
+
109
+ // Group by parent for sidebar
110
+ const rootSchemas: SchemaDefinition[] = []
111
+ const childrenOf = new Map<string, SchemaDefinition[]>()
112
+
113
+ for (const s of routable) {
114
+ const routeParent = s.parents?.[0]
115
+ if (DEPENDENT_ARCHETYPES.has(s.archetype) && routeParent) {
116
+ if (!childrenOf.has(routeParent)) childrenOf.set(routeParent, [])
117
+ childrenOf.get(routeParent)!.push(s)
118
+ } else {
119
+ rootSchemas.push(s)
120
+ }
121
+ }
122
+
123
+ const sidebar = this.buildSidebar(rootSchemas, childrenOf)
124
+ const content = this.buildContent(routable)
125
+
126
+ const html = `<!DOCTYPE html>
127
+ <html lang="en" class="scroll-smooth">
128
+ <head>
129
+ <meta charset="UTF-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
131
+ <title>API Reference</title>
132
+ <script src="https://cdn.tailwindcss.com"></script>
133
+ <link rel="preconnect" href="https://fonts.googleapis.com">
134
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
135
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
136
+ <script>
137
+ tailwind.config = {
138
+ theme: {
139
+ extend: {
140
+ fontFamily: {
141
+ sans: ['Inter', 'system-ui', 'sans-serif'],
142
+ mono: ['JetBrains Mono', 'monospace'],
143
+ },
144
+ },
145
+ },
146
+ }
147
+ </script>
148
+ <style>
149
+ html { scroll-padding-top: 6rem; }
150
+ .sidebar-link.active { color: #fff; background: rgba(255,255,255,0.06); }
151
+ .sidebar-link:hover { color: #e4e4e7; background: rgba(255,255,255,0.04); }
152
+ .method-badge { font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; padding: 0.15rem 0.5rem; border-radius: 9999px; text-transform: uppercase; }
153
+ pre code { font-size: 0.8125rem; line-height: 1.625; }
154
+ @media (max-width: 1023px) {
155
+ .sidebar { transform: translateX(-100%); position: fixed; z-index: 50; }
156
+ .sidebar.open { transform: translateX(0); }
157
+ }
158
+ </style>
159
+ </head>
160
+ <body class="bg-white font-sans text-zinc-900 antialiased">
161
+
162
+ <!-- Mobile menu button -->
163
+ <button onclick="document.getElementById('sidebar').classList.toggle('open')"
164
+ class="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-zinc-900 text-white shadow-lg">
165
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
166
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
167
+ </svg>
168
+ </button>
169
+
170
+ <!-- Sidebar -->
171
+ <aside id="sidebar" class="sidebar fixed inset-y-0 left-0 w-72 bg-zinc-900 overflow-y-auto transition-transform duration-200 lg:translate-x-0">
172
+ <div class="px-6 py-8">
173
+ <h1 class="text-lg font-semibold text-white tracking-tight">API Reference</h1>
174
+ <p class="mt-1 text-xs text-zinc-500">Generated by Strav</p>
175
+ </div>
176
+ <nav class="px-3 pb-8">
177
+ ${sidebar}
178
+ </nav>
179
+ </aside>
180
+
181
+ <!-- Main content -->
182
+ <main class="lg:pl-72">
183
+ <div class="max-w-4xl mx-auto px-6 sm:px-8 py-16">
184
+ ${content}
185
+ </div>
186
+ </main>
187
+
188
+ <script>
189
+ // Highlight active sidebar link on scroll
190
+ const observer = new IntersectionObserver(entries => {
191
+ for (const entry of entries) {
192
+ if (entry.isIntersecting) {
193
+ document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
194
+ const link = document.querySelector('.sidebar-link[href="#' + entry.target.id + '"]');
195
+ if (link) link.classList.add('active');
196
+ }
197
+ }
198
+ }, { rootMargin: '-20% 0px -70% 0px' });
199
+ document.querySelectorAll('section[id]').forEach(s => observer.observe(s));
200
+
201
+ // Close mobile sidebar on link click
202
+ document.querySelectorAll('.sidebar-link').forEach(link => {
203
+ link.addEventListener('click', () => {
204
+ document.getElementById('sidebar').classList.remove('open');
205
+ });
206
+ });
207
+ </script>
208
+ </body>
209
+ </html>`
210
+
211
+ return {
212
+ path: join(this.paths.docs, 'index.html'),
213
+ content: html,
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Sidebar
219
+ // ---------------------------------------------------------------------------
220
+
221
+ private buildSidebar(
222
+ rootSchemas: SchemaDefinition[],
223
+ childrenOf: Map<string, SchemaDefinition[]>
224
+ ): string {
225
+ const lines: string[] = []
226
+
227
+ // Introduction & Auth
228
+ lines.push(' <div class="mb-6">')
229
+ lines.push(
230
+ ' <p class="px-3 text-[0.6875rem] font-semibold uppercase tracking-wider text-zinc-500 mb-2">Getting Started</p>'
231
+ )
232
+ lines.push(
233
+ ' <a href="#introduction" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">Introduction</a>'
234
+ )
235
+ lines.push(
236
+ ' <a href="#authentication" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">Authentication</a>'
237
+ )
238
+ lines.push(' </div>')
239
+
240
+ // Resources
241
+ lines.push(' <div>')
242
+ lines.push(
243
+ ' <p class="px-3 text-[0.6875rem] font-semibold uppercase tracking-wider text-zinc-500 mb-2">Resources</p>'
244
+ )
245
+
246
+ const emitSchema = (schema: SchemaDefinition): void => {
247
+ const anchor = toSnakeCase(schema.name)
248
+ const label = this.displayName(schema.name)
249
+ lines.push(
250
+ ` <a href="#${anchor}" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">${label}</a>`
251
+ )
252
+
253
+ const children = childrenOf.get(schema.name)
254
+ if (children?.length) {
255
+ for (const child of children) emitSchema(child)
256
+ }
257
+ }
258
+
259
+ for (const schema of rootSchemas) emitSchema(schema)
260
+
261
+ lines.push(' </div>')
262
+ return lines.join('\n')
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Main content
267
+ // ---------------------------------------------------------------------------
268
+
269
+ private buildContent(routable: SchemaDefinition[]): string {
270
+ const sections: string[] = []
271
+
272
+ sections.push(this.buildIntroduction())
273
+ sections.push(this.buildAuthentication())
274
+
275
+ for (const schema of routable) {
276
+ const table = this.representation.tables.find(t => t.name === toSnakeCase(schema.name))
277
+ if (!table) continue
278
+ sections.push(this.buildResourceSection(schema, table))
279
+ }
280
+
281
+ return sections.join('\n\n')
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Introduction section
286
+ // ---------------------------------------------------------------------------
287
+
288
+ private buildIntroduction(): string {
289
+ const baseUrl =
290
+ this.apiConfig.routing === ApiRouting.Subdomain
291
+ ? `https://${this.apiConfig.subdomain}.&lt;domain&gt;`
292
+ : `https://&lt;domain&gt;${this.apiConfig.prefix}`
293
+
294
+ return ` <section id="introduction" class="mb-20">
295
+ <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">Introduction</h2>
296
+ <div class="mt-4 text-sm leading-relaxed text-zinc-600 space-y-3">
297
+ <p>Welcome to the API reference. This documentation is auto-generated from the application schema definitions.</p>
298
+ <div class="rounded-lg border border-zinc-200 overflow-hidden">
299
+ <table class="w-full text-sm">
300
+ <tbody>
301
+ <tr class="border-b border-zinc-100">
302
+ <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50 w-40">Base URL</td>
303
+ <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">${baseUrl}</td>
304
+ </tr>
305
+ <tr class="border-b border-zinc-100">
306
+ <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50">Content-Type</td>
307
+ <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">application/json</td>
308
+ </tr>
309
+ <tr>
310
+ <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50">Authentication</td>
311
+ <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">Bearer token</td>
312
+ </tr>
313
+ </tbody>
314
+ </table>
315
+ </div>
316
+ </div>
317
+ </section>`
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Authentication section
322
+ // ---------------------------------------------------------------------------
323
+
324
+ private buildAuthentication(): string {
325
+ return ` <section id="authentication" class="mb-20">
326
+ <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">Authentication</h2>
327
+ <div class="mt-4 text-sm leading-relaxed text-zinc-600 space-y-4">
328
+ <p>All API endpoints require authentication via a bearer token. Include the token in the <code class="text-xs font-mono bg-zinc-100 px-1.5 py-0.5 rounded">Authorization</code> header of every request.</p>
329
+ <div class="rounded-lg bg-zinc-900 p-4 overflow-x-auto">
330
+ <pre><code class="text-zinc-100 font-mono">GET ${this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''}/resources HTTP/1.1
331
+ Host: &lt;domain&gt;
332
+ Authorization: Bearer &lt;your-token&gt;
333
+ Content-Type: application/json</code></pre>
334
+ </div>
335
+ <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
336
+ <p class="text-amber-800 text-xs font-medium">Unauthenticated requests</p>
337
+ <p class="text-amber-700 text-xs mt-1">Requests without a valid bearer token will receive a <code class="font-mono bg-amber-100 px-1 py-0.5 rounded">401 Unauthenticated</code> response.</p>
338
+ </div>
339
+ <h3 class="text-base font-semibold text-zinc-900 pt-2">Error responses</h3>
340
+ <div class="rounded-lg bg-zinc-900 p-4 overflow-x-auto">
341
+ <pre><code class="text-zinc-100 font-mono">// 401 Unauthenticated
342
+ { "error": "Unauthenticated" }
343
+
344
+ // 422 Validation Error
345
+ { "errors": { "fieldName": ["Validation message"] } }
346
+
347
+ // 404 Not Found
348
+ { "error": "Not Found" }</code></pre>
349
+ </div>
350
+ </div>
351
+ </section>`
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Per-resource section
356
+ // ---------------------------------------------------------------------------
357
+
358
+ private buildResourceSection(schema: SchemaDefinition, table: TableDefinition): string {
359
+ const anchor = toSnakeCase(schema.name)
360
+ const label = this.displayName(schema.name)
361
+ const actions = ARCHETYPE_CONTROLLER[schema.archetype] ?? []
362
+ const archetypeColor = this.archetypeBadgeColor(schema.archetype)
363
+ const description = ARCHETYPE_DESCRIPTIONS[schema.archetype]
364
+
365
+ const fieldsTable = this.buildFieldsTable(schema, table)
366
+ const endpoints = actions.map(action => this.buildEndpoint(action, schema, table)).join('\n')
367
+
368
+ return ` <section id="${anchor}" class="mb-20">
369
+ <div class="flex items-center gap-3">
370
+ <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">${label}</h2>
371
+ <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-[0.625rem] font-semibold ${archetypeColor}">${schema.archetype}</span>
372
+ </div>
373
+ <p class="mt-2 text-sm text-zinc-500">${description}</p>
374
+ ${schema.parents?.length ? ` <p class="mt-4 text-sm text-zinc-500">Parents: <span class="font-semibold text-zinc-700">${schema.parents.map(p => this.displayName(p)).join(', ')}</span></p>` : ''}
375
+
376
+ <div class="mt-6">
377
+ <h3 class="text-sm font-semibold text-zinc-900 mb-3">Fields</h3>
378
+ ${fieldsTable}
379
+ </div>
380
+
381
+ <div class="mt-8 space-y-8">
382
+ <h3 class="text-sm font-semibold text-zinc-900">Endpoints</h3>
383
+ ${endpoints}
384
+ </div>
385
+ </section>`
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Fields table
390
+ // ---------------------------------------------------------------------------
391
+
392
+ private buildFieldsTable(schema: SchemaDefinition, table: TableDefinition): string {
393
+ const rows: string[] = []
394
+
395
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
396
+ if (fieldDef.primaryKey) continue
397
+ const colName = toSnakeCase(fieldName)
398
+ if (SYSTEM_COLUMNS.has(colName)) continue
399
+
400
+ const camelName = toCamelCase(fieldName)
401
+ const typeLabel = this.fieldTypeLabel(fieldDef)
402
+ const required = fieldDef.required
403
+ ? '<span class="text-rose-500 text-[0.625rem] font-semibold">required</span>'
404
+ : '<span class="text-zinc-400 text-[0.625rem]">optional</span>'
405
+ const sensitive = fieldDef.sensitive
406
+ ? ' <span class="text-amber-500 text-[0.625rem] font-semibold">sensitive</span>'
407
+ : ''
408
+ const validators = this.validatorSummary(fieldDef)
409
+ const validatorHtml = validators
410
+ ? `<span class="text-zinc-400 text-[0.625rem]">${validators}</span>`
411
+ : ''
412
+
413
+ rows.push(` <tr class="border-b border-zinc-100 last:border-0">
414
+ <td class="py-2.5 pl-4 pr-4 font-mono text-xs text-emerald-600 whitespace-nowrap">${camelName}</td>
415
+ <td class="py-2.5 pr-4 text-xs text-zinc-500 whitespace-nowrap">${typeLabel}</td>
416
+ <td class="py-2.5 pr-4">${required}${sensitive}</td>
417
+ <td class="py-2.5 text-xs text-zinc-400">${validatorHtml}</td>
418
+ </tr>`)
419
+ }
420
+
421
+ if (rows.length === 0) {
422
+ return ' <p class="text-xs text-zinc-400 italic">No user-editable fields.</p>'
423
+ }
424
+
425
+ return ` <div class="rounded-lg border border-zinc-200 overflow-hidden">
426
+ <table class="w-full text-sm">
427
+ <thead>
428
+ <tr class="bg-zinc-50 border-b border-zinc-200">
429
+ <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Name</th>
430
+ <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Type</th>
431
+ <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Status</th>
432
+ <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Constraints</th>
433
+ </tr>
434
+ </thead>
435
+ <tbody class="px-4">
436
+ ${rows.join('\n')}
437
+ </tbody>
438
+ </table>
439
+ </div>`
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Per-endpoint block
444
+ // ---------------------------------------------------------------------------
445
+
446
+ private buildEndpoint(action: string, schema: SchemaDefinition, table: TableDefinition): string {
447
+ const { method, pathPattern, description } = this.actionMeta(action, schema)
448
+ const methodColor = this.methodBadgeColor(method)
449
+ const anchor = `${toSnakeCase(schema.name)}-${action}`
450
+
451
+ const bodyFields = this.endpointBodyFields(action, schema, table)
452
+ const bodySection = bodyFields.length > 0 ? this.buildBodyFieldsTable(bodyFields) : ''
453
+
454
+ const exampleRequest = this.buildExampleRequest(method, pathPattern, action, schema, table)
455
+ const exampleResponse = this.buildExampleResponse(action, schema, table)
456
+
457
+ return ` <div id="${anchor}" class="rounded-lg border border-zinc-200 overflow-hidden">
458
+ <div class="flex items-center gap-3 px-4 py-3 bg-zinc-50 border-b border-zinc-200">
459
+ <span class="method-badge ${methodColor}">${method}</span>
460
+ <code class="text-sm font-mono text-zinc-700">${this.escapeHtml(pathPattern)}</code>
461
+ </div>
462
+ <div class="px-4 py-4 space-y-4">
463
+ <p class="text-sm text-zinc-600">${description}</p>
464
+ ${bodySection}
465
+ ${exampleRequest}
466
+ ${exampleResponse}
467
+ </div>
468
+ </div>`
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Body fields table (for store/update endpoints)
473
+ // ---------------------------------------------------------------------------
474
+
475
+ private buildBodyFieldsTable(
476
+ fields: { name: string; type: string; required: boolean; description: string }[]
477
+ ): string {
478
+ const rows = fields.map(f => {
479
+ const req = f.required
480
+ ? '<span class="text-rose-500 text-[0.625rem] font-semibold">required</span>'
481
+ : '<span class="text-zinc-400 text-[0.625rem]">optional</span>'
482
+ return ` <tr class="border-b border-zinc-100 last:border-0">
483
+ <td class="py-2 pl-4 pr-3 font-mono text-xs text-emerald-600 whitespace-nowrap">${f.name}</td>
484
+ <td class="py-2 pr-3 text-xs text-zinc-500">${f.type}</td>
485
+ <td class="py-2 pr-3">${req}</td>
486
+ <td class="py-2 pr-4 text-xs text-zinc-400">${f.description}</td>
487
+ </tr>`
488
+ })
489
+
490
+ return ` <div>
491
+ <p class="text-xs font-semibold text-zinc-700 mb-2">Request body</p>
492
+ <div class="rounded-md border border-zinc-200 overflow-hidden">
493
+ <table class="w-full text-sm">
494
+ <tbody>
495
+ ${rows.join('\n')}
496
+ </tbody>
497
+ </table>
498
+ </div>
499
+ </div>`
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Example request
504
+ // ---------------------------------------------------------------------------
505
+
506
+ private buildExampleRequest(
507
+ method: string,
508
+ path: string,
509
+ action: string,
510
+ schema: SchemaDefinition,
511
+ table: TableDefinition
512
+ ): string {
513
+ const hasBody = action === 'store' || action === 'update'
514
+
515
+ if (!hasBody) {
516
+ return ` <div>
517
+ <p class="text-xs font-semibold text-zinc-700 mb-2">Example request</p>
518
+ <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
519
+ <pre><code class="text-zinc-100 font-mono">curl -X ${method} \\
520
+ https://&lt;domain&gt;${this.escapeHtml(path)} \\
521
+ -H "Authorization: Bearer &lt;token&gt;"</code></pre>
522
+ </div>
523
+ </div>`
524
+ }
525
+
526
+ const payload = this.buildJsonPayload(action, schema, table)
527
+
528
+ return ` <div>
529
+ <p class="text-xs font-semibold text-zinc-700 mb-2">Example request</p>
530
+ <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
531
+ <pre><code class="text-zinc-100 font-mono">curl -X ${method} \\
532
+ https://&lt;domain&gt;${this.escapeHtml(path)} \\
533
+ -H "Authorization: Bearer &lt;token&gt;" \\
534
+ -H "Content-Type: application/json" \\
535
+ -d '${this.escapeHtml(payload)}'</code></pre>
536
+ </div>
537
+ </div>`
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // Example response
542
+ // ---------------------------------------------------------------------------
543
+
544
+ private buildExampleResponse(
545
+ action: string,
546
+ schema: SchemaDefinition,
547
+ table: TableDefinition
548
+ ): string {
549
+ const { statusCode, responseBody } = this.sampleResponse(action, schema, table)
550
+
551
+ return ` <div>
552
+ <p class="text-xs font-semibold text-zinc-700 mb-2">Example response &mdash; <span class="text-emerald-600">${statusCode}</span></p>
553
+ <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
554
+ <pre><code class="text-zinc-100 font-mono">${this.escapeHtml(responseBody)}</code></pre>
555
+ </div>
556
+ </div>`
557
+ }
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // Action metadata
561
+ // ---------------------------------------------------------------------------
562
+
563
+ private actionMeta(
564
+ action: string,
565
+ schema: SchemaDefinition
566
+ ): { method: string; pathPattern: string; description: string } {
567
+ const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype) && !!schema.parents?.length
568
+ const isConfig = schema.archetype === Archetype.Configuration
569
+ const isEvent = schema.archetype === Archetype.Event
570
+ const basePath = this.buildRoutePath(schema)
571
+
572
+ switch (action) {
573
+ case 'index':
574
+ return {
575
+ method: 'GET',
576
+ pathPattern: basePath,
577
+ description: isDependent
578
+ ? `List all ${this.displayNamePlural(schema.name)} under the parent.`
579
+ : `List all ${this.displayNamePlural(schema.name)}.`,
580
+ }
581
+ case 'show':
582
+ return {
583
+ method: 'GET',
584
+ pathPattern: isConfig ? basePath : `${basePath}/:id`,
585
+ description: isConfig
586
+ ? `Retrieve the ${this.displayName(schema.name)} for the parent.`
587
+ : `Retrieve a single ${this.displayName(schema.name)} by ID.`,
588
+ }
589
+ case 'store':
590
+ return {
591
+ method: 'POST',
592
+ pathPattern: basePath,
593
+ description: isEvent
594
+ ? `Append a new ${this.displayName(schema.name)} event.`
595
+ : `Create a new ${this.displayName(schema.name)}.`,
596
+ }
597
+ case 'update':
598
+ return {
599
+ method: 'PUT',
600
+ pathPattern: isConfig ? basePath : `${basePath}/:id`,
601
+ description: isConfig
602
+ ? `Create or update the ${this.displayName(schema.name)} for the parent.`
603
+ : `Update an existing ${this.displayName(schema.name)}.`,
604
+ }
605
+ case 'destroy':
606
+ return {
607
+ method: 'DELETE',
608
+ pathPattern: isConfig ? basePath : `${basePath}/:id`,
609
+ description: isConfig
610
+ ? `Reset the ${this.displayName(schema.name)} to defaults.`
611
+ : `Delete a ${this.displayName(schema.name)}.`,
612
+ }
613
+ default:
614
+ return { method: 'GET', pathPattern: basePath, description: '' }
615
+ }
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Body fields for an endpoint
620
+ // ---------------------------------------------------------------------------
621
+
622
+ private endpointBodyFields(
623
+ action: string,
624
+ schema: SchemaDefinition,
625
+ table: TableDefinition
626
+ ): { name: string; type: string; required: boolean; description: string }[] {
627
+ if (action !== 'store' && action !== 'update') return []
628
+
629
+ const isStore = action === 'store'
630
+ const fields: { name: string; type: string; required: boolean; description: string }[] = []
631
+
632
+ const parentFkCols = new Set(
633
+ (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
634
+ )
635
+
636
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
637
+ if (fieldDef.primaryKey) continue
638
+ if (fieldDef.references) {
639
+ const refPK = this.findSchemaPK(fieldDef.references)
640
+ const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
641
+ if (parentFkCols.has(fkColName)) continue
642
+ if (SYSTEM_COLUMNS.has(fkColName)) continue
643
+
644
+ fields.push({
645
+ name: toCamelCase(fkColName),
646
+ type: 'string',
647
+ required: isStore && fieldDef.required,
648
+ description: `ID of the referenced ${this.displayName(fieldDef.references)}`,
649
+ })
650
+ continue
651
+ }
652
+
653
+ const colName = toSnakeCase(fieldName)
654
+ if (SYSTEM_COLUMNS.has(colName)) continue
655
+ if (parentFkCols.has(colName)) continue
656
+
657
+ fields.push({
658
+ name: toCamelCase(fieldName),
659
+ type: this.fieldTypeLabel(fieldDef),
660
+ required: isStore && fieldDef.required,
661
+ description: this.validatorSummary(fieldDef),
662
+ })
663
+ }
664
+
665
+ return fields
666
+ }
667
+
668
+ // ---------------------------------------------------------------------------
669
+ // JSON payloads
670
+ // ---------------------------------------------------------------------------
671
+
672
+ private buildJsonPayload(
673
+ action: string,
674
+ schema: SchemaDefinition,
675
+ table: TableDefinition
676
+ ): string {
677
+ const entries: Record<string, unknown> = {}
678
+
679
+ const parentFkCols = new Set(
680
+ (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
681
+ )
682
+
683
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
684
+ if (fieldDef.primaryKey) continue
685
+ if (fieldDef.references) {
686
+ const refPK = this.findSchemaPK(fieldDef.references)
687
+ const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
688
+ if (parentFkCols.has(fkColName)) continue
689
+ if (SYSTEM_COLUMNS.has(fkColName)) continue
690
+ entries[toCamelCase(fkColName)] = '<uuid>'
691
+ continue
692
+ }
693
+
694
+ const colName = toSnakeCase(fieldName)
695
+ if (SYSTEM_COLUMNS.has(colName)) continue
696
+ if (parentFkCols.has(colName)) continue
697
+ if (fieldDef.sensitive) continue
698
+
699
+ const camelName = toCamelCase(fieldName)
700
+ entries[camelName] = this.sampleValueLiteral(fieldName, fieldDef)
701
+ }
702
+
703
+ return JSON.stringify(entries, null, 2)
704
+ }
705
+
706
+ private sampleResponse(
707
+ action: string,
708
+ schema: SchemaDefinition,
709
+ table: TableDefinition
710
+ ): { statusCode: number; responseBody: string } {
711
+ if (action === 'destroy') {
712
+ return { statusCode: 200, responseBody: JSON.stringify({ ok: true }, null, 2) }
713
+ }
714
+
715
+ const obj: Record<string, unknown> = {}
716
+ const pkName = this.findSchemaPK(schema.name)
717
+ obj[toCamelCase(pkName)] = this.getPkSample(schema)
718
+
719
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
720
+ if (fieldDef.primaryKey) continue
721
+ const colName = toSnakeCase(fieldName)
722
+ if (colName === 'deleted_at') continue
723
+
724
+ if (fieldDef.references) {
725
+ const refPK = this.findSchemaPK(fieldDef.references)
726
+ const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
727
+ obj[toCamelCase(fkColName)] = '<uuid>'
728
+ continue
729
+ }
730
+
731
+ const camelName = toCamelCase(fieldName)
732
+ if (fieldDef.sensitive) {
733
+ obj[camelName] = '[REDACTED]'
734
+ continue
735
+ }
736
+
737
+ if (colName === 'created_at' || colName === 'updated_at') {
738
+ obj[camelName] = '2025-01-15T10:30:00.000Z'
739
+ continue
740
+ }
741
+
742
+ obj[camelName] = this.sampleValueLiteral(fieldName, fieldDef)
743
+ }
744
+
745
+ const statusCode = action === 'store' ? 201 : 200
746
+ const body = action === 'index' ? JSON.stringify([obj], null, 2) : JSON.stringify(obj, null, 2)
747
+
748
+ return { statusCode, responseBody: body }
749
+ }
750
+
751
+ // ---------------------------------------------------------------------------
752
+ // Route path building (shared with route_generator)
753
+ // ---------------------------------------------------------------------------
754
+
755
+ private buildRoutePath(schema: SchemaDefinition): string {
756
+ const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype)
757
+ const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
758
+
759
+ const routeParent = schema.parents?.[0]
760
+ if (!isDependent || !routeParent) {
761
+ return `${prefix}/${toRouteSegment(schema.name)}`
762
+ }
763
+
764
+ const parentSegment = toRouteSegment(routeParent)
765
+ const childSegment = toChildSegment(schema.name, routeParent)
766
+
767
+ return `${prefix}/${parentSegment}/:parentId/${childSegment}`
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Helpers
772
+ // ---------------------------------------------------------------------------
773
+
774
+ private displayName(name: string): string {
775
+ return toPascalCase(name)
776
+ .replace(/([A-Z])/g, ' $1')
777
+ .trim()
778
+ }
779
+
780
+ private displayNamePlural(name: string): string {
781
+ const display = this.displayName(name).toLowerCase()
782
+ const lastWord = display.split(' ').pop() ?? display
783
+ const pluralLast = pluralize(lastWord)
784
+ const words = display.split(' ')
785
+ words[words.length - 1] = pluralLast
786
+ return words.join(' ')
787
+ }
788
+
789
+ private findSchemaPK(schemaName: string): string {
790
+ const schema = this.schemaMap.get(schemaName)
791
+ if (!schema) return 'id'
792
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
793
+ if (fieldDef.primaryKey) return fieldName
794
+ }
795
+ return 'id'
796
+ }
797
+
798
+ private getPkSample(schema: SchemaDefinition): string {
799
+ for (const [, fieldDef] of Object.entries(schema.fields)) {
800
+ if (fieldDef.primaryKey) {
801
+ if (String(fieldDef.pgType) === 'uuid') return 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
802
+ return '1'
803
+ }
804
+ }
805
+ return '1'
806
+ }
807
+
808
+ private fieldTypeLabel(fieldDef: FieldDefinition): string {
809
+ if (isCustomType(fieldDef.pgType)) {
810
+ return `enum(${toPascalCase((fieldDef.pgType as PostgreSQLCustomType).name)})`
811
+ }
812
+ if (fieldDef.enumValues?.length) {
813
+ return `enum(${fieldDef.enumValues.join(' | ')})`
814
+ }
815
+ const pgType = String(fieldDef.pgType)
816
+ switch (pgType) {
817
+ case 'varchar':
818
+ case 'character_varying':
819
+ case 'char':
820
+ case 'character':
821
+ case 'text':
822
+ return fieldDef.length ? `string(${fieldDef.length})` : 'string'
823
+ case 'uuid':
824
+ return 'uuid'
825
+ case 'integer':
826
+ case 'smallint':
827
+ case 'serial':
828
+ case 'smallserial':
829
+ return 'integer'
830
+ case 'bigint':
831
+ case 'bigserial':
832
+ return 'bigint'
833
+ case 'real':
834
+ case 'double_precision':
835
+ case 'decimal':
836
+ case 'numeric':
837
+ case 'money':
838
+ return fieldDef.precision
839
+ ? `decimal(${fieldDef.precision},${fieldDef.scale ?? 0})`
840
+ : 'number'
841
+ case 'boolean':
842
+ return 'boolean'
843
+ case 'json':
844
+ case 'jsonb':
845
+ return 'json'
846
+ case 'timestamp':
847
+ case 'timestamptz':
848
+ case 'timestamp_with_time_zone':
849
+ return 'datetime'
850
+ case 'date':
851
+ return 'date'
852
+ case 'time':
853
+ case 'timetz':
854
+ return 'time'
855
+ default:
856
+ return pgType
857
+ }
858
+ }
859
+
860
+ private validatorSummary(fieldDef: FieldDefinition): string {
861
+ const parts: string[] = []
862
+ if (fieldDef.unique) parts.push('unique')
863
+ if (fieldDef.length) parts.push(`max ${fieldDef.length} chars`)
864
+ for (const v of fieldDef.validators) {
865
+ switch (v.type) {
866
+ case 'min':
867
+ parts.push(`min: ${v.params?.value ?? 0}`)
868
+ break
869
+ case 'max':
870
+ parts.push(`max: ${v.params?.value ?? 0}`)
871
+ break
872
+ case 'email':
873
+ parts.push('email format')
874
+ break
875
+ case 'url':
876
+ parts.push('URL format')
877
+ break
878
+ case 'regex':
879
+ parts.push('pattern')
880
+ break
881
+ }
882
+ }
883
+ if (fieldDef.enumValues?.length) {
884
+ parts.push(`one of: ${fieldDef.enumValues.join(', ')}`)
885
+ }
886
+ return parts.join(' &middot; ')
887
+ }
888
+
889
+ private sampleValueLiteral(fieldName: string, fieldDef: FieldDefinition): unknown {
890
+ if (fieldDef.enumValues?.length) return fieldDef.enumValues[0]!
891
+
892
+ const pgType = String(fieldDef.pgType)
893
+ switch (pgType) {
894
+ case 'uuid':
895
+ return 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
896
+ case 'boolean':
897
+ return true
898
+ case 'integer':
899
+ case 'smallint':
900
+ case 'serial':
901
+ case 'smallserial':
902
+ return 42
903
+ case 'bigint':
904
+ case 'bigserial':
905
+ return 42
906
+ case 'real':
907
+ case 'double_precision':
908
+ case 'decimal':
909
+ case 'numeric':
910
+ case 'money':
911
+ return 9.99
912
+ case 'json':
913
+ case 'jsonb':
914
+ return { key: 'value' }
915
+ case 'varchar':
916
+ case 'character_varying':
917
+ case 'char':
918
+ case 'character':
919
+ case 'text':
920
+ default: {
921
+ const snake = toSnakeCase(fieldName)
922
+ if (snake === 'email' || snake.endsWith('_email')) return 'user@example.com'
923
+ if (
924
+ snake === 'url' ||
925
+ snake.endsWith('_url') ||
926
+ snake === 'website' ||
927
+ snake === 'homepage'
928
+ )
929
+ return 'https://example.com'
930
+ const label = toPascalCase(fieldName)
931
+ .replace(/([A-Z])/g, ' $1')
932
+ .trim()
933
+ return `Sample ${label}`
934
+ }
935
+ }
936
+ }
937
+
938
+ private archetypeBadgeColor(archetype: Archetype): string {
939
+ switch (archetype) {
940
+ case Archetype.Entity:
941
+ return 'bg-sky-100 text-sky-700'
942
+ case Archetype.Contribution:
943
+ return 'bg-violet-100 text-violet-700'
944
+ case Archetype.Reference:
945
+ return 'bg-zinc-100 text-zinc-700'
946
+ case Archetype.Attribute:
947
+ return 'bg-teal-100 text-teal-700'
948
+ case Archetype.Component:
949
+ return 'bg-indigo-100 text-indigo-700'
950
+ case Archetype.Event:
951
+ return 'bg-amber-100 text-amber-700'
952
+ case Archetype.Configuration:
953
+ return 'bg-rose-100 text-rose-700'
954
+ case Archetype.Association:
955
+ return 'bg-zinc-100 text-zinc-700'
956
+ }
957
+ }
958
+
959
+ private methodBadgeColor(method: string): string {
960
+ switch (method) {
961
+ case 'GET':
962
+ return 'bg-emerald-500/10 text-emerald-600'
963
+ case 'POST':
964
+ return 'bg-sky-500/10 text-sky-600'
965
+ case 'PUT':
966
+ case 'PATCH':
967
+ return 'bg-amber-500/10 text-amber-600'
968
+ case 'DELETE':
969
+ return 'bg-rose-500/10 text-rose-600'
970
+ default:
971
+ return 'bg-zinc-500/10 text-zinc-600'
972
+ }
973
+ }
974
+
975
+ private escapeHtml(str: string): string {
976
+ return str
977
+ .replace(/&/g, '&amp;')
978
+ .replace(/</g, '&lt;')
979
+ .replace(/>/g, '&gt;')
980
+ .replace(/"/g, '&quot;')
981
+ }
982
+ }
983
+
984
+ function isCustomType(pgType: unknown): pgType is PostgreSQLCustomType {
985
+ return typeof pgType === 'object' && pgType !== null && (pgType as any).type === 'custom'
986
+ }