diff --git a/.chronus/changes/fix-openapi-duration-properties-2026-01-19-19-29-03.md b/.chronus/changes/fix-openapi-duration-properties-2026-01-19-19-29-03.md new file mode 100644 index 00000000000..ff2a6900875 --- /dev/null +++ b/.chronus/changes/fix-openapi-duration-properties-2026-01-19-19-29-03.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +importer - OpenAPI number type with duration format now converts to TypeSpec duration type with @encode("seconds", float32) decorator diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts index c0564847f40..8a26e4173c5 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -535,6 +535,13 @@ export function getTypeSpecPrimitiveFromSchema(schema: SupportedOpenAPISchema): function getIntegerType(schema: SupportedOpenAPISchema): string { const format = schema.format ?? ""; + + // Check for x-ms-duration extension + const xmsDuration = (schema as any)["x-ms-duration"]; + if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { + return "duration"; + } + switch (format) { case "int8": case "int16": @@ -556,6 +563,13 @@ function getIntegerType(schema: SupportedOpenAPISchema): string { function getNumberType(schema: SupportedOpenAPISchema): string { const format = schema.format ?? ""; + + // Check for x-ms-duration extension + const xmsDuration = (schema as any)["x-ms-duration"]; + if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { + return "duration"; + } + switch (format) { case "decimal": case "decimal128": diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index 5f68e2122af..ad4edc57b2e 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -174,6 +174,13 @@ export function getDecoratorsForSchema( ? schema.type.find((t) => t !== "null") : schema.type; + // Handle x-ms-duration extension with @encode decorator + // Must be after effectiveType extraction to handle type arrays correctly + const xmsDuration = (schema as any)["x-ms-duration"]; + if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { + decorators.push(...getDurationSchemaDecorators(schema, effectiveType)); + } + // Handle unixtime format with @encode decorator // Check both direct format and format from anyOf/oneOf members let formatToUse = schema.format; @@ -322,6 +329,75 @@ function getUnixtimeSchemaDecorators(effectiveType: string | undefined) { return decorators; } +function getDurationSchemaDecorators( + schema: OpenAPI3Schema | OpenAPISchema3_1, + effectiveType: string | undefined, +) { + const decorators: TypeSpecDecorator[] = []; + + // Get the x-ms-duration value (seconds or milliseconds) + const xmsDuration = (schema as any)["x-ms-duration"]; + if (!xmsDuration || (xmsDuration !== "seconds" && xmsDuration !== "milliseconds")) { + return decorators; + } + + // Determine the encoding type based on the schema's format and type + let encodingType = "float32"; // default + const format = schema.format ?? ""; + + if (effectiveType === "integer") { + // For integer types, use the specific format or default to integer + switch (format) { + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + encodingType = format; + break; + default: + encodingType = "integer"; + } + } else if (effectiveType === "number") { + // For number types, use the specific format or default to float32 + switch (format) { + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + // Number type can have integer formats (e.g., type: number, format: int64) + encodingType = format; + break; + case "decimal": + case "decimal128": + encodingType = format; + break; + case "double": + encodingType = "float64"; + break; + case "float": + encodingType = "float32"; + break; + default: + encodingType = "float32"; + } + } + + decorators.push({ + name: "encode", + args: [createTSValue(`"${xmsDuration}"`), createTSValue(encodingType)], + }); + + return decorators; +} + const knownStringFormats = new Set([ "binary", "byte", diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index ba44e9f8234..426bbf20e38 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -93,6 +93,219 @@ describe("converts top-level schemas", () => { ]); }); + describe("handles duration types with x-ms-duration extension", () => { + it("string with duration format -> duration (no encoding)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationString: { + type: "string", + format: "duration", + }, + }, + }); + + const scalars = serviceNamespace.scalars; + /* scalar DurationString extends duration; */ + expect(scalars.get("DurationString")?.baseScalar?.name).toBe("duration"); + expect(scalars.get("DurationString")!.decorators.length).toBe(0); + }); + + it("integer with int32 format and no extension -> int32 (no encoding)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + IntegerInt32: { + type: "integer", + format: "int32", + }, + }, + }); + + const scalars = serviceNamespace.scalars; + /* scalar IntegerInt32 extends int32; */ + expect(scalars.get("IntegerInt32")?.baseScalar?.name).toBe("int32"); + expect(scalars.get("IntegerInt32")!.decorators.length).toBe(0); + }); + + it("integer with int32 format and x-ms-duration: seconds -> duration with @encode(seconds, int32)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationIntSeconds: { + type: "integer", + format: "int32", + "x-ms-duration": "seconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "seconds") @encode("seconds", int32) scalar DurationIntSeconds extends duration; */ + expect(scalars.get("DurationIntSeconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationIntSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "int32" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, + ]); + }); + + it("integer with int32 format and x-ms-duration: milliseconds -> duration with @encode(milliseconds, int32)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationIntMilliseconds: { + type: "integer", + format: "int32", + "x-ms-duration": "milliseconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", int32) scalar DurationIntMilliseconds extends duration; */ + expect(scalars.get("DurationIntMilliseconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationIntMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "int32" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, + ]); + }); + + it("number with float format and no extension -> float32 (no encoding)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + NumberFloat: { + type: "number", + format: "float", + }, + }, + }); + + const scalars = serviceNamespace.scalars; + /* scalar NumberFloat extends float32; */ + expect(scalars.get("NumberFloat")?.baseScalar?.name).toBe("float32"); + expect(scalars.get("NumberFloat")!.decorators.length).toBe(0); + }); + + it("number with float format and x-ms-duration: seconds -> duration with @encode(seconds, float32)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationFloatSeconds: { + type: "number", + format: "float", + "x-ms-duration": "seconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "seconds") @encode("seconds", float32) scalar DurationFloatSeconds extends duration; */ + expect(scalars.get("DurationFloatSeconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationFloatSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, + ]); + }); + + it("number with float format and x-ms-duration: milliseconds -> duration with @encode(milliseconds, float32)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationFloatMilliseconds: { + type: "number", + format: "float", + "x-ms-duration": "milliseconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", float32) scalar DurationFloatMilliseconds extends duration; */ + expect(scalars.get("DurationFloatMilliseconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationFloatMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "float32" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, + ]); + }); + + it("number with int64 format and x-ms-duration: seconds -> duration with @encode(seconds, int64)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationInt64Seconds: { + type: "number", + format: "int64", + "x-ms-duration": "seconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "seconds") @encode("seconds", int64) scalar DurationInt64Seconds extends duration; */ + expect(scalars.get("DurationInt64Seconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationInt64Seconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "int64" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, + ]); + }); + + it("number with decimal format and no extension -> decimal (no encoding)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + NumberDecimal: { + type: "number", + format: "decimal", + }, + }, + }); + + const scalars = serviceNamespace.scalars; + /* scalar NumberDecimal extends decimal; */ + expect(scalars.get("NumberDecimal")?.baseScalar?.name).toBe("decimal"); + expect(scalars.get("NumberDecimal")!.decorators.length).toBe(0); + }); + + it("number with decimal format and x-ms-duration: seconds -> duration with @encode(seconds, decimal)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationDecimalSeconds: { + type: "number", + format: "decimal", + "x-ms-duration": "seconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "seconds") @encode("seconds", decimal) scalar DurationDecimalSeconds extends duration; */ + expect(scalars.get("DurationDecimalSeconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationDecimalSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "decimal" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, + ]); + }); + + it("number with decimal format and x-ms-duration: milliseconds -> duration with @encode(milliseconds, decimal)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationDecimalMilliseconds: { + type: "number", + format: "decimal", + "x-ms-duration": "milliseconds", + } as any, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", decimal) scalar DurationDecimalMilliseconds extends duration; */ + expect(scalars.get("DurationDecimalMilliseconds")?.baseScalar?.name).toBe("duration"); + // Note: TypeSpec compiler may reorder decorators during parsing + expectDecorators(scalars.get("DurationDecimalMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "decimal" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, + ]); + }); + }); + it("handles arrays", async () => { const serviceNamespace = await tspForOpenAPI3({ schemas: { diff --git a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts index 38033dc131c..bfd2be6e924 100644 --- a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts +++ b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts @@ -49,6 +49,23 @@ const testScenarios: TestScenario[] = [ { schema: { type: "number", format: "double" }, expected: "float64" }, { schema: { type: "number", format: "float" }, expected: "float32" }, { schema: { type: "number", enum: [3.14, 6.28, 42] }, expected: "3.14 | 6.28 | 42" }, + // Duration tests with x-ms-duration extension + { + schema: { type: "integer", format: "int32", "x-ms-duration": "seconds" } as any, + expected: "duration", + }, + { + schema: { type: "integer", format: "int32", "x-ms-duration": "milliseconds" } as any, + expected: "duration", + }, + { + schema: { type: "number", format: "float", "x-ms-duration": "seconds" } as any, + expected: "duration", + }, + { + schema: { type: "number", format: "float", "x-ms-duration": "milliseconds" } as any, + expected: "duration", + }, // strings { schema: { type: "string" }, expected: "string" }, {