ts-procedures 6.0.2 → 6.2.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/agent_config/bin/setup.mjs +2 -2
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
- package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/agent_config/lib/install-claude.mjs +1 -1
- package/build/codegen/bin/cli.d.ts +39 -0
- package/build/codegen/bin/cli.js +164 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +180 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/index.d.ts +36 -0
- package/build/codegen/index.js +8 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +22 -4
- package/build/codegen/pipeline.js +44 -86
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/pipeline.test.js +162 -0
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
- package/build/codegen/targets/_shared/error-schemas.js +17 -0
- package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
- package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
- package/build/codegen/targets/_shared/indent.d.ts +6 -0
- package/build/codegen/targets/_shared/indent.js +13 -0
- package/build/codegen/targets/_shared/indent.js.map +1 -0
- package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
- package/build/codegen/targets/_shared/indent.test.js +21 -0
- package/build/codegen/targets/_shared/indent.test.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
- package/build/codegen/targets/_shared/pascal-case.js +13 -0
- package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
- package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
- package/build/codegen/targets/_shared/path-utils.js +20 -0
- package/build/codegen/targets/_shared/path-utils.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
- package/build/codegen/targets/_shared/path-utils.test.js +42 -0
- package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
- package/build/codegen/targets/_shared/pick-defined.js +21 -0
- package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
- package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
- package/build/codegen/targets/_shared/route-slots.js +17 -0
- package/build/codegen/targets/_shared/route-slots.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
- package/build/codegen/targets/_shared/route-slots.test.js +43 -0
- package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +27 -0
- package/build/codegen/targets/_shared/target-run.js +2 -0
- package/build/codegen/targets/_shared/target-run.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.d.ts +24 -0
- package/build/codegen/targets/_shared/write-files.js +35 -0
- package/build/codegen/targets/_shared/write-files.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
- package/build/codegen/targets/_shared/write-files.test.js +79 -0
- package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
- package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
- package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
- package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
- package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
- package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -28
- package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +12 -11
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -1
- package/build/codegen/targets/kotlin/format-kotlin.js +0 -7
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -8
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +27 -10
- package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
- package/build/codegen/targets/kotlin/run.d.ts +11 -0
- package/build/codegen/targets/kotlin/run.js +51 -0
- package/build/codegen/targets/kotlin/run.js.map +1 -0
- package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
- package/build/codegen/targets/swift/access-level.test.js +98 -0
- package/build/codegen/targets/swift/access-level.test.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
- package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
- package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
- package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
- package/build/codegen/targets/swift/emit-route-swift.js +64 -0
- package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
- package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
- package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.d.ts +2 -0
- package/build/codegen/targets/swift/format-swift.js +10 -0
- package/build/codegen/targets/swift/format-swift.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/format-swift.test.js +14 -0
- package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/integration.test.d.ts +1 -0
- package/build/codegen/targets/swift/integration.test.js +53 -0
- package/build/codegen/targets/swift/integration.test.js.map +1 -0
- package/build/codegen/targets/swift/run.d.ts +11 -0
- package/build/codegen/targets/swift/run.js +47 -0
- package/build/codegen/targets/swift/run.js.map +1 -0
- package/build/codegen/targets/ts/run.d.ts +4 -0
- package/build/codegen/targets/ts/run.js +86 -0
- package/build/codegen/targets/ts/run.js.map +1 -0
- package/build/codegen/test-helpers/golden.d.ts +15 -0
- package/build/codegen/test-helpers/golden.js +30 -0
- package/build/codegen/test-helpers/golden.js.map +1 -0
- package/build/codegen/test-helpers/golden.test.d.ts +1 -0
- package/build/codegen/test-helpers/golden.test.js +76 -0
- package/build/codegen/test-helpers/golden.test.js.map +1 -0
- package/docs/codegen-kotlin.md +176 -0
- package/docs/codegen-swift.md +314 -0
- package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
- package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
- package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
- package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
- package/package.json +2 -2
- package/src/codegen/__fixtures__/users-envelope.json +144 -0
- package/src/codegen/bin/cli.test.ts +200 -1
- package/src/codegen/bin/cli.ts +187 -0
- package/src/codegen/index.ts +50 -0
- package/src/codegen/pipeline.test.ts +175 -0
- package/src/codegen/pipeline.ts +58 -101
- package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
- package/src/codegen/targets/_shared/error-schemas.ts +17 -0
- package/src/codegen/targets/_shared/indent.test.ts +25 -0
- package/src/codegen/targets/_shared/indent.ts +12 -0
- package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
- package/src/codegen/targets/_shared/pascal-case.ts +12 -0
- package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
- package/src/codegen/targets/_shared/path-utils.ts +21 -0
- package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
- package/src/codegen/targets/_shared/pick-defined.ts +23 -0
- package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
- package/src/codegen/targets/_shared/route-slots.ts +32 -0
- package/src/codegen/targets/_shared/target-run.ts +28 -0
- package/src/codegen/targets/_shared/write-files.test.ts +110 -0
- package/src/codegen/targets/_shared/write-files.ts +53 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
- package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
- package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.ts +89 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +60 -0
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +26 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +13 -0
- package/src/codegen/targets/kotlin/integration.test.ts +77 -0
- package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
- package/src/codegen/targets/kotlin/run.ts +78 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
- package/src/codegen/targets/swift/access-level.test.ts +108 -0
- package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
- package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
- package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
- package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
- package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
- package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
- package/src/codegen/targets/swift/format-swift.test.ts +23 -0
- package/src/codegen/targets/swift/format-swift.ts +9 -0
- package/src/codegen/targets/swift/integration.test.ts +80 -0
- package/src/codegen/targets/swift/run.ts +74 -0
- package/src/codegen/targets/ts/run.ts +117 -0
- package/src/codegen/test-helpers/golden.test.ts +80 -0
- package/src/codegen/test-helpers/golden.ts +34 -0
package/src/codegen/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
|
|
2
2
|
import { runPipeline, type GeneratedFile } from './pipeline.js'
|
|
3
3
|
import type { AjscOptions } from './emit-types.js'
|
|
4
|
+
import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
5
|
+
import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
|
|
4
6
|
|
|
5
7
|
export interface GenerateClientOptions extends ResolveInput {
|
|
6
8
|
outDir: string
|
|
@@ -11,6 +13,16 @@ export interface GenerateClientOptions extends ResolveInput {
|
|
|
11
13
|
selfContained?: boolean
|
|
12
14
|
serviceName?: string
|
|
13
15
|
cleanOutDir?: boolean
|
|
16
|
+
target?: 'ts' | 'kotlin' | 'swift'
|
|
17
|
+
kotlinPackage?: string
|
|
18
|
+
kotlinSerializer?: 'kotlinx' | 'none'
|
|
19
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
20
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
21
|
+
kotlinEmitter?: KotlinEmitter
|
|
22
|
+
swiftSerializer?: 'codable' | 'none'
|
|
23
|
+
swiftAccessLevel?: 'public' | 'internal'
|
|
24
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
25
|
+
swiftEmitter?: SwiftEmitter
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
|
|
@@ -25,8 +37,46 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
|
|
|
25
37
|
selfContained: options.selfContained,
|
|
26
38
|
serviceName: options.serviceName,
|
|
27
39
|
cleanOutDir: options.cleanOutDir,
|
|
40
|
+
target: options.target,
|
|
41
|
+
kotlinPackage: options.kotlinPackage,
|
|
42
|
+
kotlinSerializer: options.kotlinSerializer,
|
|
43
|
+
unsupportedUnions: options.unsupportedUnions,
|
|
44
|
+
kotlinEmitter: options.kotlinEmitter,
|
|
45
|
+
swiftSerializer: options.swiftSerializer,
|
|
46
|
+
swiftAccessLevel: options.swiftAccessLevel,
|
|
47
|
+
swiftEmitter: options.swiftEmitter,
|
|
28
48
|
})
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
export type { AjscOptions } from './emit-types.js'
|
|
32
52
|
export type { ResolveInput } from './resolve-envelope.js'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @internal Subject to change with ajsc minor versions. Use for test injection
|
|
56
|
+
* only; consumer code should not depend on this shape.
|
|
57
|
+
*/
|
|
58
|
+
export type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @internal Mirrors ajsc's `KotlinConverterOpts` and may shift with ajsc
|
|
62
|
+
* minor versions. Use for test injection only.
|
|
63
|
+
*/
|
|
64
|
+
export type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
|
|
65
|
+
|
|
66
|
+
/** Result shape produced by `KotlinEmitter.emit`; stable, useful for stub builders in tests. */
|
|
67
|
+
export type { KotlinEmitResult } from './targets/kotlin/ajsc-adapter.js'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @internal Subject to change with ajsc minor versions. Use for test injection
|
|
71
|
+
* only; consumer code should not depend on this shape.
|
|
72
|
+
*/
|
|
73
|
+
export type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @internal Mirrors ajsc's `SwiftConverterOpts` and may shift with ajsc
|
|
77
|
+
* minor versions. Use for test injection only.
|
|
78
|
+
*/
|
|
79
|
+
export type { SwiftEmitOptions } from './targets/swift/ajsc-adapter.js'
|
|
80
|
+
|
|
81
|
+
/** Result shape produced by `SwiftEmitter.emit`; stable, useful for stub builders in tests. */
|
|
82
|
+
export type { SwiftEmitResult } from './targets/swift/ajsc-adapter.js'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
2
2
|
import { generateClient } from './index.js'
|
|
3
3
|
import { runPipeline } from './pipeline.js'
|
|
4
|
+
import { createStubKotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
5
|
+
import type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
|
|
4
6
|
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
|
|
5
7
|
import { join } from 'node:path'
|
|
6
8
|
import { tmpdir } from 'node:os'
|
|
@@ -359,3 +361,176 @@ describe('generateClient pipeline', () => {
|
|
|
359
361
|
)
|
|
360
362
|
})
|
|
361
363
|
})
|
|
364
|
+
|
|
365
|
+
describe('runPipeline (kotlin target)', () => {
|
|
366
|
+
it('emits only .kt files when target is "kotlin"', async () => {
|
|
367
|
+
const envelope = {
|
|
368
|
+
basePath: '/api',
|
|
369
|
+
headers: [],
|
|
370
|
+
version: '1' as const,
|
|
371
|
+
routes: [
|
|
372
|
+
{
|
|
373
|
+
kind: 'api',
|
|
374
|
+
name: 'GetUser',
|
|
375
|
+
scope: 'users',
|
|
376
|
+
method: 'GET',
|
|
377
|
+
fullPath: '/users/:id',
|
|
378
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
379
|
+
errors: [],
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
errors: [],
|
|
383
|
+
} as any
|
|
384
|
+
|
|
385
|
+
const files = await runPipeline({
|
|
386
|
+
envelope,
|
|
387
|
+
outDir: 'out',
|
|
388
|
+
dryRun: true,
|
|
389
|
+
target: 'kotlin',
|
|
390
|
+
kotlinPackage: 'com.example.api',
|
|
391
|
+
kotlinEmitter: createStubKotlinEmitter({
|
|
392
|
+
PathParams: {
|
|
393
|
+
code: '@Serializable data class PathParams(val id: String)',
|
|
394
|
+
rootTypeName: 'PathParams',
|
|
395
|
+
extractedTypeNames: [],
|
|
396
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
397
|
+
},
|
|
398
|
+
Response: {
|
|
399
|
+
code: '@Serializable data class Response(val id: String)',
|
|
400
|
+
rootTypeName: 'Response',
|
|
401
|
+
extractedTypeNames: [],
|
|
402
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
expect(files.map((f) => f.path)).toEqual([join('out', 'Users.kt')])
|
|
408
|
+
expect(files[0]!.code).toContain('object Users {')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('does not emit _errors.ts, index.ts, or client runtime files when target is "kotlin"', async () => {
|
|
412
|
+
const envelope = { basePath: '/api', headers: [], version: '1', routes: [], errors: [] } as any
|
|
413
|
+
const files = await runPipeline({
|
|
414
|
+
envelope,
|
|
415
|
+
outDir: 'out',
|
|
416
|
+
dryRun: true,
|
|
417
|
+
target: 'kotlin',
|
|
418
|
+
kotlinPackage: 'p',
|
|
419
|
+
kotlinEmitter: createStubKotlinEmitter({}),
|
|
420
|
+
})
|
|
421
|
+
expect(files.find((f) => f.path.endsWith('_errors.ts'))).toBeUndefined()
|
|
422
|
+
expect(files.find((f) => f.path.endsWith('index.ts'))).toBeUndefined()
|
|
423
|
+
expect(files.find((f) => f.path.endsWith('_client.ts'))).toBeUndefined()
|
|
424
|
+
expect(files.find((f) => f.path.endsWith('_types.ts'))).toBeUndefined()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('threads kotlinSerializer and unsupportedUnions to the emitter', async () => {
|
|
428
|
+
const calls: KotlinEmitOptions[] = []
|
|
429
|
+
const captureEmitter = {
|
|
430
|
+
emit(_s: unknown, opts: KotlinEmitOptions) {
|
|
431
|
+
calls.push(opts)
|
|
432
|
+
return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const envelope = {
|
|
437
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
438
|
+
routes: [
|
|
439
|
+
{
|
|
440
|
+
kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/users/:id',
|
|
441
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
442
|
+
errors: [],
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
} as any
|
|
446
|
+
|
|
447
|
+
await runPipeline({
|
|
448
|
+
envelope, outDir: 'out', dryRun: true,
|
|
449
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
450
|
+
kotlinSerializer: 'none',
|
|
451
|
+
unsupportedUnions: 'fallback',
|
|
452
|
+
kotlinEmitter: captureEmitter,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
456
|
+
for (const c of calls) {
|
|
457
|
+
expect(c.serializer).toBe('none')
|
|
458
|
+
expect(c.unsupportedUnions).toBe('fallback')
|
|
459
|
+
expect(c.inlineTypes).toBe(true)
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('logs a single summary line for skipped stream routes', async () => {
|
|
464
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
465
|
+
try {
|
|
466
|
+
const envelope = {
|
|
467
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
468
|
+
routes: [
|
|
469
|
+
{ kind: 'stream', name: 'WatchA', scope: 's', method: 'GET', path: '/a', schema: {}, errors: [] },
|
|
470
|
+
{ kind: 'stream', name: 'WatchB', scope: 's', method: 'GET', path: '/b', schema: {}, errors: [] },
|
|
471
|
+
{ kind: 'api', name: 'GetThing', scope: 's', method: 'GET', fullPath: '/c', schema: { returnType: { type: 'object' } }, errors: [] },
|
|
472
|
+
],
|
|
473
|
+
} as any
|
|
474
|
+
await runPipeline({
|
|
475
|
+
envelope, outDir: 'out', dryRun: true,
|
|
476
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
477
|
+
kotlinEmitter: createStubKotlinEmitter({
|
|
478
|
+
Response: { code: 'data class Response(val ok: Boolean)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] },
|
|
479
|
+
}),
|
|
480
|
+
})
|
|
481
|
+
const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
|
|
482
|
+
expect(summary).toBeDefined()
|
|
483
|
+
expect(String(summary![0])).toContain('Skipped 2 stream routes')
|
|
484
|
+
expect(String(summary![0])).toContain('WatchA')
|
|
485
|
+
expect(String(summary![0])).toContain('WatchB')
|
|
486
|
+
} finally {
|
|
487
|
+
logSpy.mockRestore()
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('does not log a summary when there are no skipped streams', async () => {
|
|
492
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
493
|
+
try {
|
|
494
|
+
const envelope = {
|
|
495
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
496
|
+
routes: [{ kind: 'api', name: 'X', scope: 's', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] }],
|
|
497
|
+
} as any
|
|
498
|
+
await runPipeline({
|
|
499
|
+
envelope, outDir: 'out', dryRun: true,
|
|
500
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
501
|
+
kotlinEmitter: createStubKotlinEmitter({ Response: { code: 'data class Response(val x: Int)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] } }),
|
|
502
|
+
})
|
|
503
|
+
const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
|
|
504
|
+
expect(summary).toBeUndefined()
|
|
505
|
+
} finally {
|
|
506
|
+
logSpy.mockRestore()
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('forwards ajsc passthroughs (arrayItemNaming/depluralize/uncountableWords) to the kotlin emitter', async () => {
|
|
511
|
+
const calls: KotlinEmitOptions[] = []
|
|
512
|
+
const captureEmitter = {
|
|
513
|
+
emit(_s: unknown, opts: KotlinEmitOptions) {
|
|
514
|
+
calls.push(opts)
|
|
515
|
+
return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const envelope = {
|
|
520
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
521
|
+
routes: [{ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] }],
|
|
522
|
+
} as any
|
|
523
|
+
|
|
524
|
+
await runPipeline({
|
|
525
|
+
envelope, outDir: 'out', dryRun: true,
|
|
526
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
527
|
+
ajsc: { arrayItemNaming: 'Item', depluralize: true, uncountableWords: ['data'] },
|
|
528
|
+
kotlinEmitter: captureEmitter,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
expect(calls.length).toBe(1)
|
|
532
|
+
expect(calls[0]!.arrayItemNaming).toBe('Item')
|
|
533
|
+
expect(calls[0]!.depluralize).toBe(true)
|
|
534
|
+
expect(calls[0]!.uncountableWords).toEqual(['data'])
|
|
535
|
+
})
|
|
536
|
+
})
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
1
|
import { createHash } from 'node:crypto'
|
|
4
2
|
import type { DocEnvelope } from '../implementations/types.js'
|
|
5
3
|
import type { AjscOptions } from './emit-types.js'
|
|
6
4
|
import { groupRoutesByScope } from './group-routes.js'
|
|
7
|
-
import { emitScopeFile } from './emit-scope.js'
|
|
8
|
-
import { emitIndexFile } from './emit-index.js'
|
|
9
|
-
import { emitErrorsFile } from './emit-errors.js'
|
|
10
|
-
import { emitClientTypesFile } from './emit-client-types.js'
|
|
11
|
-
import { emitClientRuntimeFile } from './emit-client-runtime.js'
|
|
12
5
|
import { validateServiceName } from './naming.js'
|
|
6
|
+
import type { GeneratedFile } from './targets/_shared/write-files.js'
|
|
7
|
+
import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
8
|
+
import { runKotlinPipeline } from './targets/kotlin/run.js'
|
|
9
|
+
import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
|
|
10
|
+
import { runSwiftPipeline } from './targets/swift/run.js'
|
|
11
|
+
import { runTsPipeline } from './targets/ts/run.js'
|
|
13
12
|
|
|
14
13
|
export interface PipelineOptions {
|
|
15
14
|
envelope: DocEnvelope
|
|
@@ -21,117 +20,75 @@ export interface PipelineOptions {
|
|
|
21
20
|
selfContained?: boolean
|
|
22
21
|
serviceName?: string
|
|
23
22
|
cleanOutDir?: boolean
|
|
23
|
+
target?: 'ts' | 'kotlin' | 'swift'
|
|
24
|
+
kotlinPackage?: string
|
|
25
|
+
kotlinSerializer?: 'kotlinx' | 'none'
|
|
26
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
27
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
28
|
+
kotlinEmitter?: KotlinEmitter
|
|
29
|
+
swiftSerializer?: 'codable' | 'none'
|
|
30
|
+
swiftAccessLevel?: 'public' | 'internal'
|
|
31
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
32
|
+
swiftEmitter?: SwiftEmitter
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
export
|
|
27
|
-
path: string
|
|
28
|
-
code: string
|
|
29
|
-
}
|
|
35
|
+
export type { GeneratedFile } from './targets/_shared/write-files.js'
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Top-level codegen entry. Validates the service name, computes the source
|
|
39
|
+
* hash, groups routes by scope, then dispatches to the per-target run module.
|
|
40
|
+
*
|
|
41
|
+
* Per-target modules own their language-specific emission and the file-write
|
|
42
|
+
* tail (dryRun / cleanOutDir / mkdir / writeFile via
|
|
43
|
+
* `_shared/write-files.ts`).
|
|
44
|
+
*/
|
|
31
45
|
export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
|
|
32
46
|
const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = false } = options
|
|
33
47
|
const serviceName = options.serviceName ?? 'Api'
|
|
34
48
|
validateServiceName(serviceName)
|
|
49
|
+
|
|
35
50
|
const clientImportPath = selfContained ? './_types' : options.clientImportPath
|
|
36
51
|
if (selfContained && options.clientImportPath != null) {
|
|
37
52
|
console.warn('[ts-procedures-codegen] --self-contained overrides --client-import-path; using ./_types')
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
const hash = createHash('md5').update(JSON.stringify(envelope)).digest('hex')
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const groups = groupRoutesByScope(envelope.routes)
|
|
44
|
-
const groupArray = Array.from(groups.values())
|
|
45
|
-
|
|
46
|
-
// Error keys that will be emitted in `_errors.ts` — only those with a schema.
|
|
47
|
-
// Scope emit uses this to filter `route.errors` so generated code never
|
|
48
|
-
// references an undefined error type.
|
|
49
|
-
const errorKeys = new Set(
|
|
50
|
-
envelope.errors.filter((e) => e.schema != null).map((e) => e.name)
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
if (selfContained) {
|
|
54
|
-
for (const group of groupArray) {
|
|
55
|
-
if (group.scopeKey === '_types' || group.scopeKey === '_client') {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`[ts-procedures-codegen] Scope "${group.scopeKey}" conflicts with self-contained mode reserved filename "${group.scopeKey}.ts". Rename the scope to avoid collision.`
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const files: GeneratedFile[] = []
|
|
64
|
-
|
|
65
|
-
for (const group of groupArray) {
|
|
66
|
-
const rawCode = await emitScopeFile(group, {
|
|
67
|
-
ajsc: ajscOpts,
|
|
68
|
-
clientImportPath,
|
|
69
|
-
namespaceTypes,
|
|
70
|
-
serviceName,
|
|
71
|
-
errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
|
|
72
|
-
})
|
|
73
|
-
const lines = rawCode.split('\n')
|
|
74
|
-
lines.splice(1, 0, hashComment)
|
|
75
|
-
const code = lines.join('\n')
|
|
76
|
-
files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const errorsCode = await emitErrorsFile(envelope.errors, { ajsc: ajscOpts, clientImportPath, namespaceTypes, serviceName })
|
|
80
|
-
const hasErrors = errorsCode != null
|
|
81
|
-
if (errorsCode != null) {
|
|
82
|
-
const errorsLines = errorsCode.split('\n')
|
|
83
|
-
errorsLines.splice(1, 0, hashComment)
|
|
84
|
-
const errorsWithHash = errorsLines.join('\n')
|
|
85
|
-
files.push({ path: join(outDir, '_errors.ts'), code: errorsWithHash })
|
|
86
|
-
}
|
|
56
|
+
const groups = Array.from(groupRoutesByScope(envelope.routes).values())
|
|
87
57
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
58
|
+
const base = {
|
|
59
|
+
envelope,
|
|
60
|
+
outDir,
|
|
61
|
+
hash,
|
|
62
|
+
groups,
|
|
63
|
+
serviceName,
|
|
64
|
+
ajsc: ajscOpts,
|
|
93
65
|
clientImportPath,
|
|
94
|
-
|
|
95
|
-
hasErrors,
|
|
66
|
+
dryRun,
|
|
96
67
|
namespaceTypes,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const indexLines = rawIndexCode.split('\n')
|
|
100
|
-
indexLines.splice(1, 0, hashComment)
|
|
101
|
-
const indexCode = indexLines.join('\n')
|
|
102
|
-
files.push({ path: join(outDir, 'index.ts'), code: indexCode })
|
|
103
|
-
|
|
104
|
-
if (selfContained) {
|
|
105
|
-
const rawTypesCode = await emitClientTypesFile()
|
|
106
|
-
const typesLines = rawTypesCode.split('\n')
|
|
107
|
-
typesLines.splice(1, 0, hashComment)
|
|
108
|
-
const typesCode = typesLines.join('\n')
|
|
109
|
-
files.push({ path: join(outDir, '_types.ts'), code: typesCode })
|
|
110
|
-
|
|
111
|
-
const rawClientCode = await emitClientRuntimeFile()
|
|
112
|
-
const clientLines = rawClientCode.split('\n')
|
|
113
|
-
clientLines.splice(1, 0, hashComment)
|
|
114
|
-
const clientCode = clientLines.join('\n')
|
|
115
|
-
files.push({ path: join(outDir, '_client.ts'), code: clientCode })
|
|
68
|
+
selfContained,
|
|
69
|
+
cleanOutDir,
|
|
116
70
|
}
|
|
117
71
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
72
|
+
switch (options.target) {
|
|
73
|
+
case 'kotlin':
|
|
74
|
+
return runKotlinPipeline({
|
|
75
|
+
...base,
|
|
76
|
+
kotlinPackage: options.kotlinPackage,
|
|
77
|
+
kotlinSerializer: options.kotlinSerializer,
|
|
78
|
+
unsupportedUnions: options.unsupportedUnions,
|
|
79
|
+
kotlinEmitter: options.kotlinEmitter,
|
|
80
|
+
})
|
|
81
|
+
case 'swift':
|
|
82
|
+
return runSwiftPipeline({
|
|
83
|
+
...base,
|
|
84
|
+
swiftSerializer: options.swiftSerializer,
|
|
85
|
+
swiftAccessLevel: options.swiftAccessLevel,
|
|
86
|
+
unsupportedUnions: options.unsupportedUnions,
|
|
87
|
+
swiftEmitter: options.swiftEmitter,
|
|
88
|
+
})
|
|
89
|
+
case 'ts':
|
|
90
|
+
case undefined:
|
|
91
|
+
default:
|
|
92
|
+
return runTsPipeline(base)
|
|
134
93
|
}
|
|
135
|
-
|
|
136
|
-
return files
|
|
137
94
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildErrorSchemasMap } from './error-schemas.js'
|
|
3
|
+
import type { ErrorDoc } from '../../../implementations/types.js'
|
|
4
|
+
|
|
5
|
+
describe('buildErrorSchemasMap', () => {
|
|
6
|
+
it('returns an empty map when there are no errors', () => {
|
|
7
|
+
expect(buildErrorSchemasMap([])).toEqual(new Map())
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('includes errors with schemas', () => {
|
|
11
|
+
const schemaA = { type: 'object', tag: 'a' }
|
|
12
|
+
const schemaB = { type: 'object', tag: 'b' }
|
|
13
|
+
const errors: ErrorDoc[] = [
|
|
14
|
+
{ name: 'A', statusCode: 400, description: '', schema: schemaA },
|
|
15
|
+
{ name: 'B', statusCode: 500, description: '', schema: schemaB },
|
|
16
|
+
]
|
|
17
|
+
const map = buildErrorSchemasMap(errors)
|
|
18
|
+
expect(map.size).toBe(2)
|
|
19
|
+
expect(map.get('A')).toBe(schemaA)
|
|
20
|
+
expect(map.get('B')).toBe(schemaB)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('skips errors without a schema', () => {
|
|
24
|
+
const schemaA = { type: 'object' }
|
|
25
|
+
const errors: ErrorDoc[] = [
|
|
26
|
+
{ name: 'A', statusCode: 400, description: '', schema: schemaA },
|
|
27
|
+
{ name: 'NoSchema', statusCode: 500, description: '' },
|
|
28
|
+
]
|
|
29
|
+
const map = buildErrorSchemasMap(errors)
|
|
30
|
+
expect(map.size).toBe(1)
|
|
31
|
+
expect(map.has('A')).toBe(true)
|
|
32
|
+
expect(map.has('NoSchema')).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keys the map by error name', () => {
|
|
36
|
+
const schema = { type: 'object' }
|
|
37
|
+
const map = buildErrorSchemasMap([
|
|
38
|
+
{ name: 'NotFound', statusCode: 404, description: '', schema },
|
|
39
|
+
])
|
|
40
|
+
expect(Array.from(map.keys())).toEqual(['NotFound'])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ErrorDoc } from '../../../implementations/types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a lookup map from a {@link DocEnvelope}'s top-level `errors` array
|
|
5
|
+
* keyed by `name`. Only entries with a `schema` are included — errors
|
|
6
|
+
* documented without a schema cannot have a corresponding generated type.
|
|
7
|
+
*
|
|
8
|
+
* Targets pass this map to per-route emission so taxonomy keys on
|
|
9
|
+
* `route.errors` (a `string[]`) can be resolved to actual JSON Schemas.
|
|
10
|
+
*/
|
|
11
|
+
export function buildErrorSchemasMap(errors: readonly ErrorDoc[]): Map<string, unknown> {
|
|
12
|
+
const errorSchemas = new Map<string, unknown>()
|
|
13
|
+
for (const e of errors) {
|
|
14
|
+
if (e.schema != null) errorSchemas.set(e.name, e.schema)
|
|
15
|
+
}
|
|
16
|
+
return errorSchemas
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { indent } from './indent.js'
|
|
3
|
+
|
|
4
|
+
describe('indent', () => {
|
|
5
|
+
it('indents every line by 4 spaces per level', () => {
|
|
6
|
+
expect(indent('a\nb', 1)).toBe(' a\n b')
|
|
7
|
+
expect(indent('a', 2)).toBe(' a')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('preserves blank lines without trailing whitespace when indenting', () => {
|
|
11
|
+
expect(indent('a\n\nb', 1)).toBe(' a\n\n b')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns the input unchanged at level 0', () => {
|
|
15
|
+
expect(indent('a\n\nb', 0)).toBe('a\n\nb')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('indents a single line', () => {
|
|
19
|
+
expect(indent('hello', 1)).toBe(' hello')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns empty string for empty input', () => {
|
|
23
|
+
expect(indent('', 1)).toBe('')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indents every non-blank line of `text` by `level * 4` spaces. Blank lines
|
|
3
|
+
* are preserved as empty (no trailing whitespace). Suitable for languages
|
|
4
|
+
* that use 4-space indentation (Kotlin, Swift).
|
|
5
|
+
*/
|
|
6
|
+
export function indent(text: string, level: number): string {
|
|
7
|
+
const prefix = ' '.repeat(level)
|
|
8
|
+
return text
|
|
9
|
+
.split('\n')
|
|
10
|
+
.map((line) => (line.length === 0 ? line : `${prefix}${line}`))
|
|
11
|
+
.join('\n')
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pascalCase } from './pascal-case.js'
|
|
3
|
+
|
|
4
|
+
describe('pascalCase', () => {
|
|
5
|
+
it('converts a single-word scope', () => {
|
|
6
|
+
expect(pascalCase('users')).toBe('Users')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('converts a kebab-case scope', () => {
|
|
10
|
+
expect(pascalCase('user-management')).toBe('UserManagement')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('handles multi-segment kebab-case', () => {
|
|
14
|
+
expect(pascalCase('a-b-c')).toBe('ABC')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('drops empty segments from leading/trailing/double dashes', () => {
|
|
18
|
+
expect(pascalCase('-users')).toBe('Users')
|
|
19
|
+
expect(pascalCase('users-')).toBe('Users')
|
|
20
|
+
expect(pascalCase('user--mgmt')).toBe('UserMgmt')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns empty string for empty input', () => {
|
|
24
|
+
expect(pascalCase('')).toBe('')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('preserves casing of letters after the leading character', () => {
|
|
28
|
+
expect(pascalCase('user-API')).toBe('UserAPI')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts a kebab-case (or single-word) scope key to PascalCase. Used by
|
|
3
|
+
* codegen targets to derive top-level type/namespace identifiers from scope
|
|
4
|
+
* names (e.g. `user-management` → `UserManagement`).
|
|
5
|
+
*/
|
|
6
|
+
export function pascalCase(scope: string): string {
|
|
7
|
+
return scope
|
|
8
|
+
.split('-')
|
|
9
|
+
.filter((p) => p.length > 0)
|
|
10
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
11
|
+
.join('')
|
|
12
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { COLON_PARAM_RE, toBracePath, pathParamNames } from './path-utils.js'
|
|
3
|
+
|
|
4
|
+
describe('path-utils', () => {
|
|
5
|
+
describe('toBracePath', () => {
|
|
6
|
+
it('converts colon params to brace form', () => {
|
|
7
|
+
expect(toBracePath('/users/:id')).toBe('/users/{id}')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('converts multiple params', () => {
|
|
11
|
+
expect(toBracePath('/orgs/:orgId/users/:userId')).toBe('/orgs/{orgId}/users/{userId}')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns the template unchanged when there are no params', () => {
|
|
15
|
+
expect(toBracePath('/users')).toBe('/users')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('supports underscore-prefixed param names', () => {
|
|
19
|
+
expect(toBracePath('/x/:_id')).toBe('/x/{_id}')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not match leading-digit identifiers', () => {
|
|
23
|
+
// ":1abc" is not a valid identifier; should not be rewritten.
|
|
24
|
+
expect(toBracePath('/x/:1abc')).toBe('/x/:1abc')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('pathParamNames', () => {
|
|
29
|
+
it('returns an empty list for templates without params', () => {
|
|
30
|
+
expect(pathParamNames('/users')).toEqual([])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns the single param name', () => {
|
|
34
|
+
expect(pathParamNames('/users/:id')).toEqual(['id'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('preserves the order of multiple params', () => {
|
|
38
|
+
expect(pathParamNames('/orgs/:orgId/users/:userId')).toEqual(['orgId', 'userId'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('matches identifiers with digits and underscores', () => {
|
|
42
|
+
expect(pathParamNames('/x/:user_id1')).toEqual(['user_id1'])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('COLON_PARAM_RE', () => {
|
|
47
|
+
it('is a global regex (so matchAll works)', () => {
|
|
48
|
+
expect(COLON_PARAM_RE.global).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic path-template utilities used by every codegen target that
|
|
3
|
+
* emits HTTP routes. Routes carry colon-prefixed path templates (e.g.
|
|
4
|
+
* `/users/:id`); targets typically need both the brace form (`/users/{id}`)
|
|
5
|
+
* for documentation/template constants and the parameter-name list for
|
|
6
|
+
* generating typed path-builder functions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
|
|
10
|
+
|
|
11
|
+
/** Converts colon-prefixed path params (`:foo`) to brace form (`{foo}`). */
|
|
12
|
+
export function toBracePath(template: string): string {
|
|
13
|
+
return template.replace(COLON_PARAM_RE, '{$1}')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extracts the parameter names from a colon-prefixed path template. */
|
|
17
|
+
export function pathParamNames(template: string): string[] {
|
|
18
|
+
const names: string[] = []
|
|
19
|
+
for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
|
|
20
|
+
return names
|
|
21
|
+
}
|