Skip to content

Commit 425457c

Browse files
authored
Fix open object type generation (#7326)
1 parent 008c423 commit 425457c

4 files changed

Lines changed: 89 additions & 15 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"effect": patch
3+
"@effect/openapi-generator": patch
4+
---
5+
6+
Emit mixed struct and record schema types as intersections, preventing optional
7+
properties in open OpenAPI objects from conflicting with their index signature.

‎packages/effect/src/internal/schema/toCodeDocument.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,16 @@ export function toCodeDocument(
544544
).join(", ")
545545
const indexTypes = indexSignatures.map((signature) =>
546546
`readonly [x: ${signature.parameter.Type}]: ${signature.type.Type}`
547-
).join(", ")
547+
)
548+
if (properties.length === 0) {
549+
return makeCode(
550+
`Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`,
551+
`{ ${indexTypes.join(", ")} }`
552+
)
553+
}
548554
return makeCode(
549555
`Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`,
550-
`{ ${propertyTypes}${properties.length > 0 ? ", " : ""}${indexTypes} }`
556+
[`{ ${propertyTypes} }`, ...indexTypes.map((indexType) => `{ ${indexType} }`)].join(" & ")
551557
)
552558
}
553559
case "Union": {

‎packages/effect/test/schema/representation/toCodeDocument.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,7 +1308,7 @@ describe("toCodeDocument", () => {
13081308
{
13091309
codes: makeCode(
13101310
`Schema.StructWithRest(Schema.Struct({ "a": Schema.Number }), [Schema.Record(Schema.String, Schema.Number)])`,
1311-
`{ readonly "a": number, readonly [x: string]: number }`
1311+
`{ readonly "a": number } & { readonly [x: string]: number }`
13121312
)
13131313
}
13141314
)
@@ -1321,7 +1321,7 @@ describe("toCodeDocument", () => {
13211321
{
13221322
codes: makeCode(
13231323
`Schema.StructWithRest(Schema.Struct({ "a": Schema.Number }), [Schema.Record(Schema.String, Schema.Number)]).annotate({ "description": "a" })`,
1324-
`{ readonly "a": number, readonly [x: string]: number }`
1324+
`{ readonly "a": number } & { readonly [x: string]: number }`
13251325
)
13261326
}
13271327
)

‎packages/tools/openapi-generator/test/OpenApiGenerator.test.ts‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@ function assertTypeOnlyIncludes(
8787
)
8888
}
8989

90-
function assertGeneratedClientsCompile(spec: OpenAPISpec) {
90+
function assertGeneratedClientsCompile(
91+
spec: OpenAPISpec,
92+
options: {
93+
readonly exactOptionalPropertyTypes?: boolean | undefined
94+
readonly formats?: ReadonlyArray<"httpclient" | "httpclient-type-only"> | undefined
95+
} = {}
96+
) {
9197
const generate = (
9298
format: "httpclient" | "httpclient-type-only",
9399
layer: typeof OpenApiGenerator.layerTransformerSchema
@@ -98,23 +104,29 @@ function assertGeneratedClientsCompile(spec: OpenAPISpec) {
98104
}).pipe(Effect.provide(layer))
99105

100106
return Effect.gen(function*() {
101-
const [schemaClient, typeOnlyClient] = yield* Effect.all([
102-
generate("httpclient", OpenApiGenerator.layerTransformerSchema),
103-
generate("httpclient-type-only", OpenApiGenerator.layerTransformerTs)
104-
])
107+
const formats = options.formats ?? ["httpclient", "httpclient-type-only"]
108+
const clients = yield* Effect.all(formats.map((format) =>
109+
generate(
110+
format,
111+
format === "httpclient"
112+
? OpenApiGenerator.layerTransformerSchema
113+
: OpenApiGenerator.layerTransformerTs
114+
)
115+
))
105116
const directory = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".generated-clients-"))
106117
try {
107-
const schemaPath = join(directory, "SchemaClient.ts")
108-
const typeOnlyPath = join(directory, "TypeOnlyClient.ts")
109-
writeFileSync(schemaPath, schemaClient)
110-
writeFileSync(typeOnlyPath, typeOnlyClient)
118+
const files = clients.map((client, index) => {
119+
const path = join(directory, `Client${index}.ts`)
120+
writeFileSync(path, client)
121+
return path
122+
})
111123
const configPath = join(directory, "tsconfig.json")
112124
writeFileSync(
113125
configPath,
114126
JSON.stringify({
115127
compilerOptions: {
116128
allowImportingTsExtensions: true,
117-
exactOptionalPropertyTypes: true,
129+
exactOptionalPropertyTypes: options.exactOptionalPropertyTypes ?? true,
118130
lib: ["ESNext", "DOM"],
119131
module: "NodeNext",
120132
noEmit: true,
@@ -124,7 +136,7 @@ function assertGeneratedClientsCompile(spec: OpenAPISpec) {
124136
types: [],
125137
verbatimModuleSyntax: true
126138
},
127-
files: [schemaPath, typeOnlyPath]
139+
files
128140
})
129141
)
130142
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..")
@@ -2770,5 +2782,54 @@ export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
27702782
operationId: "getUser"
27712783
}
27722784
]))
2785+
2786+
it.effect("emits compilable open objects with optional properties", () =>
2787+
assertGeneratedClientsCompile(
2788+
{
2789+
openapi: "3.1.0",
2790+
info: {
2791+
title: "Open object regression API",
2792+
version: "1.0.0"
2793+
},
2794+
paths: {
2795+
"/widget": {
2796+
get: {
2797+
operationId: "getWidget",
2798+
parameters: [],
2799+
responses: {
2800+
200: {
2801+
description: "OK",
2802+
content: {
2803+
"application/json": {
2804+
schema: {
2805+
type: "object",
2806+
properties: {
2807+
optionalValue: {
2808+
type: "string"
2809+
}
2810+
},
2811+
additionalProperties: true
2812+
}
2813+
}
2814+
}
2815+
}
2816+
},
2817+
tags: ["Widgets"],
2818+
security: []
2819+
}
2820+
}
2821+
},
2822+
components: {
2823+
schemas: {},
2824+
securitySchemes: {}
2825+
},
2826+
security: [],
2827+
tags: []
2828+
},
2829+
{
2830+
exactOptionalPropertyTypes: false,
2831+
formats: ["httpclient"]
2832+
}
2833+
))
27732834
})
27742835
})

0 commit comments

Comments
 (0)