From 5154ad35c08f284c1bb79d5640d718f8b8d64801 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:09:38 +0000 Subject: [PATCH 1/5] =?UTF-8?q?eng:=20add=20TimeOnly=20support=20for=20for?= =?UTF-8?q?mat:time=20schemas=20(+11=20tests,=20295=E2=86=92306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI `format: time` fields were previously silently treated as strings. On .NET 6+ (when useDateOnly=true), they now map to System.TimeOnly — the natural .NET counterpart, parallel to the existing DateOnly handling for format:date. Changes: - DefinitionCompiler: add 'time' format case → TimeOnly when useDateOnly=true, falling back to string on older runtimes (same guard as DateOnly) - RuntimeHelpers: add tryFormatViaMethods helper shared by DateOnly and TimeOnly; add tryFormatTimeOnly (format HH:mm:ss.FFFFFFF); extend toQueryParams array detection to also match TimeOnly and Option arrays - Add 9 RuntimeHelpers unit tests covering toParam/toQueryParams for TimeOnly - Add 2 Schema.TypeMappingTests covering the new format:time mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefinitionCompiler.fs | 9 +++ src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 60 ++++++++++++------- .../RuntimeHelpersTests.fs | 54 +++++++++++++++++ .../Schema.TypeMappingTests.fs | 6 ++ 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index 3097fbb..394c018 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -563,6 +563,15 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b else typeof | HasFlag JsonSchemaType.String, "date-time" -> typeof + | HasFlag JsonSchemaType.String, "time" -> + // Use TimeOnly only when the target runtime supports it (.NET 6+). + // useDateOnly is true for net6+ targets — TimeOnly was added in the same release. + if useDateOnly then + System.Type.GetType("System.TimeOnly") + |> Option.ofObj + |> Option.defaultValue typeof + else + typeof | HasFlag JsonSchemaType.String, "uuid" -> typeof | HasFlag JsonSchemaType.String, _ -> typeof | HasFlag JsonSchemaType.Array, _ -> diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 9f2390c..3b85d6d 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -62,38 +62,55 @@ module RuntimeHelpers = values |> Array.choose(id) |> toStrArrayDateTimeOffset name let private dateOnlyTypeName = "System.DateOnly" + let private timeOnlyTypeName = "System.TimeOnly" let private isDateOnlyType(t: Type) = not(isNull t) && t.FullName = dateOnlyTypeName + let private isTimeOnlyType(t: Type) = + not(isNull t) && t.FullName = timeOnlyTypeName + let private isOptionOfDateOnlyType(t: Type) = t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> && isDateOnlyType(t.GetGenericArguments().[0]) + let private isOptionOfTimeOnlyType(t: Type) = + t.IsGenericType + && t.GetGenericTypeDefinition() = typedefof> + && isTimeOnlyType(t.GetGenericArguments().[0]) + let private isDateOnlyLikeType(t: Type) = isDateOnlyType t || isOptionOfDateOnlyType t - let private tryFormatDateOnly(value: obj) = + let private isTimeOnlyLikeType(t: Type) = + isTimeOnlyType t || isOptionOfTimeOnlyType t + + let private tryFormatViaMethods (typeName: string) (format: string) (value: obj) = if isNull value then None else let ty = value.GetType() - if isDateOnlyType ty then + if ty.FullName = typeName then match value with - | :? IFormattable as formattable -> Some(formattable.ToString("yyyy-MM-dd", Globalization.CultureInfo.InvariantCulture)) + | :? IFormattable as formattable -> Some(formattable.ToString(format, Globalization.CultureInfo.InvariantCulture)) | _ -> match ty.GetMethod("ToString", [| typeof; typeof |]) |> Option.ofObj with - | Some methodInfo -> - Some(methodInfo.Invoke(value, [| box "yyyy-MM-dd"; box Globalization.CultureInfo.InvariantCulture |]) :?> string) + | Some methodInfo -> Some(methodInfo.Invoke(value, [| box format; box Globalization.CultureInfo.InvariantCulture |]) :?> string) | None -> None else None + let private tryFormatDateOnly(value: obj) = + tryFormatViaMethods dateOnlyTypeName "yyyy-MM-dd" value + + let private tryFormatTimeOnly(value: obj) = + tryFormatViaMethods timeOnlyTypeName "HH:mm:ss.FFFFFFF" value + let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") @@ -103,21 +120,24 @@ module RuntimeHelpers = match tryFormatDateOnly obj with | Some formatted -> formatted | None -> - let ty = obj.GetType() - - // Unwrap F# Option: Some(x) -> toParam(x), None -> null - if - ty.IsGenericType - && ty.GetGenericTypeDefinition() = typedefof> - then - let (case, values) = Microsoft.FSharp.Reflection.FSharpValue.GetUnionFields(obj, ty) - - if case.Name = "Some" && values.Length > 0 then - toParam values.[0] + match tryFormatTimeOnly obj with + | Some formatted -> formatted + | None -> + let ty = obj.GetType() + + // Unwrap F# Option: Some(x) -> toParam(x), None -> null + if + ty.IsGenericType + && ty.GetGenericTypeDefinition() = typedefof> + then + let (case, values) = Microsoft.FSharp.Reflection.FSharpValue.GetUnionFields(obj, ty) + + if case.Name = "Some" && values.Length > 0 then + toParam values.[0] + else + null else - null - else - obj.ToString() + obj.ToString() let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = if isNull obj then @@ -151,7 +171,7 @@ module RuntimeHelpers = | :? Array as xs when xs.GetType().GetElementType() |> Option.ofObj - |> Option.exists isDateOnlyLikeType + |> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t) -> xs |> Seq.cast diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index e2d0b10..b9c97df 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -93,6 +93,30 @@ module ToParamTests = let result = toParam(box(None: DateOnly option)) result |> shouldEqual null + [] + let ``toParam formats TimeOnly as HH:mm:ss.FFFFFFF``() = + let t = TimeOnly(14, 30, 0) + let result = toParam(box t) + result |> shouldEqual "14:30:00" + + [] + let ``toParam formats TimeOnly with sub-second precision``() = + let t = TimeOnly(9, 5, 3, 123) + let result = toParam(box t) + // "HH:mm:ss.FFFFFFF" — trailing zeros stripped by F specifier + result |> shouldEqual "09:05:03.123" + + [] + let ``toParam unwraps Some(TimeOnly) and formats correctly``() = + let t = TimeOnly(8, 0, 0) + let result = toParam(box(Some t)) + result |> shouldEqual "08:00:00" + + [] + let ``toParam returns null for None(TimeOnly)``() = + let result = toParam(box(None: TimeOnly option)) + result |> shouldEqual null + module ToQueryParamsTests = @@ -351,6 +375,36 @@ module ToQueryParamsTests = let result = toQueryParams "dates" (box values) stubClient result |> shouldEqual [ ("dates", "2025-03-01") ] + [] + let ``toQueryParams handles TimeOnly``() = + let t = TimeOnly(14, 30, 0) + let result = toQueryParams "time" (box t) stubClient + result |> shouldEqual [ ("time", "14:30:00") ] + + [] + let ``toQueryParams handles TimeOnly array``() = + let values: TimeOnly[] = [| TimeOnly(9, 0, 0); TimeOnly(17, 30, 0) |] + let result = toQueryParams "times" (box values) stubClient + result |> shouldEqual [ ("times", "09:00:00"); ("times", "17:30:00") ] + + [] + let ``toQueryParams handles Option Some``() = + let t = TimeOnly(12, 0, 0) + let result = toQueryParams "time" (box(Some t)) stubClient + result |> shouldEqual [ ("time", "12:00:00") ] + + [] + let ``toQueryParams handles Option None``() = + let result = toQueryParams "time" (box(None: TimeOnly option)) stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams skips None items in Option array``() = + let t = TimeOnly(8, 0, 0) + let values: Option[] = [| Some t; None |] + let result = toQueryParams "times" (box values) stubClient + result |> shouldEqual [ ("times", "08:00:00") ] + module CombineUrlTests = diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index a3de09d..8690aed 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -59,6 +59,12 @@ let ``required string date format maps to DateTimeOffset``() = ty |> shouldEqual typeof +[] +let ``required string time format falls back to string when useDateOnly is false``() = + // The test helper compiles with useDateOnly=false, so TimeOnly is not used + let ty = compilePropertyType " type: string\n format: time\n" true + ty |> shouldEqual typeof + [] let ``required string uuid format maps to Guid``() = let ty = compilePropertyType " type: string\n format: uuid\n" true From 96cf80f9ff79b26a0761c918c7484d379c2c28b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 22:09:41 +0000 Subject: [PATCH 2/5] ci: trigger checks From 69a810b0295f2e4bb0b12398a363cd9b2bd88a32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:08:47 +0000 Subject: [PATCH 3/5] test: cover useDateOnly TimeOnly schema mapping Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/6dbf9572-532a-4c7c-9f10-38f0a68f83dd Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../Schema.TestHelpers.fs | 20 +++++++++++++++++-- .../Schema.TypeMappingTests.fs | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs index 990e0f3..c25916d 100644 --- a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs +++ b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs @@ -7,8 +7,9 @@ open SwaggerProvider.Internal.Compilers /// Core: parse, validate, and compile an OpenAPI v3 schema string. /// `provideNullable` controls whether optional value-type properties use Nullable. +/// `useDateOnly` controls whether date/date-time/time formats can map to DateOnly/TimeOnly types. /// `asAsync` controls whether operation return types are Async<'T> or Task<'T>. -let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) = +let private compileV3SchemaCoreWithOptions (schemaStr: string) (provideNullable: bool) (useDateOnly: bool) (asAsync: bool) = let settings = OpenApiReaderSettings() settings.AddYamlReader() @@ -31,11 +32,14 @@ let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asA | null -> failwith "Failed to parse OpenAPI schema: Document is null." | doc -> doc - let defCompiler = DefinitionCompiler(schema, provideNullable, false) + let defCompiler = DefinitionCompiler(schema, provideNullable, useDateOnly) let opCompiler = OperationCompiler(schema, defCompiler, true, false, asAsync) opCompiler.CompileProvidedClients(defCompiler.Namespace) defCompiler.Namespace.GetProvidedTypes() +let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) = + compileV3SchemaCoreWithOptions schemaStr provideNullable false asAsync + /// Parse and compile a full OpenAPI v3 schema string, then return all provided types. /// Pass asAsync=true to generate Async<'T> operation return types, or false for Task<'T>. let compileV3Schema (schemaStr: string) (asAsync: bool) = @@ -90,3 +94,15 @@ let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required match testType.GetDeclaredProperty("Value") with | null -> failwith "Property 'Value' not found on TestType" | prop -> prop.PropertyType + +/// Compile a minimal v3 schema with configurable DefinitionCompiler options. +/// Returns the .NET type of the `Value` property on `TestType`. +let compilePropertyTypeWithUseDateOnly (provideNullable: bool) (useDateOnly: bool) (propYaml: string) (required: bool) : Type = + let types = + compileV3SchemaCoreWithOptions (buildPropertySchema propYaml required) provideNullable useDateOnly false + + let testType = types |> List.find(fun t -> t.Name = "TestType") + + match testType.GetDeclaredProperty("Value") with + | null -> failwith "Property 'Value' not found on TestType" + | prop -> prop.PropertyType diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index 8690aed..af55fb1 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -65,6 +65,13 @@ let ``required string time format falls back to string when useDateOnly is false let ty = compilePropertyType " type: string\n format: time\n" true ty |> shouldEqual typeof +[] +let ``required string time format maps to TimeOnly when useDateOnly is true``() = + let ty = + compilePropertyTypeWithUseDateOnly false true " type: string\n format: time\n" true + + ty |> shouldEqual typeof + [] let ``required string uuid format maps to Guid``() = let ty = compilePropertyType " type: string\n format: uuid\n" true From 18f76957507645cf4e393118b285f46478cb1ffc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:16:17 +0000 Subject: [PATCH 4/5] test: add DateOnly-enabled helper for time format mapping assertion Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/6dbf9572-532a-4c7c-9f10-38f0a68f83dd Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../Schema.TestHelpers.fs | 27 ++++++++----------- .../Schema.TypeMappingTests.fs | 2 +- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs index c25916d..7de5dd4 100644 --- a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs +++ b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs @@ -79,15 +79,9 @@ components: requiredBlock propYaml -/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`. -let compilePropertyType (propYaml: string) (required: bool) : Type = - compileSchemaAndGetValueType(buildPropertySchema propYaml required) - -/// Compile a minimal v3 schema with configurable DefinitionCompiler options. -/// Returns the .NET type of the `Value` property on `TestType`. -let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type = +let private compilePropertyTypeWithOptions (provideNullable: bool) (useDateOnly: bool) (propYaml: string) (required: bool) : Type = let types = - compileV3SchemaCore (buildPropertySchema propYaml required) provideNullable false + compileV3SchemaCoreWithOptions (buildPropertySchema propYaml required) provideNullable useDateOnly false let testType = types |> List.find(fun t -> t.Name = "TestType") @@ -95,14 +89,15 @@ let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required | null -> failwith "Property 'Value' not found on TestType" | prop -> prop.PropertyType +/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`. +let compilePropertyType (propYaml: string) (required: bool) : Type = + compilePropertyTypeWithOptions false false propYaml required + /// Compile a minimal v3 schema with configurable DefinitionCompiler options. /// Returns the .NET type of the `Value` property on `TestType`. -let compilePropertyTypeWithUseDateOnly (provideNullable: bool) (useDateOnly: bool) (propYaml: string) (required: bool) : Type = - let types = - compileV3SchemaCoreWithOptions (buildPropertySchema propYaml required) provideNullable useDateOnly false - - let testType = types |> List.find(fun t -> t.Name = "TestType") +let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type = + compilePropertyTypeWithOptions provideNullable false propYaml required - match testType.GetDeclaredProperty("Value") with - | null -> failwith "Property 'Value' not found on TestType" - | prop -> prop.PropertyType +/// Compile a minimal v3 schema where date/time formats map to DateOnly/TimeOnly types. +let compilePropertyTypeWithDateOnly (propYaml: string) (required: bool) : Type = + compilePropertyTypeWithOptions false true propYaml required diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index af55fb1..c979e19 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -68,7 +68,7 @@ let ``required string time format falls back to string when useDateOnly is false [] let ``required string time format maps to TimeOnly when useDateOnly is true``() = let ty = - compilePropertyTypeWithUseDateOnly false true " type: string\n format: time\n" true + compilePropertyTypeWithDateOnly " type: string\n format: time\n" true ty |> shouldEqual typeof From a522e31a608546c66a352c5eb32bb0cd6674f278 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Tue, 21 Apr 2026 12:13:33 +0200 Subject: [PATCH 5/5] Update tests/SwaggerProvider.Tests/Schema.TestHelpers.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/SwaggerProvider.Tests/Schema.TestHelpers.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs index 7de5dd4..7bcf327 100644 --- a/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs +++ b/tests/SwaggerProvider.Tests/Schema.TestHelpers.fs @@ -7,7 +7,7 @@ open SwaggerProvider.Internal.Compilers /// Core: parse, validate, and compile an OpenAPI v3 schema string. /// `provideNullable` controls whether optional value-type properties use Nullable. -/// `useDateOnly` controls whether date/date-time/time formats can map to DateOnly/TimeOnly types. +/// `useDateOnly` controls whether `date` and `time` formats map to DateOnly and TimeOnly types. /// `asAsync` controls whether operation return types are Async<'T> or Task<'T>. let private compileV3SchemaCoreWithOptions (schemaStr: string) (provideNullable: bool) (useDateOnly: bool) (asAsync: bool) = let settings = OpenApiReaderSettings()