@roundtable-bb/sdk 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/codegen/bundle.mjs +26 -0
- package/codegen/extract-spec.ts +27 -0
- package/codegen/generate.ts +298 -0
- package/openapi.json +3880 -0
- package/package.json +24 -0
- package/project.json +28 -0
- package/src/generated/client.ts +229 -0
- package/src/index.ts +3 -0
- package/src/request.ts +35 -0
- package/tsconfig.json +8 -0
- package/tsconfig.lib.json +13 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles the SDK as an IIFE for legacy frontends (window.Roundtable).
|
|
3
|
+
* Run: node packages/sdk/codegen/bundle.mjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { build } from 'esbuild'
|
|
7
|
+
import { join, dirname } from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const root = join(__dirname, '..')
|
|
12
|
+
|
|
13
|
+
await build({
|
|
14
|
+
entryPoints: [join(root, 'src/index.ts')],
|
|
15
|
+
bundle: true,
|
|
16
|
+
platform: 'browser',
|
|
17
|
+
format: 'iife',
|
|
18
|
+
globalName: 'Roundtable',
|
|
19
|
+
outfile: join(root, 'dist/roundtable.iife.js'),
|
|
20
|
+
minify: true,
|
|
21
|
+
sourcemap: true,
|
|
22
|
+
target: ['es2017'],
|
|
23
|
+
external: [],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
console.log('✓ IIFE bundle written to dist/roundtable.iife.js')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boots the Fastify app (without real connections) and writes openapi.json.
|
|
3
|
+
* Run from workspace root: tsx packages/sdk/codegen/extract-spec.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Dummy env vars — prevent real connection attempts
|
|
7
|
+
process.env['DATABASE_URL'] ??= 'postgresql://x:x@localhost:5432/x'
|
|
8
|
+
process.env['VALKEY_URL'] ??= 'redis://localhost:6379'
|
|
9
|
+
process.env['JWT_SECRET'] ??= 'dummy-for-spec-extraction'
|
|
10
|
+
process.env['BETTER_AUTH_SECRET'] ??= 'dummy-for-spec-extraction'
|
|
11
|
+
process.env['AUTH_DOMAIN'] ??= 'auth.localhost'
|
|
12
|
+
process.env['PLATFORM_OWNER_IDS'] ??= ''
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from 'fs'
|
|
15
|
+
import { join, dirname } from 'path'
|
|
16
|
+
import { fileURLToPath } from 'url'
|
|
17
|
+
import { buildApp } from '../../../apps/api/src/app.js'
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
|
|
21
|
+
const app = await buildApp()
|
|
22
|
+
await app.ready()
|
|
23
|
+
const spec = app.swagger()
|
|
24
|
+
const outPath = join(__dirname, '../openapi.json')
|
|
25
|
+
writeFileSync(outPath, JSON.stringify(spec, null, 2))
|
|
26
|
+
console.log(`✓ OpenAPI spec written to ${outPath}`)
|
|
27
|
+
await app.close()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads openapi.json and generates packages/sdk/src/generated/client.ts.
|
|
3
|
+
* Run from workspace root: tsx packages/sdk/codegen/generate.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
7
|
+
import { join, dirname } from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
9
|
+
import { $ZodRegistry, $ZodType, toJSONSchema } from 'zod/v4/core'
|
|
10
|
+
import * as types from '@roundtable-bb/types'
|
|
11
|
+
import type { ZodTypeAny } from 'zod'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
|
|
15
|
+
// ─── Convert Zod schema → OpenAPI 3.0 JSON Schema (same logic as fastify-type-provider-zod) ─
|
|
16
|
+
|
|
17
|
+
const IDENTITY_KEYWORDS = new Set(['id', '$id', '$schema'])
|
|
18
|
+
|
|
19
|
+
function sanitize(value: unknown): unknown {
|
|
20
|
+
if (Array.isArray(value)) return value.map(sanitize)
|
|
21
|
+
if (value === null || typeof value !== 'object') return value
|
|
22
|
+
const result: Record<string, unknown> = {}
|
|
23
|
+
for (const [k, child] of Object.entries(value as Record<string, unknown>)) {
|
|
24
|
+
if (IDENTITY_KEYWORDS.has(k)) continue
|
|
25
|
+
if (k === 'properties' && child !== null && typeof child === 'object') {
|
|
26
|
+
result[k] = Object.fromEntries(
|
|
27
|
+
Object.entries(child as Record<string, unknown>).map(([n, s]) => [n, sanitize(s)]),
|
|
28
|
+
)
|
|
29
|
+
} else {
|
|
30
|
+
result[k] = sanitize(child)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function zodToOpenApi(schema: ZodTypeAny, io: 'input' | 'output'): Record<string, unknown> {
|
|
37
|
+
const PLACEHOLDER = '__ID__'
|
|
38
|
+
const reg = new $ZodRegistry()
|
|
39
|
+
reg.add(schema as unknown as InstanceType<typeof $ZodType>, { id: PLACEHOLDER })
|
|
40
|
+
const { schemas } = toJSONSchema(reg, {
|
|
41
|
+
target: 'openapi-3.0',
|
|
42
|
+
io,
|
|
43
|
+
cycles: 'ref',
|
|
44
|
+
reused: 'inline',
|
|
45
|
+
unrepresentable: 'any',
|
|
46
|
+
uri: () => '__URI__',
|
|
47
|
+
})
|
|
48
|
+
return sanitize(schemas[PLACEHOLDER]) as Record<string, unknown>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Build known-schema registry (input = request bodies, output = responses) ─
|
|
52
|
+
|
|
53
|
+
type SchemaEntry = { typeName: string; input: Record<string, unknown>; output: Record<string, unknown> }
|
|
54
|
+
const registry: SchemaEntry[] = []
|
|
55
|
+
|
|
56
|
+
for (const [key, value] of Object.entries(types)) {
|
|
57
|
+
if (!key.endsWith('Schema') || key === 'PaginatedResponseSchema') continue
|
|
58
|
+
if (typeof value !== 'object' || !value || !('_zod' in value)) continue
|
|
59
|
+
const typeName = key.replace(/Schema$/, '')
|
|
60
|
+
try {
|
|
61
|
+
const input = zodToOpenApi(value as ZodTypeAny, 'input')
|
|
62
|
+
const output = zodToOpenApi(value as ZodTypeAny, 'output')
|
|
63
|
+
if (Object.keys(output).length > 0) registry.push({ typeName, input, output })
|
|
64
|
+
} catch {
|
|
65
|
+
// skip unconvertible schemas
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Deep equality ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
72
|
+
if (a === b) return true
|
|
73
|
+
if (typeof a !== typeof b || typeof a !== 'object') return false
|
|
74
|
+
if (a === null || b === null) return false
|
|
75
|
+
const ka = Object.keys(a as object).sort()
|
|
76
|
+
const kb = Object.keys(b as object).sort()
|
|
77
|
+
if (ka.length !== kb.length || ka.join('\0') !== kb.join('\0')) return false
|
|
78
|
+
return ka.every((k) =>
|
|
79
|
+
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Schema → TypeScript type ─────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const usedTypes = new Set<string>()
|
|
86
|
+
|
|
87
|
+
function primitiveType(schema: Record<string, unknown>): string {
|
|
88
|
+
if (schema.type === 'string') return 'string'
|
|
89
|
+
if (schema.type === 'integer' || schema.type === 'number') return 'number'
|
|
90
|
+
if (schema.type === 'boolean') return 'boolean'
|
|
91
|
+
if (schema.type === 'null') return 'null'
|
|
92
|
+
if (schema.anyOf) {
|
|
93
|
+
const variants = (schema.anyOf as Record<string, unknown>[]).map(primitiveType)
|
|
94
|
+
return variants.join(' | ')
|
|
95
|
+
}
|
|
96
|
+
return 'unknown'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function matchSchema(schema: Record<string, unknown>, io: 'input' | 'output' = 'output'): string | null {
|
|
100
|
+
// Direct match against known schemas from @roundtable-bb/types
|
|
101
|
+
for (const { typeName, input, output } of registry) {
|
|
102
|
+
if (deepEqual(schema, io === 'input' ? input : output)) {
|
|
103
|
+
usedTypes.add(typeName)
|
|
104
|
+
return typeName
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Array: { type: 'array', items: X }
|
|
109
|
+
if (schema.type === 'array' && schema.items && typeof schema.items === 'object') {
|
|
110
|
+
const itemsSchema = schema.items as Record<string, unknown>
|
|
111
|
+
const inner = matchSchema(itemsSchema, io) ?? primitiveType(itemsSchema)
|
|
112
|
+
if (inner !== 'unknown') return `${inner}[]`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Paginated: { type: 'object', properties: { items: { type: 'array', items: X }, nextCursor: ... } }
|
|
116
|
+
if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') {
|
|
117
|
+
const props = schema.properties as Record<string, Record<string, unknown>>
|
|
118
|
+
const itemsProp = props['items']
|
|
119
|
+
const nextCursorProp = props['nextCursor']
|
|
120
|
+
if (
|
|
121
|
+
itemsProp?.type === 'array' &&
|
|
122
|
+
typeof itemsProp.items === 'object' &&
|
|
123
|
+
nextCursorProp !== undefined
|
|
124
|
+
) {
|
|
125
|
+
const inner = matchSchema(itemsProp.items as Record<string, unknown>, io)
|
|
126
|
+
if (inner) {
|
|
127
|
+
usedTypes.add('PaginatedResponse')
|
|
128
|
+
return `PaginatedResponse<${inner}>`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveType(schema: Record<string, unknown> | undefined, io: 'input' | 'output' = 'output'): string {
|
|
137
|
+
if (!schema) return 'void'
|
|
138
|
+
return matchSchema(schema, io) ?? primitiveType(schema)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Parse OpenAPI spec ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
type Param = { name: string; tsType: string; required: boolean }
|
|
144
|
+
|
|
145
|
+
type Operation = {
|
|
146
|
+
operationId: string
|
|
147
|
+
method: string
|
|
148
|
+
path: string
|
|
149
|
+
pathParams: Param[]
|
|
150
|
+
queryParams: Param[]
|
|
151
|
+
bodyType: string | null
|
|
152
|
+
responseType: string
|
|
153
|
+
summary: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const specPath = join(__dirname, '../openapi.json')
|
|
157
|
+
const spec = JSON.parse(readFileSync(specPath, 'utf-8')) as {
|
|
158
|
+
paths: Record<
|
|
159
|
+
string,
|
|
160
|
+
Record<
|
|
161
|
+
string,
|
|
162
|
+
{
|
|
163
|
+
operationId?: string
|
|
164
|
+
summary?: string
|
|
165
|
+
parameters?: Array<{
|
|
166
|
+
name: string
|
|
167
|
+
in: string
|
|
168
|
+
required?: boolean
|
|
169
|
+
schema?: Record<string, unknown>
|
|
170
|
+
}>
|
|
171
|
+
requestBody?: {
|
|
172
|
+
content?: { 'application/json'?: { schema?: Record<string, unknown> } }
|
|
173
|
+
}
|
|
174
|
+
responses?: Record<
|
|
175
|
+
string,
|
|
176
|
+
{ content?: { 'application/json'?: { schema?: Record<string, unknown> } } }
|
|
177
|
+
>
|
|
178
|
+
}
|
|
179
|
+
>
|
|
180
|
+
>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const operations: Operation[] = []
|
|
184
|
+
|
|
185
|
+
for (const [rawPath, methods] of Object.entries(spec.paths ?? {})) {
|
|
186
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
187
|
+
if (!op.operationId) continue
|
|
188
|
+
|
|
189
|
+
const pathParams: Param[] = (op.parameters ?? [])
|
|
190
|
+
.filter((p) => p.in === 'path')
|
|
191
|
+
.map((p) => ({ name: p.name, tsType: 'string', required: true }))
|
|
192
|
+
|
|
193
|
+
const queryParams: Param[] = (op.parameters ?? [])
|
|
194
|
+
.filter((p) => p.in === 'query')
|
|
195
|
+
.map((p) => ({
|
|
196
|
+
name: p.name,
|
|
197
|
+
tsType: p.schema ? resolveType(p.schema, 'input') : 'string',
|
|
198
|
+
required: !!p.required,
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
const bodySchema = op.requestBody?.content?.['application/json']?.schema
|
|
202
|
+
const bodyType = bodySchema ? resolveType(bodySchema, 'input') : null
|
|
203
|
+
|
|
204
|
+
const [responseCode, responseEntry] =
|
|
205
|
+
Object.entries(op.responses ?? {}).find(([code]) => code.startsWith('2')) ?? [
|
|
206
|
+
'200',
|
|
207
|
+
undefined,
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
const responseType =
|
|
211
|
+
responseCode === '204'
|
|
212
|
+
? 'void'
|
|
213
|
+
: resolveType(
|
|
214
|
+
(
|
|
215
|
+
responseEntry as {
|
|
216
|
+
content?: { 'application/json'?: { schema?: Record<string, unknown> } }
|
|
217
|
+
}
|
|
218
|
+
)?.content?.['application/json']?.schema,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
operations.push({
|
|
222
|
+
operationId: op.operationId,
|
|
223
|
+
method: method.toUpperCase(),
|
|
224
|
+
path: rawPath,
|
|
225
|
+
pathParams,
|
|
226
|
+
queryParams,
|
|
227
|
+
bodyType,
|
|
228
|
+
responseType,
|
|
229
|
+
summary: op.summary ?? '',
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Emit client class ────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function buildTemplatePath(path: string): string {
|
|
237
|
+
const converted = path.replace(/\{([^}]+)\}/g, '${params.$1}')
|
|
238
|
+
return converted.includes('${') ? `\`${converted}\`` : `'${converted}'`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function emitMethod(op: Operation): string {
|
|
242
|
+
const args: string[] = []
|
|
243
|
+
|
|
244
|
+
if (op.pathParams.length > 0) {
|
|
245
|
+
const shape = op.pathParams.map((p) => `${p.name}: ${p.tsType}`).join('; ')
|
|
246
|
+
args.push(`params: { ${shape} }`)
|
|
247
|
+
}
|
|
248
|
+
if (op.bodyType) args.push(`body: ${op.bodyType}`)
|
|
249
|
+
if (op.queryParams.length > 0) {
|
|
250
|
+
const shape = op.queryParams
|
|
251
|
+
.map((p) => `${p.name}${p.required ? '' : '?'}: ${p.tsType}`)
|
|
252
|
+
.join('; ')
|
|
253
|
+
args.push(`query?: { ${shape} }`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const requestArgs: string[] = ['this.opts', `'${op.method}'`, buildTemplatePath(op.path)]
|
|
257
|
+
const requestOpts: string[] = []
|
|
258
|
+
if (op.bodyType) requestOpts.push('body')
|
|
259
|
+
if (op.queryParams.length > 0) requestOpts.push('query')
|
|
260
|
+
if (requestOpts.length > 0) requestArgs.push(`{ ${requestOpts.join(', ')} }`)
|
|
261
|
+
|
|
262
|
+
return [
|
|
263
|
+
` /** ${op.summary} */`,
|
|
264
|
+
` ${op.operationId}(${args.join(', ')}): Promise<${op.responseType}> {`,
|
|
265
|
+
` return request(${requestArgs.join(', ')})`,
|
|
266
|
+
` }`,
|
|
267
|
+
].join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const importedTypes = [...usedTypes].sort()
|
|
271
|
+
const importLine =
|
|
272
|
+
importedTypes.length > 0
|
|
273
|
+
? `import type { ${importedTypes.join(', ')} } from '@roundtable-bb/types'`
|
|
274
|
+
: ''
|
|
275
|
+
|
|
276
|
+
const methods = operations
|
|
277
|
+
.sort((a, b) => a.operationId.localeCompare(b.operationId))
|
|
278
|
+
.map(emitMethod)
|
|
279
|
+
.join('\n\n')
|
|
280
|
+
|
|
281
|
+
const output = `\
|
|
282
|
+
// AUTO-GENERATED — do not edit manually.
|
|
283
|
+
// Run \`nx run @roundtable-bb/sdk:codegen\` to regenerate.
|
|
284
|
+
|
|
285
|
+
${importLine}
|
|
286
|
+
import { request, type RoundtableClientOpts } from '../request.js'
|
|
287
|
+
|
|
288
|
+
export class RoundtableClient {
|
|
289
|
+
constructor(private readonly opts: RoundtableClientOpts) {}
|
|
290
|
+
|
|
291
|
+
${methods}
|
|
292
|
+
}
|
|
293
|
+
`
|
|
294
|
+
|
|
295
|
+
const outDir = join(__dirname, '../src/generated')
|
|
296
|
+
mkdirSync(outDir, { recursive: true })
|
|
297
|
+
writeFileSync(join(outDir, 'client.ts'), output)
|
|
298
|
+
console.log(`✓ Generated ${operations.length} operations → src/generated/client.ts`)
|