diff --git a/.changeset/fix-openapi-type-only-schema-import.md b/.changeset/fix-openapi-type-only-schema-import.md new file mode 100644 index 00000000000..db68e671eaf --- /dev/null +++ b/.changeset/fix-openapi-type-only-schema-import.md @@ -0,0 +1,5 @@ +--- +"@effect/openapi-generator": patch +--- + +Fix missing Schema imports in generated type-only HTTP clients with open object schemas. diff --git a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts index 2f2bbf05819..febf93181e3 100644 --- a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts +++ b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts @@ -128,11 +128,14 @@ function makeWithRepresentation() { renderSchemaTypeAndRuntime(name, generated.codeDocument.codes[i], typeOnly, options) ) - return renderImportArtifacts(generated.codeDocument, !typeOnly) + - render("recursive declarations", recursiveDeclarations) + + const declarations = render("recursive declarations", recursiveDeclarations) + render("non-recursive definitions", nonRecursives) + render("recursive definitions", recursives) + render("schemas", codes) + const schemaImport = typeOnly && referencesSchema(declarations) + ? "import type * as Schema from \"effect/Schema\"\n" + : "" + return renderImportArtifacts(generated.codeDocument, !typeOnly) + schemaImport + declarations } function generateHttpApi( @@ -343,6 +346,11 @@ function render(title: string, as: ReadonlyArray) { return "// " + title + "\n" + as.join("\n") + "\n" } +// Quoted property names and literal types can contain "Schema." without referencing the namespace. +function referencesSchema(declarations: string): boolean { + return /\bSchema\./.test(declarations.replace(/"(?:\\.|[^"\\])*"/g, "")) +} + function renderImportArtifacts(codeDocument: SchemaRepresentation.CodeDocument, enabled: boolean): string { if (!enabled) return "" const imports = codeDocument.artifacts.flatMap((artifact) => diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index 4410d32be71..26d85e343e7 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -38,6 +38,8 @@ const assertTypeOnly = Effect.fnUntraced(function*(spec: OpenAPISpec, expected: }) // console.log(result) + const failures = yield* typeCheckCases([{ sources: [result], exactOptionalPropertyTypes: true }], true) + assert.deepStrictEqual(failures, [[]]) assert.strictEqual(result, expected) }, Effect.provide(OpenApiGenerator.layerTransformerTs)) @@ -148,7 +150,8 @@ const parseDiagnostics = (output: string) => { // which cases are clean; each of its cases is then checked on its own. const typeCheckCases = Effect.fnUntraced( function*( - cases: ReadonlyArray<{ readonly sources: ReadonlyArray; readonly exactOptionalPropertyTypes: boolean }> + cases: ReadonlyArray<{ readonly sources: ReadonlyArray; readonly exactOptionalPropertyTypes: boolean }>, + noUnusedLocals = false ) { const directory = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".generated-clients-")) return yield* Effect.gen(function*() { @@ -171,6 +174,7 @@ const typeCheckCases = Effect.fnUntraced( lib: ["ESNext", "DOM"], module: "NodeNext", noEmit: true, + noUnusedLocals, skipLibCheck: true, strict: true, target: "ES2022", @@ -1376,11 +1380,12 @@ export const TestClientError = ( id: { type: "string" }, - name: { - type: "string" + "Schema.property": { + type: "string", + enum: ["Schema.Json"] } }, - required: ["id", "name"], + required: ["id", "Schema.property"], additionalProperties: false } } @@ -1406,7 +1411,7 @@ import * as HttpClientError from "effect/http/HttpClientError" import * as HttpClientRequest from "effect/http/HttpClientRequest" import * as HttpClientResponse from "effect/http/HttpClientResponse" // schemas -export type GetUser200 = { readonly "id": string, readonly "name": string } +export type GetUser200 = { readonly "id": string, readonly "Schema.property": "Schema.Json" } export interface OperationConfig { /** @@ -1574,7 +1579,7 @@ export const TestClientError = ( response, request: response.request, }) as any` - )) + ), compilationTimeout) it.effect("preserves response variants and routes binary and bodiless statuses", () => assertTypeOnlyIncludes(responseMatchingSpec, [ @@ -3858,6 +3863,41 @@ export const withoutLocale = client.getGreeting({ params: {} }) } ])) + itGeneratedClientsCompile("emits compilable type-only clients with implicit additional properties", { + openapi: "3.1.0", + info: { title: "Open object API", version: "1.0.0" }, + paths: { + "/users": { + get: { + operationId: "getUser", + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { schema: { $ref: "#/components/schemas/User" } } + } + } + }, + tags: ["Users"], + security: [] + } + } + }, + components: { + schemas: { + User: { + type: "object", + properties: { Name: { type: "string" } }, + required: ["Name"] + } + }, + securitySchemes: {} + }, + security: [], + tags: [] + }, { formats: ["httpclient-type-only"] }) + itGeneratedClientsCompile("emits compilable open objects with optional properties", { openapi: "3.1.0", info: {