ts-procedures 6.0.2 → 6.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/agent_config/bin/setup.mjs +0 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -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/copilot/copilot-instructions.md +2 -0
- package/agent_config/cursor/cursorrules +2 -0
- package/build/codegen/bin/cli.d.ts +25 -0
- package/build/codegen/bin/cli.js +88 -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 +19 -0
- package/build/codegen/index.js +5 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +7 -0
- package/build/codegen/pipeline.js +57 -0
- 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/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 -11
- 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 +9 -4
- 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 +11 -0
- package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +27 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +26 -9
- 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/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 +175 -0
- package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
- package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
- package/package.json +2 -2
- package/src/codegen/bin/cli.test.ts +200 -1
- package/src/codegen/bin/cli.ts +103 -0
- package/src/codegen/index.ts +27 -0
- package/src/codegen/pipeline.test.ts +175 -0
- package/src/codegen/pipeline.ts +79 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-envelope.json +144 -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 +109 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +65 -0
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +70 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +45 -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/test-helpers/golden.test.ts +80 -0
- package/src/codegen/test-helpers/golden.ts +34 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package com.example.api
|
|
2
|
+
|
|
3
|
+
// Source hash: <PLACEHOLDER>
|
|
4
|
+
|
|
5
|
+
import kotlinx.serialization.Contextual
|
|
6
|
+
import kotlinx.serialization.SerialName
|
|
7
|
+
import kotlinx.serialization.Serializable
|
|
8
|
+
import kotlinx.serialization.json.JsonClassDiscriminator
|
|
9
|
+
|
|
10
|
+
object Users {
|
|
11
|
+
object GetUser {
|
|
12
|
+
const val method = "GET"
|
|
13
|
+
const val pathTemplate = "/users/{id}"
|
|
14
|
+
fun path(p: PathParams): String = "/users/${p.id}"
|
|
15
|
+
|
|
16
|
+
@Serializable
|
|
17
|
+
data class PathParams(
|
|
18
|
+
val id: String,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@Serializable
|
|
22
|
+
data class Response(
|
|
23
|
+
val id: String,
|
|
24
|
+
val name: String,
|
|
25
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
26
|
+
val address: Address,
|
|
27
|
+
) {
|
|
28
|
+
@Serializable
|
|
29
|
+
data class Address(
|
|
30
|
+
val street: String,
|
|
31
|
+
val city: String,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
object Errors {
|
|
36
|
+
@Serializable
|
|
37
|
+
data class NotFound(
|
|
38
|
+
val name: String = "NotFound",
|
|
39
|
+
val message: String,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
object CreateUser {
|
|
45
|
+
const val method = "POST"
|
|
46
|
+
const val pathTemplate = "/users"
|
|
47
|
+
const val path = "/users"
|
|
48
|
+
|
|
49
|
+
@Serializable
|
|
50
|
+
@JsonClassDiscriminator("kind")
|
|
51
|
+
sealed interface Body {
|
|
52
|
+
@Serializable
|
|
53
|
+
@SerialName("guest")
|
|
54
|
+
data class GuestBody(
|
|
55
|
+
val displayName: String,
|
|
56
|
+
) : Body
|
|
57
|
+
|
|
58
|
+
@Serializable
|
|
59
|
+
@SerialName("registered")
|
|
60
|
+
data class RegisteredBody(
|
|
61
|
+
val email: String,
|
|
62
|
+
val name: String,
|
|
63
|
+
) : Body
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Serializable
|
|
67
|
+
data class Response(
|
|
68
|
+
val id: String,
|
|
69
|
+
val name: String,
|
|
70
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
71
|
+
val address: Address,
|
|
72
|
+
) {
|
|
73
|
+
@Serializable
|
|
74
|
+
data class Address(
|
|
75
|
+
val street: String,
|
|
76
|
+
val city: String,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
object Errors {
|
|
81
|
+
@Serializable
|
|
82
|
+
data class ValidationError(
|
|
83
|
+
val name: String = "ValidationError",
|
|
84
|
+
val message: String,
|
|
85
|
+
val field: String? = null,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
object ListUsers {
|
|
91
|
+
const val method = "GET"
|
|
92
|
+
const val pathTemplate = "/users"
|
|
93
|
+
const val path = "/users"
|
|
94
|
+
|
|
95
|
+
@Serializable
|
|
96
|
+
data class Query(
|
|
97
|
+
val status: Status? = null,
|
|
98
|
+
val limit: Long? = null,
|
|
99
|
+
) {
|
|
100
|
+
@Serializable
|
|
101
|
+
enum class Status {
|
|
102
|
+
@SerialName("active") ACTIVE,
|
|
103
|
+
@SerialName("inactive") INACTIVE,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Serializable
|
|
108
|
+
data class Response(
|
|
109
|
+
val id: String,
|
|
110
|
+
val name: String,
|
|
111
|
+
@SerialName("created-at") @Contextual val createdAt: java.time.Instant,
|
|
112
|
+
val address: Address,
|
|
113
|
+
) {
|
|
114
|
+
@Serializable
|
|
115
|
+
data class Address(
|
|
116
|
+
val street: String,
|
|
117
|
+
val city: String,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ajsc.emitKotlin — untagged oneOf behavior > produces a deterministic fallback shape for an untagged oneOf 1`] = `
|
|
4
|
+
{
|
|
5
|
+
"code": "@Serializable
|
|
6
|
+
data class Mixed()
|
|
7
|
+
",
|
|
8
|
+
"extractedTypeNames": [],
|
|
9
|
+
"imports": [
|
|
10
|
+
"kotlinx.serialization.Serializable",
|
|
11
|
+
],
|
|
12
|
+
"rootTypeName": "Mixed",
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
exports[`ajsc.emitKotlin — untagged oneOf behavior > silently falls back to empty data class when unsupportedUnions is not specified 1`] = `
|
|
17
|
+
{
|
|
18
|
+
"code": "@Serializable
|
|
19
|
+
data class Mixed()
|
|
20
|
+
",
|
|
21
|
+
"extractedTypeNames": [],
|
|
22
|
+
"imports": [
|
|
23
|
+
"kotlinx.serialization.Serializable",
|
|
24
|
+
],
|
|
25
|
+
"rootTypeName": "Mixed",
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createStubKotlinEmitter, resolveProductionKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
|
|
3
|
+
|
|
4
|
+
describe('createStubKotlinEmitter', () => {
|
|
5
|
+
it('returns the configured EmitResult for the matching root name', () => {
|
|
6
|
+
const expected: KotlinEmitResult = {
|
|
7
|
+
code: '@Serializable data class User(val id: String)',
|
|
8
|
+
rootTypeName: 'User',
|
|
9
|
+
extractedTypeNames: [],
|
|
10
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
11
|
+
}
|
|
12
|
+
const emitter = createStubKotlinEmitter({ User: expected })
|
|
13
|
+
expect(
|
|
14
|
+
emitter.emit(
|
|
15
|
+
{ type: 'object' },
|
|
16
|
+
{
|
|
17
|
+
rootTypeName: 'User',
|
|
18
|
+
inlineTypes: true,
|
|
19
|
+
serializer: 'kotlinx',
|
|
20
|
+
unsupportedUnions: 'throw',
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
).toEqual(expected)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('throws when asked to emit a name not in the stub map', () => {
|
|
27
|
+
const emitter = createStubKotlinEmitter({})
|
|
28
|
+
expect(() => emitter.emit({}, { rootTypeName: 'Missing' })).toThrow(/Missing/)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('resolveProductionKotlinEmitter', () => {
|
|
33
|
+
it('returns a working emitter that invokes ajsc.emitKotlin when ajsc is installed', async () => {
|
|
34
|
+
const emitter = await resolveProductionKotlinEmitter()
|
|
35
|
+
const result = emitter.emit(
|
|
36
|
+
{ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
37
|
+
{ rootTypeName: 'Probe' },
|
|
38
|
+
)
|
|
39
|
+
expect(typeof result.code).toBe('string')
|
|
40
|
+
expect(result.code.length).toBeGreaterThan(0)
|
|
41
|
+
expect(result.rootTypeName).toBe('Probe')
|
|
42
|
+
expect(Array.isArray(result.imports)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
// Note: testing the failure path (ajsc unavailable) requires module mocking;
|
|
45
|
+
// we leave that as a manual-verification path. The error message is pinned
|
|
46
|
+
// by the message text below so any change requires updating both call sites.
|
|
47
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface KotlinEmitResult {
|
|
2
|
+
code: string
|
|
3
|
+
rootTypeName: string
|
|
4
|
+
extractedTypeNames: string[]
|
|
5
|
+
imports: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface KotlinEmitOptions {
|
|
9
|
+
rootTypeName: string
|
|
10
|
+
/** Always set true at our call sites; v7.2 default is false. */
|
|
11
|
+
inlineTypes?: boolean
|
|
12
|
+
serializer?: 'kotlinx' | 'none'
|
|
13
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
14
|
+
arrayItemNaming?: string | false
|
|
15
|
+
depluralize?: boolean
|
|
16
|
+
uncountableWords?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface KotlinEmitter {
|
|
20
|
+
emit(schema: Record<string, unknown>, opts: KotlinEmitOptions): KotlinEmitResult
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createStubKotlinEmitter(
|
|
24
|
+
results: Record<string, KotlinEmitResult>,
|
|
25
|
+
): KotlinEmitter {
|
|
26
|
+
return {
|
|
27
|
+
emit(_schema, opts) {
|
|
28
|
+
const result = results[opts.rootTypeName]
|
|
29
|
+
if (result == null) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[stub-kotlin-emitter] No stubbed result for rootTypeName "${opts.rootTypeName}". ` +
|
|
32
|
+
`Provide one in the results map.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the production Kotlin emitter from `ajsc`. Throws a clear error
|
|
42
|
+
* if ajsc is not installed or does not expose `emitKotlin` (e.g. consumer
|
|
43
|
+
* ran `npm install --omit=optional` since ajsc is in optionalDependencies).
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveProductionKotlinEmitter(): Promise<KotlinEmitter> {
|
|
46
|
+
let ajsc: { emitKotlin?: unknown } | null = null
|
|
47
|
+
let importError: unknown
|
|
48
|
+
try {
|
|
49
|
+
ajsc = (await import('ajsc')) as { emitKotlin?: unknown }
|
|
50
|
+
} catch (err) {
|
|
51
|
+
importError = err
|
|
52
|
+
}
|
|
53
|
+
const emitKotlin = ajsc?.emitKotlin
|
|
54
|
+
if (typeof emitKotlin !== 'function') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'[ts-procedures-codegen] ajsc.emitKotlin is not available. ' +
|
|
57
|
+
'Install ajsc (`npm install ajsc`) — it is an optional dependency.',
|
|
58
|
+
importError !== undefined ? { cause: importError } : undefined,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
emit(schema, opts) {
|
|
63
|
+
return (emitKotlin as (s: unknown, o: unknown) => KotlinEmitResult)(schema, opts)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { execFileSync, execSync } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { runPipeline } from '../../pipeline.js'
|
|
8
|
+
import { resolveProductionKotlinEmitter } from './ajsc-adapter.js'
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = dirname(__filename)
|
|
12
|
+
|
|
13
|
+
function kotlincAvailable(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
execSync('kotlinc -version', { stdio: 'ignore' })
|
|
16
|
+
return true
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RUN = process.env.TS_PROCEDURES_KOTLIN_E2E === '1'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* E2E: real ajsc → real .kt output → real kotlinc compile.
|
|
26
|
+
*
|
|
27
|
+
* Gated on (a) `kotlinc` on PATH and (b) opt-in via env var so default
|
|
28
|
+
* `npm test` runs stay green for contributors without the toolchain.
|
|
29
|
+
*
|
|
30
|
+
* **Classpath setup (one-time, local):**
|
|
31
|
+
*
|
|
32
|
+
* Download kotlinx-serialization jars (matching versions, e.g. 1.6.3) from
|
|
33
|
+
* Maven Central or a local Gradle/Maven cache. The classpath value is a
|
|
34
|
+
* `:`-separated list (`;` on Windows). Concrete example:
|
|
35
|
+
*
|
|
36
|
+
* export TS_PROCEDURES_KOTLIN_E2E_CLASSPATH="\
|
|
37
|
+
* $HOME/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-json-jvm/1.6.3/<hash>/kotlinx-serialization-json-jvm-1.6.3.jar:\
|
|
38
|
+
* $HOME/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-core-jvm/1.6.3/<hash>/kotlinx-serialization-core-jvm-1.6.3.jar"
|
|
39
|
+
*
|
|
40
|
+
* If the env var is unset the compile uses kotlinc's default classpath which
|
|
41
|
+
* lacks the kotlinx jars and fails — that's the expected mode when checking
|
|
42
|
+
* gating works.
|
|
43
|
+
*/
|
|
44
|
+
describe('kotlin codegen — kotlinc compile (gated)', () => {
|
|
45
|
+
it.skipIf(!kotlincAvailable() || !RUN)(
|
|
46
|
+
'compiles generated output without errors',
|
|
47
|
+
async () => {
|
|
48
|
+
const emitter = await resolveProductionKotlinEmitter()
|
|
49
|
+
const envelope = JSON.parse(
|
|
50
|
+
readFileSync(join(__dirname, '__fixtures__/users-envelope.json'), 'utf8'),
|
|
51
|
+
)
|
|
52
|
+
const files = await runPipeline({
|
|
53
|
+
envelope,
|
|
54
|
+
outDir: 'out',
|
|
55
|
+
dryRun: true,
|
|
56
|
+
target: 'kotlin',
|
|
57
|
+
kotlinPackage: 'com.example.api',
|
|
58
|
+
kotlinEmitter: emitter,
|
|
59
|
+
})
|
|
60
|
+
const dir = mkdtempSync(join(tmpdir(), 'tsp-kotlin-e2e-'))
|
|
61
|
+
for (const f of files) {
|
|
62
|
+
writeFileSync(join(dir, f.path.split('/').pop()!), f.code)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Expand the source glob in Node and pass kotlinc args as an array so
|
|
66
|
+
// the shell never sees user-controlled input. The classpath env var is
|
|
67
|
+
// only forwarded as a single -classpath argument; even a value with
|
|
68
|
+
// shell metachars cannot escape into command parsing.
|
|
69
|
+
const ktFiles = readdirSync(dir)
|
|
70
|
+
.filter((name) => name.endsWith('.kt'))
|
|
71
|
+
.map((name) => join(dir, name))
|
|
72
|
+
|
|
73
|
+
const outJar = join(dir, 'out.jar')
|
|
74
|
+
const cp = process.env.TS_PROCEDURES_KOTLIN_E2E_CLASSPATH
|
|
75
|
+
const args: string[] = []
|
|
76
|
+
if (cp != null && cp.length > 0) {
|
|
77
|
+
args.push('-classpath', cp)
|
|
78
|
+
}
|
|
79
|
+
args.push(...ktFiles, '-d', outJar)
|
|
80
|
+
|
|
81
|
+
execFileSync('kotlinc', args, { stdio: 'inherit' })
|
|
82
|
+
|
|
83
|
+
expect(existsSync(outJar)).toBe(true)
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
3
|
+
import { emitKotlinRoute } from './emit-route-kotlin.js'
|
|
4
|
+
import { createStubKotlinEmitter, type KotlinEmitOptions, type KotlinEmitResult } from './ajsc-adapter.js'
|
|
5
|
+
|
|
6
|
+
const ok = (code: string, rootTypeName: string): KotlinEmitResult => ({
|
|
7
|
+
code,
|
|
8
|
+
rootTypeName,
|
|
9
|
+
extractedTypeNames: [],
|
|
10
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const noErrors = new Map<string, unknown>()
|
|
14
|
+
|
|
15
|
+
interface CapturedCall { schema: unknown; opts: KotlinEmitOptions }
|
|
16
|
+
|
|
17
|
+
function makeSpyEmitter(results: Record<string, KotlinEmitResult>) {
|
|
18
|
+
const calls: CapturedCall[] = []
|
|
19
|
+
const emitter = {
|
|
20
|
+
emit(schema: unknown, opts: KotlinEmitOptions) {
|
|
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('emitKotlinRoute', () => {
|
|
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 = createStubKotlinEmitter({
|
|
47
|
+
PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
|
|
48
|
+
Response: ok('@Serializable data class Response(val id: String, val name: String)', 'Response'),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const result = emitKotlinRoute(route, emitter, noErrors)
|
|
52
|
+
|
|
53
|
+
expect(result.imports).toContain('kotlinx.serialization.Serializable')
|
|
54
|
+
expect(result.code).toContain('const val method = "GET"')
|
|
55
|
+
expect(result.code).toContain('const val pathTemplate = "/users/{id}"')
|
|
56
|
+
expect(result.code).toContain('fun path(p: PathParams): String = "/users/${p.id}"')
|
|
57
|
+
expect(result.code).toContain('@Serializable data class PathParams(val id: String)')
|
|
58
|
+
expect(result.code).toContain('@Serializable data class Response(val id: String, val 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 = createStubKotlinEmitter({
|
|
72
|
+
Body: ok('@Serializable data class Body(val name: String)', 'Body'),
|
|
73
|
+
Response: ok('@Serializable data class Response(val id: String)', 'Response'),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const result = emitKotlinRoute(route, emitter, noErrors)
|
|
77
|
+
expect(result.code).toContain('const val path = "/users"')
|
|
78
|
+
expect(result.code).not.toContain('fun 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 = createStubKotlinEmitter({
|
|
92
|
+
PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
|
|
93
|
+
Response: ok('@Serializable data class Response(val id: String)', 'Response'),
|
|
94
|
+
NotFound: ok('@Serializable data class NotFound(val name: String, val message: String)', 'NotFound'),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const errorSchemas = new Map<string, unknown>([['NotFound', { type: 'object' }]])
|
|
98
|
+
const result = emitKotlinRoute(route, emitter, errorSchemas)
|
|
99
|
+
expect(result.code).toContain('object Errors {')
|
|
100
|
+
expect(result.code).toContain('@Serializable data class NotFound(val name: String, val 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 = emitKotlinRoute(route, createStubKotlinEmitter({}), new Map())
|
|
109
|
+
expect(result.code).not.toContain('object 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 = emitKotlinRoute(route, createStubKotlinEmitter({}), noErrors)
|
|
115
|
+
expect(result.code).toBe('')
|
|
116
|
+
expect(result.skipped).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('passes inlineTypes:true plus serializer/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('@Serializable data class PathParams(val id: String)', 'PathParams'),
|
|
134
|
+
Response: ok('@Serializable data class Response(val id: String)', 'Response'),
|
|
135
|
+
NotFound: ok('@Serializable data class NotFound(val message: String)', 'NotFound'),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
emitKotlinRoute(route, emitter, new Map([['NotFound', { type: 'object' }]]), {
|
|
139
|
+
serializer: 'none',
|
|
140
|
+
unsupportedUnions: 'fallback',
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// 3 emits: PathParams, Response, NotFound
|
|
144
|
+
expect(calls.length).toBe(3)
|
|
145
|
+
for (const c of calls) {
|
|
146
|
+
expect(c.opts.inlineTypes).toBe(true)
|
|
147
|
+
expect(c.opts.serializer).toBe('none')
|
|
148
|
+
expect(c.opts.unsupportedUnions).toBe('fallback')
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('preserves slot order: pathParams → query → body → response → errors', () => {
|
|
153
|
+
const route = {
|
|
154
|
+
kind: 'api',
|
|
155
|
+
name: 'X',
|
|
156
|
+
method: 'POST',
|
|
157
|
+
fullPath: '/x/:id',
|
|
158
|
+
schema: {
|
|
159
|
+
input: {
|
|
160
|
+
pathParams: { type: 'object' },
|
|
161
|
+
query: { type: 'object' },
|
|
162
|
+
body: { type: 'object' },
|
|
163
|
+
},
|
|
164
|
+
returnType: { type: 'object' },
|
|
165
|
+
},
|
|
166
|
+
errors: ['Z'],
|
|
167
|
+
} as unknown as AnyHttpRouteDoc
|
|
168
|
+
|
|
169
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
170
|
+
PathParams: ok('a', 'PathParams'),
|
|
171
|
+
Query: ok('b', 'Query'),
|
|
172
|
+
Body: ok('c', 'Body'),
|
|
173
|
+
Response: ok('d', 'Response'),
|
|
174
|
+
Z: ok('e', 'Z'),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
emitKotlinRoute(route, emitter, new Map([['Z', { type: 'object' }]]), {})
|
|
178
|
+
|
|
179
|
+
expect(calls.map((c) => c.opts.rootTypeName)).toEqual([
|
|
180
|
+
'PathParams', 'Query', 'Body', 'Response', 'Z',
|
|
181
|
+
])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('threads passthrough opts (arrayItemNaming/depluralize/uncountableWords) verbatim', () => {
|
|
185
|
+
const route = {
|
|
186
|
+
kind: 'api',
|
|
187
|
+
name: 'X',
|
|
188
|
+
method: 'GET',
|
|
189
|
+
fullPath: '/x',
|
|
190
|
+
schema: { returnType: { type: 'object' } },
|
|
191
|
+
errors: [],
|
|
192
|
+
} as unknown as AnyHttpRouteDoc
|
|
193
|
+
|
|
194
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
195
|
+
Response: ok('@Serializable data class Response(val ok: Boolean)', 'Response'),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
emitKotlinRoute(route, emitter, new Map(), {
|
|
199
|
+
arrayItemNaming: false,
|
|
200
|
+
depluralize: true,
|
|
201
|
+
uncountableWords: ['data', 'metadata'],
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(calls).toHaveLength(1)
|
|
205
|
+
expect(calls[0]!.opts.arrayItemNaming).toBe(false)
|
|
206
|
+
expect(calls[0]!.opts.depluralize).toBe(true)
|
|
207
|
+
expect(calls[0]!.opts.uncountableWords).toEqual(['data', 'metadata'])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('does not include passthrough keys when caller omits them', () => {
|
|
211
|
+
const route = {
|
|
212
|
+
kind: 'api',
|
|
213
|
+
name: 'X',
|
|
214
|
+
method: 'GET',
|
|
215
|
+
fullPath: '/x',
|
|
216
|
+
schema: { returnType: { type: 'object' } },
|
|
217
|
+
errors: [],
|
|
218
|
+
} as unknown as AnyHttpRouteDoc
|
|
219
|
+
|
|
220
|
+
const { emitter, calls } = makeSpyEmitter({
|
|
221
|
+
Response: ok('@Serializable data class Response(val ok: Boolean)', 'Response'),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
emitKotlinRoute(route, emitter, new Map(), {})
|
|
225
|
+
|
|
226
|
+
expect(calls).toHaveLength(1)
|
|
227
|
+
// Conditional-spread invariant: undefined opts must NOT be forwarded as keys
|
|
228
|
+
// (otherwise ajsc would receive `{ arrayItemNaming: undefined }` etc, which
|
|
229
|
+
// could shadow ajsc-side defaults).
|
|
230
|
+
expect('arrayItemNaming' in calls[0]!.opts).toBe(false)
|
|
231
|
+
expect('depluralize' in calls[0]!.opts).toBe(false)
|
|
232
|
+
expect('uncountableWords' in calls[0]!.opts).toBe(false)
|
|
233
|
+
// serializer / unsupportedUnions same invariant
|
|
234
|
+
expect('serializer' in calls[0]!.opts).toBe(false)
|
|
235
|
+
expect('unsupportedUnions' in calls[0]!.opts).toBe(false)
|
|
236
|
+
// inlineTypes is always set
|
|
237
|
+
expect(calls[0]!.opts.inlineTypes).toBe(true)
|
|
238
|
+
})
|
|
239
|
+
})
|