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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
3
|
+
import { emitSwiftRoute } from './emit-route-swift.js'
|
|
4
|
+
import { createStubSwiftEmitter, type SwiftEmitOptions, type SwiftEmitResult } from './ajsc-adapter.js'
|
|
5
|
+
|
|
6
|
+
const ok = (code: string, rootTypeName: string): SwiftEmitResult => ({
|
|
7
|
+
code,
|
|
8
|
+
rootTypeName,
|
|
9
|
+
extractedTypeNames: [],
|
|
10
|
+
imports: ['Foundation'],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const noErrors = new Map<string, unknown>()
|
|
14
|
+
|
|
15
|
+
interface CapturedCall { schema: unknown; opts: SwiftEmitOptions }
|
|
16
|
+
|
|
17
|
+
function makeSpyEmitter(results: Record<string, SwiftEmitResult>) {
|
|
18
|
+
const calls: CapturedCall[] = []
|
|
19
|
+
const emitter = {
|
|
20
|
+
emit(schema: unknown, opts: SwiftEmitOptions) {
|
|
21
|
+
calls.push({ schema, opts })
|
|
22
|
+
const r = results[opts.rootTypeName]
|
|
23
|
+
if (r == null) throw new Error(`No stubbed result for "${opts.rootTypeName}"`)
|
|
24
|
+
return r
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
return { emitter, calls }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('emitSwiftRoute', () => {
|
|
31
|
+
it('emits an api-kind route with path params and a response', () => {
|
|
32
|
+
const route: AnyHttpRouteDoc = {
|
|
33
|
+
kind: 'api',
|
|
34
|
+
name: 'GetUser',
|
|
35
|
+
method: 'GET',
|
|
36
|
+
fullPath: '/users/:id',
|
|
37
|
+
schema: {
|
|
38
|
+
input: {
|
|
39
|
+
pathParams: { type: 'object' },
|
|
40
|
+
},
|
|
41
|
+
returnType: { type: 'object' },
|
|
42
|
+
},
|
|
43
|
+
errors: [],
|
|
44
|
+
} as unknown as AnyHttpRouteDoc
|
|
45
|
+
|
|
46
|
+
const emitter = createStubSwiftEmitter({
|
|
47
|
+
PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
|
|
48
|
+
Response: ok('public struct Response: Codable { public let id: String; public let name: String }', 'Response'),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const result = emitSwiftRoute(route, emitter, noErrors)
|
|
52
|
+
|
|
53
|
+
expect(result.imports).toContain('Foundation')
|
|
54
|
+
expect(result.code).toContain('public static let method = "GET"')
|
|
55
|
+
expect(result.code).toContain('public static let pathTemplate = "/users/{id}"')
|
|
56
|
+
expect(result.code).toContain('public static func path(_ p: PathParams) -> String { return "/users/\\(p.id)" }')
|
|
57
|
+
expect(result.code).toContain('public struct PathParams: Codable { public let id: String }')
|
|
58
|
+
expect(result.code).toContain('public struct Response: Codable { public let id: String; public let name: String }')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('emits a route with no path params using a path constant', () => {
|
|
62
|
+
const route = {
|
|
63
|
+
kind: 'api',
|
|
64
|
+
name: 'CreateUser',
|
|
65
|
+
method: 'POST',
|
|
66
|
+
fullPath: '/users',
|
|
67
|
+
schema: { input: { body: { type: 'object' } }, returnType: { type: 'object' } },
|
|
68
|
+
errors: [],
|
|
69
|
+
} as unknown as AnyHttpRouteDoc
|
|
70
|
+
|
|
71
|
+
const emitter = createStubSwiftEmitter({
|
|
72
|
+
Body: ok('public struct Body: Codable { public let name: String }', 'Body'),
|
|
73
|
+
Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const result = emitSwiftRoute(route, emitter, noErrors)
|
|
77
|
+
expect(result.code).toContain('public static let path = "/users"')
|
|
78
|
+
expect(result.code).not.toContain('static func path(')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('emits an Errors namespace for routes whose error keys have schemas in the envelope', () => {
|
|
82
|
+
const route = {
|
|
83
|
+
kind: 'api',
|
|
84
|
+
name: 'GetUser',
|
|
85
|
+
method: 'GET',
|
|
86
|
+
fullPath: '/users/:id',
|
|
87
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
88
|
+
errors: ['NotFound'],
|
|
89
|
+
} as unknown as AnyHttpRouteDoc
|
|
90
|
+
|
|
91
|
+
const emitter = createStubSwiftEmitter({
|
|
92
|
+
PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
|
|
93
|
+
Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
|
|
94
|
+
NotFound: ok('public struct NotFound: Codable { public let name: String; public let message: String }', 'NotFound'),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const errorSchemas = new Map<string, unknown>([['NotFound', { type: 'object' }]])
|
|
98
|
+
const result = emitSwiftRoute(route, emitter, errorSchemas)
|
|
99
|
+
expect(result.code).toContain('public enum Errors {')
|
|
100
|
+
expect(result.code).toContain('public struct NotFound: Codable { public let name: String; public let message: String }')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('silently skips error keys with no schema in the envelope map', () => {
|
|
104
|
+
const route = {
|
|
105
|
+
kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users',
|
|
106
|
+
schema: {}, errors: ['UnknownTaxonomyKey'],
|
|
107
|
+
} as unknown as AnyHttpRouteDoc
|
|
108
|
+
const result = emitSwiftRoute(route, createStubSwiftEmitter({}), new Map())
|
|
109
|
+
expect(result.code).not.toContain('enum Errors {')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns skipped:true for stream routes', () => {
|
|
113
|
+
const route = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/users/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
114
|
+
const result = emitSwiftRoute(route, createStubSwiftEmitter({}), noErrors)
|
|
115
|
+
expect(result.code).toBe('')
|
|
116
|
+
expect(result.skipped).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('passes inlineTypes:true plus serializer/accessLevel/unsupportedUnions to every slot emit', () => {
|
|
120
|
+
const route = {
|
|
121
|
+
kind: 'api',
|
|
122
|
+
name: 'GetUser',
|
|
123
|
+
method: 'GET',
|
|
124
|
+
fullPath: '/users/:id',
|
|
125
|
+
schema: {
|
|
126
|
+
input: { pathParams: { type: 'object' } },
|
|
127
|
+
returnType: { type: 'object' },
|
|
128
|
+
},
|
|
129
|
+
errors: ['NotFound'],
|
|
130
|
+
} as unknown as AnyHttpRouteDoc
|
|
131
|
+
|
|
132
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
133
|
+
PathParams: ok('public struct PathParams { public let id: String }', 'PathParams'),
|
|
134
|
+
Response: ok('public struct Response { public let id: String }', 'Response'),
|
|
135
|
+
NotFound: ok('public struct NotFound { public let message: String }', 'NotFound'),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
emitSwiftRoute(route, emitter, new Map([['NotFound', { type: 'object' }]]), {
|
|
139
|
+
serializer: 'none',
|
|
140
|
+
accessLevel: 'public',
|
|
141
|
+
unsupportedUnions: 'fallback',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// 3 emits: PathParams, Response, NotFound
|
|
145
|
+
expect(calls.length).toBe(3)
|
|
146
|
+
for (const c of calls) {
|
|
147
|
+
expect(c.opts.inlineTypes).toBe(true)
|
|
148
|
+
expect(c.opts.serializer).toBe('none')
|
|
149
|
+
expect(c.opts.accessLevel).toBe('public')
|
|
150
|
+
expect(c.opts.unsupportedUnions).toBe('fallback')
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('preserves slot order: pathParams → query → body → response → errors', () => {
|
|
155
|
+
const route = {
|
|
156
|
+
kind: 'api',
|
|
157
|
+
name: 'X',
|
|
158
|
+
method: 'POST',
|
|
159
|
+
fullPath: '/x/:id',
|
|
160
|
+
schema: {
|
|
161
|
+
input: {
|
|
162
|
+
pathParams: { type: 'object' },
|
|
163
|
+
query: { type: 'object' },
|
|
164
|
+
body: { type: 'object' },
|
|
165
|
+
},
|
|
166
|
+
returnType: { type: 'object' },
|
|
167
|
+
},
|
|
168
|
+
errors: ['Z'],
|
|
169
|
+
} as unknown as AnyHttpRouteDoc
|
|
170
|
+
|
|
171
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
172
|
+
PathParams: ok('a', 'PathParams'),
|
|
173
|
+
Query: ok('b', 'Query'),
|
|
174
|
+
Body: ok('c', 'Body'),
|
|
175
|
+
Response: ok('d', 'Response'),
|
|
176
|
+
Z: ok('e', 'Z'),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
emitSwiftRoute(route, emitter, new Map([['Z', { type: 'object' }]]), {})
|
|
180
|
+
|
|
181
|
+
expect(calls.map((c) => c.opts.rootTypeName)).toEqual([
|
|
182
|
+
'PathParams', 'Query', 'Body', 'Response', 'Z',
|
|
183
|
+
])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('threads passthrough opts (arrayItemNaming/depluralize/uncountableWords) verbatim', () => {
|
|
187
|
+
const route = {
|
|
188
|
+
kind: 'api',
|
|
189
|
+
name: 'X',
|
|
190
|
+
method: 'GET',
|
|
191
|
+
fullPath: '/x',
|
|
192
|
+
schema: { returnType: { type: 'object' } },
|
|
193
|
+
errors: [],
|
|
194
|
+
} as unknown as AnyHttpRouteDoc
|
|
195
|
+
|
|
196
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
197
|
+
Response: ok('public struct Response: Codable { public let ok: Bool }', 'Response'),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
emitSwiftRoute(route, emitter, new Map(), {
|
|
201
|
+
arrayItemNaming: false,
|
|
202
|
+
depluralize: true,
|
|
203
|
+
uncountableWords: ['data', 'metadata'],
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(calls).toHaveLength(1)
|
|
207
|
+
expect(calls[0]!.opts.arrayItemNaming).toBe(false)
|
|
208
|
+
expect(calls[0]!.opts.depluralize).toBe(true)
|
|
209
|
+
expect(calls[0]!.opts.uncountableWords).toEqual(['data', 'metadata'])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('does not include passthrough keys when caller omits them', () => {
|
|
213
|
+
const route = {
|
|
214
|
+
kind: 'api',
|
|
215
|
+
name: 'X',
|
|
216
|
+
method: 'GET',
|
|
217
|
+
fullPath: '/x',
|
|
218
|
+
schema: { returnType: { type: 'object' } },
|
|
219
|
+
errors: [],
|
|
220
|
+
} as unknown as AnyHttpRouteDoc
|
|
221
|
+
|
|
222
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
223
|
+
Response: ok('public struct Response: Codable { public let ok: Bool }', 'Response'),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
emitSwiftRoute(route, emitter, new Map(), {})
|
|
227
|
+
|
|
228
|
+
expect(calls).toHaveLength(1)
|
|
229
|
+
// Conditional-spread invariant: undefined opts must NOT be forwarded as keys
|
|
230
|
+
// (otherwise ajsc would receive `{ arrayItemNaming: undefined }` etc, which
|
|
231
|
+
// could shadow ajsc-side defaults).
|
|
232
|
+
expect('arrayItemNaming' in calls[0]!.opts).toBe(false)
|
|
233
|
+
expect('depluralize' in calls[0]!.opts).toBe(false)
|
|
234
|
+
expect('uncountableWords' in calls[0]!.opts).toBe(false)
|
|
235
|
+
// serializer / accessLevel / unsupportedUnions same invariant
|
|
236
|
+
expect('serializer' in calls[0]!.opts).toBe(false)
|
|
237
|
+
expect('accessLevel' in calls[0]!.opts).toBe(false)
|
|
238
|
+
expect('unsupportedUnions' in calls[0]!.opts).toBe(false)
|
|
239
|
+
// inlineTypes is always set
|
|
240
|
+
expect(calls[0]!.opts.inlineTypes).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('defaults the static-member access level to public', () => {
|
|
244
|
+
const route = {
|
|
245
|
+
kind: 'api',
|
|
246
|
+
name: 'GetUser',
|
|
247
|
+
method: 'GET',
|
|
248
|
+
fullPath: '/users/:id',
|
|
249
|
+
schema: { input: { pathParams: { type: 'object' } } },
|
|
250
|
+
errors: [],
|
|
251
|
+
} as unknown as AnyHttpRouteDoc
|
|
252
|
+
|
|
253
|
+
const emitter = createStubSwiftEmitter({
|
|
254
|
+
PathParams: ok('public struct PathParams { public let id: String }', 'PathParams'),
|
|
255
|
+
})
|
|
256
|
+
const result = emitSwiftRoute(route, emitter, noErrors)
|
|
257
|
+
expect(result.code).toContain('public static let method = "GET"')
|
|
258
|
+
expect(result.code).toContain('public static let pathTemplate = "/users/{id}"')
|
|
259
|
+
expect(result.code).toContain('public static func path(_ p: PathParams) -> String')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('honors accessLevel="internal" on the static members and the Errors enum', () => {
|
|
263
|
+
const route = {
|
|
264
|
+
kind: 'api',
|
|
265
|
+
name: 'GetUser',
|
|
266
|
+
method: 'GET',
|
|
267
|
+
fullPath: '/users/:id',
|
|
268
|
+
schema: { input: { pathParams: { type: 'object' } } },
|
|
269
|
+
errors: ['NotFound'],
|
|
270
|
+
} as unknown as AnyHttpRouteDoc
|
|
271
|
+
|
|
272
|
+
const emitter = createStubSwiftEmitter({
|
|
273
|
+
PathParams: ok('internal struct PathParams { internal let id: String }', 'PathParams'),
|
|
274
|
+
NotFound: ok('internal struct NotFound { internal let message: String }', 'NotFound'),
|
|
275
|
+
})
|
|
276
|
+
const result = emitSwiftRoute(
|
|
277
|
+
route,
|
|
278
|
+
emitter,
|
|
279
|
+
new Map([['NotFound', { type: 'object' }]]),
|
|
280
|
+
{ accessLevel: 'internal' },
|
|
281
|
+
)
|
|
282
|
+
expect(result.code).toContain('internal static let method = "GET"')
|
|
283
|
+
expect(result.code).toContain('internal static let pathTemplate = "/users/{id}"')
|
|
284
|
+
expect(result.code).toContain('internal static func path(_ p: PathParams) -> String')
|
|
285
|
+
expect(result.code).toContain('internal enum Errors {')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('emits accessLevel="internal" on the path constant for no-param routes', () => {
|
|
289
|
+
const route = {
|
|
290
|
+
kind: 'api',
|
|
291
|
+
name: 'CreateUser',
|
|
292
|
+
method: 'POST',
|
|
293
|
+
fullPath: '/users',
|
|
294
|
+
schema: {},
|
|
295
|
+
errors: [],
|
|
296
|
+
} as unknown as AnyHttpRouteDoc
|
|
297
|
+
const result = emitSwiftRoute(route, createStubSwiftEmitter({}), noErrors, { accessLevel: 'internal' })
|
|
298
|
+
expect(result.code).toContain('internal static let path = "/users"')
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
2
|
+
import type { SwiftEmitter, SwiftEmitOptions } from './ajsc-adapter.js'
|
|
3
|
+
import { indent } from '../_shared/indent.js'
|
|
4
|
+
import { pickDefined } from '../_shared/pick-defined.js'
|
|
5
|
+
import { toBracePath, pathParamNames } from '../_shared/path-utils.js'
|
|
6
|
+
import { extractRouteSlots } from '../_shared/route-slots.js'
|
|
7
|
+
|
|
8
|
+
export interface EmitRouteResult {
|
|
9
|
+
/** Inner body of the `enum RouteName { ... }` block — already indented one level. */
|
|
10
|
+
code: string
|
|
11
|
+
/** Imports collected from every ajsc emit + any helpers this route used. */
|
|
12
|
+
imports: string[]
|
|
13
|
+
/** Outer route name used as the `enum RouteName` identifier. */
|
|
14
|
+
routeName: string
|
|
15
|
+
/** True when the route was a stream (out-of-scope). Caller logs once. */
|
|
16
|
+
skipped?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Subset of SwiftEmitOptions threaded by the pipeline; per-call rootTypeName is set inside. */
|
|
20
|
+
export type EmitRouteOpts = Omit<SwiftEmitOptions, 'rootTypeName' | 'inlineTypes'>
|
|
21
|
+
|
|
22
|
+
function buildPathFn(bracePath: string, params: string[], access: string): string {
|
|
23
|
+
if (params.length === 0) return `${access} static let path = "${bracePath}"`
|
|
24
|
+
let body = bracePath
|
|
25
|
+
for (const name of params) body = body.replace(`{${name}}`, `\\(p.${name})`)
|
|
26
|
+
return `${access} static func path(_ p: PathParams) -> String { return "${body}" }`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emitOptsFor(rootTypeName: string, routeOpts: EmitRouteOpts): SwiftEmitOptions {
|
|
30
|
+
const PASSTHROUGH_KEYS = ['serializer', 'accessLevel', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
|
|
31
|
+
return {
|
|
32
|
+
rootTypeName,
|
|
33
|
+
inlineTypes: true,
|
|
34
|
+
...pickDefined(routeOpts, PASSTHROUGH_KEYS),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function emitSwiftRoute(
|
|
39
|
+
route: AnyHttpRouteDoc,
|
|
40
|
+
emitter: SwiftEmitter,
|
|
41
|
+
errorSchemas: Map<string, unknown>,
|
|
42
|
+
routeOpts: EmitRouteOpts = {},
|
|
43
|
+
): EmitRouteResult {
|
|
44
|
+
const kind = (route as { kind?: string }).kind
|
|
45
|
+
if (kind === 'stream') {
|
|
46
|
+
return { code: '', imports: [], routeName: route.name, skipped: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isApi = kind === 'api' || 'fullPath' in route
|
|
50
|
+
const rawPath = isApi ? (route as { fullPath: string }).fullPath : (route as { path: string }).path
|
|
51
|
+
const method = String((route as { method: string }).method).toUpperCase()
|
|
52
|
+
const bracePath = toBracePath(rawPath)
|
|
53
|
+
const params = pathParamNames(rawPath)
|
|
54
|
+
const access = routeOpts.accessLevel ?? 'public'
|
|
55
|
+
|
|
56
|
+
const lines: string[] = [
|
|
57
|
+
`${access} static let method = "${method}"`,
|
|
58
|
+
`${access} static let pathTemplate = "${bracePath}"`,
|
|
59
|
+
buildPathFn(bracePath, params, access),
|
|
60
|
+
]
|
|
61
|
+
const imports: string[] = []
|
|
62
|
+
|
|
63
|
+
// Per-slot emission. Order is fixed for deterministic output.
|
|
64
|
+
for (const slot of extractRouteSlots(route)) {
|
|
65
|
+
const result = emitter.emit(slot.source as Record<string, unknown>, emitOptsFor(slot.rootName, routeOpts))
|
|
66
|
+
lines.push('')
|
|
67
|
+
lines.push(result.code)
|
|
68
|
+
imports.push(...result.imports)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Errors namespace — route.errors is `string[]` of taxonomy keys; look up each schema
|
|
72
|
+
// from the envelope-level errors map. Keys without schemas are skipped silently
|
|
73
|
+
// (matching the existing TS scope emitter's `errorKeys` filter).
|
|
74
|
+
const routeErrorKeys = ((route as { errors?: string[] }).errors ?? [])
|
|
75
|
+
.filter((key) => errorSchemas.has(key))
|
|
76
|
+
if (routeErrorKeys.length > 0) {
|
|
77
|
+
const inner: string[] = []
|
|
78
|
+
for (const key of routeErrorKeys) {
|
|
79
|
+
const r = emitter.emit(errorSchemas.get(key) as Record<string, unknown>, emitOptsFor(key, routeOpts))
|
|
80
|
+
inner.push(r.code)
|
|
81
|
+
imports.push(...r.imports)
|
|
82
|
+
}
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push(`${access} enum Errors {`)
|
|
85
|
+
lines.push(indent(inner.join('\n\n'), 1))
|
|
86
|
+
lines.push('}')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { code: lines.join('\n'), imports, routeName: route.name, skipped: false }
|
|
90
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
3
|
+
import type { ScopeGroup } from '../../group-routes.js'
|
|
4
|
+
import { emitSwiftScope } from './emit-scope-swift.js'
|
|
5
|
+
import { createStubSwiftEmitter, type SwiftEmitOptions, type SwiftEmitResult } from './ajsc-adapter.js'
|
|
6
|
+
|
|
7
|
+
const ok = (code: string, rootTypeName: string): SwiftEmitResult => ({
|
|
8
|
+
code,
|
|
9
|
+
rootTypeName,
|
|
10
|
+
extractedTypeNames: [],
|
|
11
|
+
imports: ['Foundation'],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('emitSwiftScope', () => {
|
|
15
|
+
it('produces a complete swift source file for a single-route scope', () => {
|
|
16
|
+
const route: AnyHttpRouteDoc = {
|
|
17
|
+
kind: 'api',
|
|
18
|
+
name: 'GetUser',
|
|
19
|
+
method: 'GET',
|
|
20
|
+
fullPath: '/users/:id',
|
|
21
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
22
|
+
errors: [],
|
|
23
|
+
} as unknown as AnyHttpRouteDoc
|
|
24
|
+
|
|
25
|
+
const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
|
|
26
|
+
const emitter = createStubSwiftEmitter({
|
|
27
|
+
PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
|
|
28
|
+
Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const file = emitSwiftScope(group, { sourceHash: 'abc123' }, emitter, new Map())
|
|
32
|
+
|
|
33
|
+
expect(file.filename).toBe('Users.swift')
|
|
34
|
+
expect(file.code).toContain('// Generated by ts-procedures-codegen — do not edit.')
|
|
35
|
+
expect(file.code).toContain('// Source hash: abc123')
|
|
36
|
+
expect(file.code).toContain('import Foundation')
|
|
37
|
+
expect(file.code).toContain('public enum Users {')
|
|
38
|
+
expect(file.code).toContain('public enum GetUser {')
|
|
39
|
+
expect(file.code).toContain('public static let method = "GET"')
|
|
40
|
+
expect(file.code).not.toContain('package ')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('joins multiple routes inside one scope enum, separated by a blank line', () => {
|
|
44
|
+
const route1 = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users/:id', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
45
|
+
const route2 = { kind: 'api', name: 'CreateUser', method: 'POST', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
46
|
+
const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route1, route2] }
|
|
47
|
+
const emitter = createStubSwiftEmitter({})
|
|
48
|
+
|
|
49
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
|
|
50
|
+
expect(file.code).toContain('public enum GetUser {')
|
|
51
|
+
expect(file.code).toContain('public enum CreateUser {')
|
|
52
|
+
// exactly one outer scope enum
|
|
53
|
+
expect((file.code.match(/^public enum Users \{/gm) ?? []).length).toBe(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('uses PascalCase scope name for the filename and outer enum', () => {
|
|
57
|
+
const group: ScopeGroup = { scopeKey: 'admin-users', camelCase: 'adminUsers', routes: [] }
|
|
58
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
|
|
59
|
+
expect(file.filename).toBe('AdminUsers.swift')
|
|
60
|
+
expect(file.code).toContain('public enum AdminUsers {')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('PascalCases multi-segment kebab scope keys', () => {
|
|
64
|
+
const group: ScopeGroup = { scopeKey: 'admin-settings', camelCase: 'adminSettings', routes: [] }
|
|
65
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
|
|
66
|
+
expect(file.filename).toBe('AdminSettings.swift')
|
|
67
|
+
expect(file.code).toContain('public enum AdminSettings {')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('emits an empty scope enum body when there are no non-stream routes', () => {
|
|
71
|
+
const group: ScopeGroup = { scopeKey: 'empty', camelCase: 'empty', routes: [] }
|
|
72
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
|
|
73
|
+
expect(file.code).toContain('public enum Empty {\n}')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('dedupes and sorts imports across all routes in the scope', () => {
|
|
77
|
+
const route1 = { kind: 'api', name: 'A', method: 'GET', fullPath: '/a', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
78
|
+
const route2 = { kind: 'api', name: 'B', method: 'GET', fullPath: '/b', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
79
|
+
const group: ScopeGroup = { scopeKey: 'multi', camelCase: 'multi', routes: [route1, route2] }
|
|
80
|
+
const emitter = {
|
|
81
|
+
emit(_s: unknown, opts: SwiftEmitOptions): SwiftEmitResult {
|
|
82
|
+
if (opts.rootTypeName === 'Response') {
|
|
83
|
+
return { code: `public struct Response { }`, rootTypeName: 'Response', extractedTypeNames: [], imports: ['Foundation', 'CommonCrypto'] }
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`No stub for ${opts.rootTypeName}`)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
|
|
90
|
+
// Should appear once per unique import, sorted alphabetically.
|
|
91
|
+
const imports = file.code.split('\n').filter((l) => l.startsWith('import '))
|
|
92
|
+
expect(imports).toEqual(['import CommonCrypto', 'import Foundation'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('threads all 6 passthrough opts to every emitter call', () => {
|
|
96
|
+
const route = { kind: 'api', name: 'X', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
97
|
+
const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [route] }
|
|
98
|
+
|
|
99
|
+
const calls: SwiftEmitOptions[] = []
|
|
100
|
+
const emitter = {
|
|
101
|
+
emit(_s: unknown, opts: SwiftEmitOptions) {
|
|
102
|
+
calls.push(opts)
|
|
103
|
+
return { code: 'public struct Response', rootTypeName: 'Response', extractedTypeNames: [], imports: [] }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
emitSwiftScope(
|
|
108
|
+
group,
|
|
109
|
+
{
|
|
110
|
+
sourceHash: 'h',
|
|
111
|
+
serializer: 'none',
|
|
112
|
+
accessLevel: 'internal',
|
|
113
|
+
unsupportedUnions: 'fallback',
|
|
114
|
+
arrayItemNaming: false,
|
|
115
|
+
depluralize: true,
|
|
116
|
+
uncountableWords: ['data', 'metadata'],
|
|
117
|
+
},
|
|
118
|
+
emitter,
|
|
119
|
+
new Map(),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(calls.length).toBe(1)
|
|
123
|
+
expect(calls[0]!.inlineTypes).toBe(true)
|
|
124
|
+
expect(calls[0]!.serializer).toBe('none')
|
|
125
|
+
expect(calls[0]!.accessLevel).toBe('internal')
|
|
126
|
+
expect(calls[0]!.unsupportedUnions).toBe('fallback')
|
|
127
|
+
expect(calls[0]!.arrayItemNaming).toBe(false)
|
|
128
|
+
expect(calls[0]!.depluralize).toBe(true)
|
|
129
|
+
expect(calls[0]!.uncountableWords).toEqual(['data', 'metadata'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('applies accessLevel to the outer scope enum and the route enum wrappers', () => {
|
|
133
|
+
const route = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
134
|
+
const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
|
|
135
|
+
const emitter = createStubSwiftEmitter({})
|
|
136
|
+
|
|
137
|
+
const file = emitSwiftScope(group, { sourceHash: 'h', accessLevel: 'internal' }, emitter, new Map())
|
|
138
|
+
expect(file.code).toContain('internal enum Users {')
|
|
139
|
+
expect(file.code).toContain('internal enum GetUser {')
|
|
140
|
+
expect(file.code).toContain('internal static let method = "GET"')
|
|
141
|
+
expect(file.code).not.toContain('public enum')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('collects skipped stream-route names', () => {
|
|
145
|
+
const stream = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/u/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
146
|
+
const api = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
147
|
+
const group: ScopeGroup = { scopeKey: 'u', camelCase: 'u', routes: [stream, api] }
|
|
148
|
+
|
|
149
|
+
const emitter = createStubSwiftEmitter({
|
|
150
|
+
Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
|
|
151
|
+
})
|
|
152
|
+
const result = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
|
|
153
|
+
|
|
154
|
+
expect(result.skippedStreams).toEqual(['WatchUsers'])
|
|
155
|
+
expect(result.code).toContain('public enum GetUser')
|
|
156
|
+
expect(result.code).not.toContain('public enum WatchUsers')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('terminates the file with a trailing newline', () => {
|
|
160
|
+
const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [] }
|
|
161
|
+
const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
|
|
162
|
+
expect(file.code.endsWith('\n')).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ScopeGroup } from '../../group-routes.js'
|
|
2
|
+
import type { SwiftEmitter } from './ajsc-adapter.js'
|
|
3
|
+
import { emitSwiftRoute, type EmitRouteOpts } from './emit-route-swift.js'
|
|
4
|
+
import { swiftHeader, swiftImports } from './format-swift.js'
|
|
5
|
+
import { indent } from '../_shared/indent.js'
|
|
6
|
+
import { pickDefined } from '../_shared/pick-defined.js'
|
|
7
|
+
import { pascalCase } from '../_shared/pascal-case.js'
|
|
8
|
+
|
|
9
|
+
export interface EmitScopeOptions extends EmitRouteOpts {
|
|
10
|
+
sourceHash: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EmittedSwiftFile {
|
|
14
|
+
filename: string
|
|
15
|
+
code: string
|
|
16
|
+
/** Names of stream routes within this scope that were skipped. */
|
|
17
|
+
skippedStreams: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emitSwiftScope(
|
|
21
|
+
group: ScopeGroup,
|
|
22
|
+
opts: EmitScopeOptions,
|
|
23
|
+
emitter: SwiftEmitter,
|
|
24
|
+
errorSchemas: Map<string, unknown>,
|
|
25
|
+
): EmittedSwiftFile {
|
|
26
|
+
const scopeName = pascalCase(group.scopeKey)
|
|
27
|
+
const access = opts.accessLevel ?? 'public'
|
|
28
|
+
const allImports: string[] = []
|
|
29
|
+
const routeBlocks: string[] = []
|
|
30
|
+
const skippedStreams: string[] = []
|
|
31
|
+
|
|
32
|
+
const PASSTHROUGH_KEYS = ['serializer', 'accessLevel', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
|
|
33
|
+
const routeOpts: EmitRouteOpts = pickDefined(opts, PASSTHROUGH_KEYS)
|
|
34
|
+
|
|
35
|
+
for (const route of group.routes) {
|
|
36
|
+
const r = emitSwiftRoute(route, emitter, errorSchemas, routeOpts)
|
|
37
|
+
if (r.skipped) {
|
|
38
|
+
skippedStreams.push(r.routeName)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
allImports.push(...r.imports)
|
|
42
|
+
const wrapped = `${access} enum ${r.routeName} {\n${indent(r.code, 1)}\n}`
|
|
43
|
+
routeBlocks.push(wrapped)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const innerScope = routeBlocks.length === 0 ? '' : indent(routeBlocks.join('\n\n'), 1)
|
|
47
|
+
const scopeBlock = innerScope === ''
|
|
48
|
+
? `${access} enum ${scopeName} {\n}`
|
|
49
|
+
: `${access} enum ${scopeName} {\n${innerScope}\n}`
|
|
50
|
+
|
|
51
|
+
const importsBlock = swiftImports(allImports)
|
|
52
|
+
const parts = [
|
|
53
|
+
swiftHeader(opts.sourceHash),
|
|
54
|
+
importsBlock,
|
|
55
|
+
scopeBlock,
|
|
56
|
+
].filter((p) => p.length > 0)
|
|
57
|
+
|
|
58
|
+
return { filename: `${scopeName}.swift`, code: parts.join('\n\n') + '\n', skippedStreams }
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
swiftHeader,
|
|
4
|
+
swiftImports,
|
|
5
|
+
} from './format-swift.js'
|
|
6
|
+
|
|
7
|
+
describe('format-swift', () => {
|
|
8
|
+
it('emits a two-line generated header with source hash', () => {
|
|
9
|
+
expect(swiftHeader('abc123')).toBe(
|
|
10
|
+
'// Generated by ts-procedures-codegen — do not edit.\n// Source hash: abc123',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('dedupes and sorts imports', () => {
|
|
15
|
+
expect(swiftImports(['Foundation', 'Foundation', 'CommonCrypto'])).toBe(
|
|
16
|
+
'import CommonCrypto\nimport Foundation',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns empty string when no imports', () => {
|
|
21
|
+
expect(swiftImports([])).toBe('')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function swiftHeader(sourceHash: string): string {
|
|
2
|
+
return `// Generated by ts-procedures-codegen — do not edit.\n// Source hash: ${sourceHash}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function swiftImports(imports: string[]): string {
|
|
6
|
+
if (imports.length === 0) return ''
|
|
7
|
+
const unique = Array.from(new Set(imports)).sort()
|
|
8
|
+
return unique.map((i) => `import ${i}`).join('\n')
|
|
9
|
+
}
|