Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 36 additions & 3 deletions packages/openapi3/src/openapi-helpers-3-1.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,59 @@
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";
}
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<OpenAPISchema3_1>): 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);
};

Expand Down
36 changes: 36 additions & 0 deletions packages/openapi3/test/nullable-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
});
});
});
});