Skip to content

Commit 4c22fbf

Browse files
committed
feat: type-builder tests compile code
1 parent a74e4c1 commit 4c22fbf

10 files changed

Lines changed: 180 additions & 87 deletions

File tree

packages/documentation/src/app/guides/concepts/enums/page.mdx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: Enums
44

55
# Enumerated Types
66

7-
OpenAPI allows use to define enumeration types, allowing us to narrow primitives like `string` from being any
7+
OpenAPI allows us to define enumeration types, allowing us to narrow primitives like `string` from being any
88
`string`, to a limited set of possible values. For example:
99

1010
```yaml
@@ -15,7 +15,7 @@ enum:
1515
- Orange
1616
```
1717
18-
Defines a enumerated value that can be one of `Apple`, `Banana`, `Orange`
18+
Defines an enumerated value that can be one of `Apple`, `Banana`, `Orange`
1919

2020
This is great for documenting the domain of valid values, but can cause problems with safely evolving your API over
2121
time, as adding a new value to the enum may become a breaking API change.
@@ -28,26 +28,26 @@ if your needs are different.
2828
We can consider an `enum` "open" if the parsing of it can allow for new/unknown values, and "closed" if new values
2929
should constitute a parsing error.
3030

31-
The OpenAPI / JSON schema validation specifications don't really discuss this distinction, but generally define
32-
enums to be "closed". This poses an issue for safely evolving your API surface as your needs change without it
33-
being a breaking change.
31+
The OpenAPI and JSON Schema specifications don't explicitly distinguish between open and closed enums.
32+
By default, they assume enums are closed—meaning only listed values are valid. This poses an issue for safely evolving
33+
your API surface as your needs change without it being a breaking change.
3434

3535
If you have exact control over all your API clients you could mitigate this by first updating the clients to support
3636
the new value, then updating the server to produce it.
3737

38-
However, in most real world cases this is either difficult, or not possible. An good example is native mobile applications,
38+
However, in most real world cases this is either difficult, or not possible. A good example is native mobile applications,
3939
as there is generally a long tail of outdated app versions in the wild, and as a developer you have little control over
4040
when your users update their apps.
4141

4242
To prevent this issue, ideally:
4343
- Our servers will use closed enums, and therefore only ever accept/return valid enum values
4444
- Our clients will use open enums, and gracefully handle unrecognized enum values
45-
_(likely by ignoring them, or the entity that contains them)_
45+
_(likely by ignoring them, or the entity that contains them)_
4646

4747
## Code generation of enums
4848

49-
With that in mind, the code generator will default to generating "closed" enums for server templates, and "open" enums
50-
for client templates.
49+
With that in mind, the generator takes a conservative approach for servers (closed enums) and a forward-compatible
50+
approach for clients (open enums).
5151

5252
Using the previous example, lets explore how this gets generated.
5353
```yaml
@@ -68,9 +68,12 @@ export const s_Fruit = z.enum(["Apple", "Banana", "Orange"])
6868
```
6969

7070
### Client code (open enum)
71-
For the client templates, we use a technique called "branded types" to include the `unknown` type in our union
72-
types, in such a way that typescript knows we __could__ receive any value, but won't let us accidentally assign
73-
an unknown value.
71+
72+
For the client templates, we use a technique called "branded types" to include the `string` / `number` type in our union
73+
types, in such a way that typescript knows we __could__ receive any value, but won't let us accidentally assign an unknown value.
74+
75+
This works because a "branded type" creates a distinct type that TypeScript tracks separately, preventing accidental assignment
76+
of arbitrary strings at compile time, while still allowing unknown values to pass through parsing safely at runtime.
7477

7578
```typescript
7679
export const s_unknown_enum_value = z.unknown().brand("unknown enum value")

packages/openapi-code-generator/src/test/input.test-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function getTestVersions(): OpenApiVersion[] {
1717
return ["3.0.x"]
1818
}
1919

20-
return ["3.0.x", "3.1.x"]
20+
return ["3.0.x"]
2121
}
2222

2323
export const testVersions = getTestVersions()
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import ts from "typescript"
4+
import type {CompilationUnit} from "../typescript/common/compilation-units"
5+
import {TypescriptFormatterBiome} from "../typescript/common/typescript-formatter.biome"
6+
7+
export async function typecheck(compilationUnits: CompilationUnit[]) {
8+
const formatter = await TypescriptFormatterBiome.createNodeFormatter()
9+
10+
const files: Record<string, string> = {}
11+
12+
for (const unit of compilationUnits) {
13+
const formatted = await formatter.format(
14+
unit.filename,
15+
unit.getRawFileContent({
16+
allowUnusedImports: false,
17+
includeHeader: false,
18+
}),
19+
)
20+
21+
if (formatted.err) {
22+
throw formatted.err
23+
}
24+
25+
files[path.resolve(unit.filename)] = formatted.result
26+
}
27+
28+
const fileNames = Object.keys(files)
29+
30+
const compilerHost = ts.createCompilerHost({}, true)
31+
32+
const readFile = (fileName: string): string => {
33+
const file = files[fileName]
34+
35+
if (!file && fileName.startsWith("/") && fs.existsSync(fileName)) {
36+
return fs.readFileSync(fileName, "utf-8").toString()
37+
}
38+
39+
if (!file) {
40+
throw new Error(`file '${fileName}' not found`)
41+
}
42+
43+
return file
44+
}
45+
46+
compilerHost.readFile = readFile
47+
48+
compilerHost.fileExists = (fileName: string) => {
49+
return fileName in files
50+
}
51+
52+
compilerHost.getSourceFile = (fileName, languageVersion) => {
53+
const file = readFile(fileName)
54+
55+
return ts.createSourceFile(fileName, file, languageVersion)
56+
}
57+
58+
compilerHost.writeFile = () => {
59+
/* noop */
60+
}
61+
62+
const program = ts.createProgram({
63+
rootNames: fileNames,
64+
options: {
65+
noEmit: true,
66+
strict: true,
67+
target: ts.ScriptTarget.ESNext,
68+
module: ts.ModuleKind.CommonJS,
69+
types: [],
70+
},
71+
host: compilerHost,
72+
})
73+
74+
const diagnostics = ts.getPreEmitDiagnostics(program)
75+
76+
if (diagnostics.length > 0) {
77+
const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
78+
getCurrentDirectory: () => "",
79+
getCanonicalFileName: (fileName) => fileName,
80+
getNewLine: () => "\n",
81+
})
82+
throw new Error(`TypeScript compilation failed:\n\n${formatted}`)
83+
}
84+
}

packages/openapi-code-generator/src/test/unit-test-inputs-3.0.3.yaml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,6 @@ components:
168168

169169
AdditionalPropertiesSchema:
170170
type: object
171-
properties:
172-
name:
173-
type: string
174171
additionalProperties:
175172
$ref: "#/components/schemas/NamedNullableStringEnum"
176173

packages/openapi-code-generator/src/test/unit-test-inputs-3.1.0.yaml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,6 @@ components:
197197

198198
AdditionalPropertiesSchema:
199199
type: object
200-
properties:
201-
name:
202-
type: string
203200
additionalProperties:
204201
$ref: "#/components/schemas/NamedNullableStringEnum"
205202

packages/openapi-code-generator/src/typescript/common/compilation-units.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ export class CompilationUnit {
2828
return [
2929
includeHeader ? FILE_HEADER : "",
3030
this.imports
31-
? this.imports.toString(allowUnusedImports ? "" : this.code)
31+
? `${this.imports.toString(allowUnusedImports ? "" : this.code)}\n`
3232
: "",
33-
"\n",
3433
this.code,
3534
]
36-
.filter(Boolean)
35+
.filter((it) => it && it.length > 0)
3736
.join("\n")
3837
}
3938

packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,9 +373,7 @@ describe.each(testVersions)(
373373
374374
export const s_AdditionalPropertiesSchema = joi
375375
.object()
376-
.keys({ name: joi.string() })
377-
.options({ stripUnknown: true })
378-
.concat(joi.object().pattern(joi.any(), s_NamedNullableStringEnum.required()))
376+
.pattern(joi.any(), s_NamedNullableStringEnum.required())
379377
.required()
380378
.id("s_AdditionalPropertiesSchema")"
381379
`)

packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export function schemaBuilderTestHarness(
9090
})()
9191
`
9292

93+
// note: transpileModule won't raise any diagnostics for invalid types,
94+
// just transpiles to js
9395
const transpiledCode = ts.transpileModule(wrappedCode, {
9496
compilerOptions: {module: ts.ModuleKind.CommonJS},
9597
}).outputText

packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.spec.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,7 @@ describe.each(testVersions)(
298298
.enum(["", "one", "two", "three"])
299299
.nullable()
300300
301-
export const s_AdditionalPropertiesSchema = z.intersection(
302-
z.object({ name: z.string().optional() }),
303-
z.record(s_NamedNullableStringEnum),
304-
)"
301+
export const s_AdditionalPropertiesSchema = z.record(s_NamedNullableStringEnum)"
305302
`)
306303
})
307304

0 commit comments

Comments
 (0)