diff --git a/.chronus/changes/fix-openapi3-encode-nullable-property-2026-09-18.md b/.chronus/changes/fix-openapi3-encode-nullable-property-2026-09-18.md new file mode 100644 index 00000000000..6616496eb87 --- /dev/null +++ b/.chronus/changes/fix-openapi3-encode-nullable-property-2026-09-18.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix `@encode` on nullable properties and parameters in OpenAPI 3.1 and 3.2. The encoding is now applied to the `T` member of `anyOf: [T, { type: "null" }]` instead of being written next to `anyOf`. When the encoding changed the emitted type, like `unixTimestamp`, `seconds` or `@encode(string)` on a number, the old schema rejected every value. When the encoded type stayed a string, like `rfc7231` on `utcDateTime` or `base64url` on `bytes`, it only rejected `null`. `bytes | null` now also gets `contentEncoding` instead of `format`. diff --git a/packages/openapi3/src/openapi-helpers-3-1.ts b/packages/openapi3/src/openapi-helpers-3-1.ts index 5df24c7f7d5..6fdee3d5e84 100644 --- a/packages/openapi3/src/openapi-helpers-3-1.ts +++ b/packages/openapi3/src/openapi-helpers-3-1.ts @@ -1,13 +1,15 @@ -import type { ModelProperty, Scalar } from "@typespec/compiler"; +import { ObjectBuilder } from "@typespec/asset-emitter"; +import type { ModelProperty, Scalar, Type } from "@typespec/compiler"; +import { getEncode, isNullType } from "@typespec/compiler"; import { applyEncoding as baseApplyEncoding } from "./encoding.js"; import type { OpenApiSpecSpecificProps } from "./openapi-spec-mappings.js"; -import type { OpenAPISchema3_1 } from "./types.js"; +import type { OpenAPISchema3_1, Refable } from "./types.js"; import { isScalarExtendsBytes } from "./util.js"; function getEncodingFieldName(typespecType: Scalar | ModelProperty) { // In Open API 3.1, `contentEncoding` is used for encoded binary data instead of `format`. const typeIsBytes = isScalarExtendsBytes( - typespecType.kind === "ModelProperty" ? typespecType.type : typespecType, + typespecType.kind === "ModelProperty" ? getNonNullType(typespecType.type) : typespecType, ); if (typeIsBytes) { return "contentEncoding"; @@ -15,12 +17,43 @@ function getEncodingFieldName(typespecType: Scalar | ModelProperty) { return "format"; } +/** Returns `T` for a nullable union `T | null`, otherwise the type itself. */ +function getNonNullType(type: Type): Type { + if (type.kind !== "Union") { + return type; + } + const variants = [...type.variants.values()].filter((x) => !isNullType(x.type)); + return variants.length === 1 ? variants[0].type : type; +} + +function isNullSchema(schema: Refable): boolean { + return "type" in schema && schema.type === "null"; +} + export const applyEncoding: OpenApiSpecSpecificProps["applyEncoding"] = ( program, typespecType, target, options, ) => { + // A nullable `T | null` is emitted as `anyOf: [T, { type: "null" }]` in Open API 3.1. + // The encoding describes `T` so it is applied to that member and not to the `anyOf` wrapper. + const anyOf = (target as OpenAPISchema3_1).anyOf; + if (anyOf?.length === 2 && anyOf.some(isNullSchema) && getEncode(program, typespecType)) { + const result = new ObjectBuilder(target); + result.anyOf = anyOf.map((member) => + isNullSchema(member) + ? member + : baseApplyEncoding( + program, + typespecType, + member as OpenAPISchema3_1, + getEncodingFieldName, + options, + ), + ); + return result; + } return baseApplyEncoding(program, typespecType, target, getEncodingFieldName, options); }; diff --git a/packages/openapi3/test/nullable-properties.test.ts b/packages/openapi3/test/nullable-properties.test.ts index 51498235192..6b2167d5326 100644 --- a/packages/openapi3/test/nullable-properties.test.ts +++ b/packages/openapi3/test/nullable-properties.test.ts @@ -139,3 +139,39 @@ worksFor(["3.1.0"], ({ oapiForModel, openApiFor }) => { }); }); }); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel, openApiFor }) => { + describe("with @encode", () => { + it.each([ + [ + "DateTimeKnownEncoding.unixTimestamp, int32", + "utcDateTime", + { type: "integer", format: "unixtime" }, + ], + ["DateTimeKnownEncoding.rfc7231", "utcDateTime", { type: "string", format: "http-date" }], + ["DurationKnownEncoding.seconds, int32", "duration", { type: "integer", format: "int32" }], + ["string", "int64", { type: "string", format: "int64" }], + ["BytesKnownEncoding.base64url", "bytes", { type: "string", contentEncoding: "base64url" }], + ])( + "@encode(%s) applies to the non-null member of %s | null", + async (encode, type, expected) => { + const res = await oapiForModel( + "Test", + `model Test { @encode(${encode}) prop: ${type} | null }`, + ); + deepStrictEqual(res.schemas.Test.properties.prop, { + anyOf: [expected, { type: "null" }], + }); + }, + ); + + it("applies to the non-null member of a nullable query parameter", async () => { + const res = await openApiFor( + `op test(@query @encode(DateTimeKnownEncoding.unixTimestamp, int32) since: utcDateTime | null): void;`, + ); + deepStrictEqual(res.paths["/"].get.parameters[0].schema, { + anyOf: [{ type: "integer", format: "unixtime" }, { type: "null" }], + }); + }); + }); +});