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