From 0a944fe7576db24e4d7513ca4d8de6b64ba38d64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:16:23 +0000 Subject: [PATCH 1/9] Initial plan From 53b3f99f48c314d977ab9d625a32995855beac64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:27:27 +0000 Subject: [PATCH 2/9] feat(openapi3): convert number type with duration format to duration with @encode decorator Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../convert/generators/generate-types.ts | 2 ++ .../cli/actions/convert/utils/decorators.ts | 18 ++++++++++++++++++ .../test/tsp-openapi3/data-types.test.ts | 18 ++++++++++++++++++ .../test/tsp-openapi3/generate-type.test.ts | 1 + 4 files changed, 39 insertions(+) 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..6d22fdb87b2 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -564,6 +564,8 @@ function getNumberType(schema: SupportedOpenAPISchema): string { return "float64"; case "float": return "float32"; + case "duration": + return "duration"; default: // Could be either 'float' or 'numeric' - add FIXME? return "numeric"; diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index 5f68e2122af..6ec4344eb56 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -206,6 +206,11 @@ export function getDecoratorsForSchema( decorators.push(...getUnixtimeSchemaDecorators(typeForFormat)); } + // Handle duration format with @encode decorator + if (formatToUse === "duration" && typeForFormat === "number") { + decorators.push(...getDurationSchemaDecorators(schema)); + } + switch (effectiveType) { case "array": decorators.push(...getArraySchemaDecorators(schema)); @@ -322,6 +327,19 @@ function getUnixtimeSchemaDecorators(effectiveType: string | undefined) { return decorators; } +function getDurationSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) { + const decorators: TypeSpecDecorator[] = []; + + // For number type with duration format, encode as seconds with float32 by default + // This matches the expected behavior in the issue description + decorators.push({ + name: "encode", + args: [createTSValue(`"seconds"`), createTSValue("float32")], + }); + + 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..dcc9cb87701 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -93,6 +93,24 @@ describe("converts top-level schemas", () => { ]); }); + it("handles number with duration format as duration with @encode decorator", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + DurationInSeconds: { + type: "number", + format: "duration", + }, + }, + }); + + const scalars = serviceNamespace.scalars; + /* @encode("seconds", float32) scalar DurationInSeconds extends duration; */ + expect(scalars.get("DurationInSeconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationInSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, + ]); + }); + 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..0374984c90d 100644 --- a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts +++ b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts @@ -48,6 +48,7 @@ const testScenarios: TestScenario[] = [ { schema: { type: "number", format: "decimal128" }, expected: "decimal128" }, { schema: { type: "number", format: "double" }, expected: "float64" }, { schema: { type: "number", format: "float" }, expected: "float32" }, + { schema: { type: "number", format: "duration" }, expected: "duration" }, { schema: { type: "number", enum: [3.14, 6.28, 42] }, expected: "3.14 | 6.28 | 42" }, // strings { schema: { type: "string" }, expected: "string" }, From 76122217aebfc69b9f61d418e72a09b262c0f3fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:31:31 +0000 Subject: [PATCH 3/9] chore: add changelog entry for duration format fix Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../fix-openapi-duration-properties-2026-01-19-19-29-03.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/fix-openapi-duration-properties-2026-01-19-19-29-03.md 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 From 09b3593347a40fb89bf9cc5e4734fa81f722a4e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:46:50 +0000 Subject: [PATCH 4/9] test(openapi3): add regression test for string duration format Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../test/tsp-openapi3/data-types.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index dcc9cb87701..e73a0c82d16 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -111,6 +111,23 @@ describe("converts top-level schemas", () => { ]); }); + it("handles string with duration format as duration without @encode decorator", 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"); + // Should have no encode decorator for string type with duration format + expect(scalars.get("DurationString")!.decorators.length).toBe(0); + }); + it("handles arrays", async () => { const serviceNamespace = await tspForOpenAPI3({ schemas: { From f9e76ce762e8bf99e7bc6e6b1b87c88453ac069a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:17:43 +0000 Subject: [PATCH 5/9] feat(openapi3): add x-ms-duration extension support for duration encoding - Add support for x-ms-duration extension (seconds/milliseconds) on integer and number types - Update type generation for both integers and numbers with x-ms-duration - Generate appropriate @encode decorators based on x-ms-duration value and schema format - Filter x-ms-duration from extension decorators to avoid duplication - Update tests to cover all duration scenarios from requirements table - Remove old format: duration support for numbers (replaced by x-ms-duration) Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../convert/generators/generate-types.ts | 16 +- .../cli/actions/convert/utils/decorators.ts | 58 +++++- .../test/tsp-openapi3/data-types.test.ts | 197 +++++++++++++++--- .../test/tsp-openapi3/generate-type.test.ts | 18 +- 4 files changed, 255 insertions(+), 34 deletions(-) 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 6d22fdb87b2..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": @@ -564,8 +578,6 @@ function getNumberType(schema: SupportedOpenAPISchema): string { return "float64"; case "float": return "float32"; - case "duration": - return "duration"; default: // Could be either 'float' or 'numeric' - add FIXME? return "numeric"; diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index 6ec4344eb56..bc91e760ba2 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -19,6 +19,11 @@ export function getExtensions(element: Extensions): TypeSpecDecorator[] { const decorators: TypeSpecDecorator[] = []; for (const key of Object.keys(element)) { + // Skip x-ms-duration as it's handled separately for duration encoding + if (key === "x-ms-duration") { + continue; + } + if (isExtensionKey(key)) { decorators.push({ name: extensionDecoratorName, @@ -206,8 +211,9 @@ export function getDecoratorsForSchema( decorators.push(...getUnixtimeSchemaDecorators(typeForFormat)); } - // Handle duration format with @encode decorator - if (formatToUse === "duration" && typeForFormat === "number") { + // Handle x-ms-duration extension with @encode decorator + const xmsDuration = (schema as any)["x-ms-duration"]; + if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { decorators.push(...getDurationSchemaDecorators(schema)); } @@ -330,11 +336,53 @@ function getUnixtimeSchemaDecorators(effectiveType: string | undefined) { function getDurationSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) { const decorators: TypeSpecDecorator[] = []; - // For number type with duration format, encode as seconds with float32 by default - // This matches the expected behavior in the issue description + // 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 + let encodingType = "float32"; // default + const format = schema.format ?? ""; + + if (schema.type === "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 (schema.type === "number") { + // For number types, use the specific format or default to float32 + switch (format) { + 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(`"seconds"`), createTSValue("float32")], + args: [createTSValue(`"${xmsDuration}"`), createTSValue(encodingType)], }); return decorators; diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index e73a0c82d16..5b0d091831e 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -93,39 +93,184 @@ describe("converts top-level schemas", () => { ]); }); - it("handles number with duration format as duration with @encode decorator", async () => { - const serviceNamespace = await tspForOpenAPI3({ - schemas: { - DurationInSeconds: { - type: "number", - format: "duration", + 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); }); - const scalars = serviceNamespace.scalars; - /* @encode("seconds", float32) scalar DurationInSeconds extends duration; */ - expect(scalars.get("DurationInSeconds")?.baseScalar?.name).toBe("duration"); - expectDecorators(scalars.get("DurationInSeconds")!.decorators, [ - { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, - ]); - }); + it("integer with int32 format and no extension -> int32 (no encoding)", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + IntegerInt32: { + type: "integer", + format: "int32", + }, + }, + }); - it("handles string with duration format as duration without @encode decorator", async () => { - const serviceNamespace = await tspForOpenAPI3({ - schemas: { - DurationString: { - type: "string", - format: "duration", + 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; + /* @encode("seconds", int32) scalar DurationIntSeconds extends duration; */ + expect(scalars.get("DurationIntSeconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationIntSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "int32" }] }, + ]); }); - const scalars = serviceNamespace.scalars; - /* scalar DurationString extends duration; */ - expect(scalars.get("DurationString")?.baseScalar?.name).toBe("duration"); - // Should have no encode decorator for string type with duration format - expect(scalars.get("DurationString")!.decorators.length).toBe(0); + 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; + /* @encode("milliseconds", int32) scalar DurationIntMilliseconds extends duration; */ + expect(scalars.get("DurationIntMilliseconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationIntMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "int32" }] }, + ]); + }); + + 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; + /* @encode("seconds", float32) scalar DurationFloatSeconds extends duration; */ + expect(scalars.get("DurationFloatSeconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationFloatSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, + ]); + }); + + 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; + /* @encode("milliseconds", float32) scalar DurationFloatMilliseconds extends duration; */ + expect(scalars.get("DurationFloatMilliseconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationFloatMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "float32" }] }, + ]); + }); + + 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; + /* @encode("seconds", decimal) scalar DurationDecimalSeconds extends duration; */ + expect(scalars.get("DurationDecimalSeconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationDecimalSeconds")!.decorators, [ + { name: "encode", args: ["seconds", { kind: "Scalar", name: "decimal" }] }, + ]); + }); + + 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; + /* @encode("milliseconds", decimal) scalar DurationDecimalMilliseconds extends duration; */ + expect(scalars.get("DurationDecimalMilliseconds")?.baseScalar?.name).toBe("duration"); + expectDecorators(scalars.get("DurationDecimalMilliseconds")!.decorators, [ + { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "decimal" }] }, + ]); + }); }); it("handles arrays", async () => { diff --git a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts index 0374984c90d..bfd2be6e924 100644 --- a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts +++ b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts @@ -48,8 +48,24 @@ const testScenarios: TestScenario[] = [ { schema: { type: "number", format: "decimal128" }, expected: "decimal128" }, { schema: { type: "number", format: "double" }, expected: "float64" }, { schema: { type: "number", format: "float" }, expected: "float32" }, - { schema: { type: "number", format: "duration" }, expected: "duration" }, { 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" }, { From d7f45bd3bb1365922cf7a016cbb85a6f3bd1462a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:39:16 +0000 Subject: [PATCH 6/9] feat(openapi3): emit x-ms-duration extension when encoding duration types - Add x-ms-duration extension to OpenAPI output when duration type has @encode decorator - Support both seconds and milliseconds encoding - Update applyEncoding to detect duration types and add extension - Add comprehensive tests for all duration encoding scenarios with extension emission - Walk up scalar chain to detect duration as base type Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- packages/openapi3/src/encoding.ts | 18 +++++++++++ .../openapi3/test/primitive-types.test.ts | 30 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/openapi3/src/encoding.ts b/packages/openapi3/src/encoding.ts index a7e9de5832d..06a076800e1 100644 --- a/packages/openapi3/src/encoding.ts +++ b/packages/openapi3/src/encoding.ts @@ -39,6 +39,24 @@ export function applyEncoding( encodeData.encoding, newType.format, ); + + // Check if the original typespecType (before encoding) is duration with seconds or milliseconds encoding + // If so, add the x-ms-duration extension + const originalType = typespecType.kind === "Scalar" ? typespecType : typespecType.type; + if (originalType.kind === "Scalar") { + // Walk up the scalar chain to find the base type + let currentScalar: Scalar | undefined = originalType; + while (currentScalar) { + if (currentScalar.name === "duration") { + if (encodeData.encoding === "seconds" || encodeData.encoding === "milliseconds") { + (targetObject as any)["x-ms-duration"] = encodeData.encoding; + } + break; + } + currentScalar = currentScalar.baseScalar; + } + } + return targetObject; } diff --git a/packages/openapi3/test/primitive-types.test.ts b/packages/openapi3/test/primitive-types.test.ts index bd5d856f26d..5ce201ac19a 100644 --- a/packages/openapi3/test/primitive-types.test.ts +++ b/packages/openapi3/test/primitive-types.test.ts @@ -300,8 +300,34 @@ worksFor(supportedVersions, ({ oapiForModel, openApiFor }) => { describe("duration", () => { it("set format to 'duration' by default", () => testEncode("duration", { type: "string", format: "duration" })); - it("set integer with int32 format setting duration as seconds", () => - testEncode("duration", { type: "integer", format: "int32" }, "seconds", "int32")); + it("set integer with int32 format and x-ms-duration extension when encoding as seconds", () => + testEncode( + "duration", + { type: "integer", format: "int32", "x-ms-duration": "seconds" }, + "seconds", + "int32", + )); + it("set integer with int32 format and x-ms-duration extension when encoding as milliseconds", () => + testEncode( + "duration", + { type: "integer", format: "int32", "x-ms-duration": "milliseconds" }, + "milliseconds", + "int32", + )); + it("set number with float format and x-ms-duration extension when encoding as seconds", () => + testEncode( + "duration", + { type: "number", format: "float", "x-ms-duration": "seconds" }, + "seconds", + "float32", + )); + it("set number with decimal format and x-ms-duration extension when encoding as milliseconds", () => + testEncode( + "duration", + { type: "number", format: "decimal", "x-ms-duration": "milliseconds" }, + "milliseconds", + "decimal", + )); }); describe("int64", () => { From 3eab35dfedb01e8700d6595d16aabc6df603234c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:13:59 +0000 Subject: [PATCH 7/9] refactor(openapi3): use @extension decorator for x-ms-duration instead of auto-generating extension - Revert export functionality that auto-generated x-ms-duration extension - Update import to add @extension decorator when x-ms-duration is present - Both @extension and @encode decorators are now added during import - Removes extension filtering for x-ms-duration - Update tests to expect both decorators - Enables proper round-tripping via @extension decorator Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../cli/actions/convert/utils/decorators.ts | 18 +++++------ packages/openapi3/src/encoding.ts | 18 ----------- .../openapi3/test/primitive-types.test.ts | 30 ++----------------- .../test/tsp-openapi3/data-types.test.ts | 18 +++++++---- 4 files changed, 21 insertions(+), 63 deletions(-) diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index bc91e760ba2..780fefacfa4 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -19,11 +19,6 @@ export function getExtensions(element: Extensions): TypeSpecDecorator[] { const decorators: TypeSpecDecorator[] = []; for (const key of Object.keys(element)) { - // Skip x-ms-duration as it's handled separately for duration encoding - if (key === "x-ms-duration") { - continue; - } - if (isExtensionKey(key)) { decorators.push({ name: extensionDecoratorName, @@ -171,6 +166,13 @@ export function getDecoratorsForSchema( return decorators; } + // Handle x-ms-duration extension with @encode decorator + // Add this before getExtensions so the order is @encode first, then @extension + const xmsDuration = (schema as any)["x-ms-duration"]; + if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { + decorators.push(...getDurationSchemaDecorators(schema)); + } + decorators.push(...getExtensions(schema)); // Handle OpenAPI 3.1 type arrays like ["integer", "null"] @@ -211,12 +213,6 @@ export function getDecoratorsForSchema( decorators.push(...getUnixtimeSchemaDecorators(typeForFormat)); } - // Handle x-ms-duration extension with @encode decorator - const xmsDuration = (schema as any)["x-ms-duration"]; - if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { - decorators.push(...getDurationSchemaDecorators(schema)); - } - switch (effectiveType) { case "array": decorators.push(...getArraySchemaDecorators(schema)); diff --git a/packages/openapi3/src/encoding.ts b/packages/openapi3/src/encoding.ts index 06a076800e1..a7e9de5832d 100644 --- a/packages/openapi3/src/encoding.ts +++ b/packages/openapi3/src/encoding.ts @@ -39,24 +39,6 @@ export function applyEncoding( encodeData.encoding, newType.format, ); - - // Check if the original typespecType (before encoding) is duration with seconds or milliseconds encoding - // If so, add the x-ms-duration extension - const originalType = typespecType.kind === "Scalar" ? typespecType : typespecType.type; - if (originalType.kind === "Scalar") { - // Walk up the scalar chain to find the base type - let currentScalar: Scalar | undefined = originalType; - while (currentScalar) { - if (currentScalar.name === "duration") { - if (encodeData.encoding === "seconds" || encodeData.encoding === "milliseconds") { - (targetObject as any)["x-ms-duration"] = encodeData.encoding; - } - break; - } - currentScalar = currentScalar.baseScalar; - } - } - return targetObject; } diff --git a/packages/openapi3/test/primitive-types.test.ts b/packages/openapi3/test/primitive-types.test.ts index 5ce201ac19a..bd5d856f26d 100644 --- a/packages/openapi3/test/primitive-types.test.ts +++ b/packages/openapi3/test/primitive-types.test.ts @@ -300,34 +300,8 @@ worksFor(supportedVersions, ({ oapiForModel, openApiFor }) => { describe("duration", () => { it("set format to 'duration' by default", () => testEncode("duration", { type: "string", format: "duration" })); - it("set integer with int32 format and x-ms-duration extension when encoding as seconds", () => - testEncode( - "duration", - { type: "integer", format: "int32", "x-ms-duration": "seconds" }, - "seconds", - "int32", - )); - it("set integer with int32 format and x-ms-duration extension when encoding as milliseconds", () => - testEncode( - "duration", - { type: "integer", format: "int32", "x-ms-duration": "milliseconds" }, - "milliseconds", - "int32", - )); - it("set number with float format and x-ms-duration extension when encoding as seconds", () => - testEncode( - "duration", - { type: "number", format: "float", "x-ms-duration": "seconds" }, - "seconds", - "float32", - )); - it("set number with decimal format and x-ms-duration extension when encoding as milliseconds", () => - testEncode( - "duration", - { type: "number", format: "decimal", "x-ms-duration": "milliseconds" }, - "milliseconds", - "decimal", - )); + it("set integer with int32 format setting duration as seconds", () => + testEncode("duration", { type: "integer", format: "int32" }, "seconds", "int32")); }); describe("int64", () => { diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index 5b0d091831e..c07f053dddf 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -138,9 +138,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("seconds", int32) scalar DurationIntSeconds extends duration; */ + /* @extension("x-ms-duration", "seconds") @encode("seconds", int32) scalar DurationIntSeconds extends duration; */ expect(scalars.get("DurationIntSeconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationIntSeconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "int32" }] }, ]); }); @@ -157,9 +158,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("milliseconds", int32) scalar DurationIntMilliseconds extends duration; */ + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", int32) scalar DurationIntMilliseconds extends duration; */ expect(scalars.get("DurationIntMilliseconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationIntMilliseconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "int32" }] }, ]); }); @@ -192,9 +194,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("seconds", float32) scalar DurationFloatSeconds extends duration; */ + /* @extension("x-ms-duration", "seconds") @encode("seconds", float32) scalar DurationFloatSeconds extends duration; */ expect(scalars.get("DurationFloatSeconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationFloatSeconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, ]); }); @@ -211,9 +214,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("milliseconds", float32) scalar DurationFloatMilliseconds extends duration; */ + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", float32) scalar DurationFloatMilliseconds extends duration; */ expect(scalars.get("DurationFloatMilliseconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationFloatMilliseconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "float32" }] }, ]); }); @@ -246,9 +250,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("seconds", decimal) scalar DurationDecimalSeconds extends duration; */ + /* @extension("x-ms-duration", "seconds") @encode("seconds", decimal) scalar DurationDecimalSeconds extends duration; */ expect(scalars.get("DurationDecimalSeconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationDecimalSeconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "decimal" }] }, ]); }); @@ -265,9 +270,10 @@ describe("converts top-level schemas", () => { }); const scalars = serviceNamespace.scalars; - /* @encode("milliseconds", decimal) scalar DurationDecimalMilliseconds extends duration; */ + /* @extension("x-ms-duration", "milliseconds") @encode("milliseconds", decimal) scalar DurationDecimalMilliseconds extends duration; */ expect(scalars.get("DurationDecimalMilliseconds")?.baseScalar?.name).toBe("duration"); expectDecorators(scalars.get("DurationDecimalMilliseconds")!.decorators, [ + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "decimal" }] }, ]); }); From 446c2e1c6405df21aad1679c698575c41664909a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:22:58 +0000 Subject: [PATCH 8/9] fix(openapi3): use effectiveType instead of schema.type for duration encoding format - Pass effectiveType to getDurationSchemaDecorators to handle OpenAPI 3.1 type arrays - Fixes issue where format was always float32 regardless of actual format - Update test expectations to match TypeSpec compiler's decorator ordering - Now correctly generates int32, float32, float64, decimal based on schema format Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../cli/actions/convert/utils/decorators.ts | 25 +++++++++++-------- .../test/tsp-openapi3/data-types.test.ts | 18 ++++++++----- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index 780fefacfa4..c03feea1697 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -166,13 +166,6 @@ export function getDecoratorsForSchema( return decorators; } - // Handle x-ms-duration extension with @encode decorator - // Add this before getExtensions so the order is @encode first, then @extension - const xmsDuration = (schema as any)["x-ms-duration"]; - if (xmsDuration === "seconds" || xmsDuration === "milliseconds") { - decorators.push(...getDurationSchemaDecorators(schema)); - } - decorators.push(...getExtensions(schema)); // Handle OpenAPI 3.1 type arrays like ["integer", "null"] @@ -181,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; @@ -329,7 +329,10 @@ function getUnixtimeSchemaDecorators(effectiveType: string | undefined) { return decorators; } -function getDurationSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) { +function getDurationSchemaDecorators( + schema: OpenAPI3Schema | OpenAPISchema3_1, + effectiveType: string | undefined, +) { const decorators: TypeSpecDecorator[] = []; // Get the x-ms-duration value (seconds or milliseconds) @@ -338,11 +341,11 @@ function getDurationSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) return decorators; } - // Determine the encoding type based on the schema's format + // Determine the encoding type based on the schema's format and type let encodingType = "float32"; // default const format = schema.format ?? ""; - if (schema.type === "integer") { + if (effectiveType === "integer") { // For integer types, use the specific format or default to integer switch (format) { case "int8": @@ -358,7 +361,7 @@ function getDurationSchemaDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1) default: encodingType = "integer"; } - } else if (schema.type === "number") { + } else if (effectiveType === "number") { // For number types, use the specific format or default to float32 switch (format) { case "decimal": diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index c07f053dddf..82e2f8175cf 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -140,9 +140,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "int32" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, ]); }); @@ -160,9 +161,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "int32" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, ]); }); @@ -196,9 +198,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "float32" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, ]); }); @@ -216,9 +219,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "float32" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, ]); }); @@ -252,9 +256,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "seconds"] }, { name: "encode", args: ["seconds", { kind: "Scalar", name: "decimal" }] }, + { name: "extension", args: ["x-ms-duration", "seconds"] }, ]); }); @@ -272,9 +277,10 @@ describe("converts top-level schemas", () => { 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: "extension", args: ["x-ms-duration", "milliseconds"] }, { name: "encode", args: ["milliseconds", { kind: "Scalar", name: "decimal" }] }, + { name: "extension", args: ["x-ms-duration", "milliseconds"] }, ]); }); }); From 8772da445ab5813866f8e8cea4c0b60b41b6e26f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:41:29 +0000 Subject: [PATCH 9/9] feat(openapi3): support integer formats (int8-uint64) for number type with x-ms-duration - Add support for integer formats (int8, int16, int32, int64, uint8, uint16, uint32, uint64) when type is number - Fixes scenario where type: number, format: int64, x-ms-duration: seconds now correctly generates @encode("seconds", int64) - Add test case for number with int64 format and x-ms-duration extension - All 33 data-types tests pass Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../cli/actions/convert/utils/decorators.ts | 11 ++++++++++ .../test/tsp-openapi3/data-types.test.ts | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index c03feea1697..ad4edc57b2e 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -364,6 +364,17 @@ function getDurationSchemaDecorators( } 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; diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index 82e2f8175cf..426bbf20e38 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -226,6 +226,27 @@ describe("converts top-level schemas", () => { ]); }); + 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: {