From e8ccfec4b6625bf5aa1f4306548d8ceca1a37355 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:19:33 +0000 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20handle=20DateOnly=20in=20toParam?= =?UTF-8?q?=20and=20toQueryParams=20(+8=20tests,=20287=E2=86=92295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateOnly (generated for format:date fields on .NET 6+) was missing from RuntimeHelpers.toParam and toQueryParams. Without explicit handling, the code falls through to obj.ToString() which uses the current culture's short-date format (e.g. "4/19/2026" in en-US) instead of the ISO 8601 "yyyy-MM-dd" required by OpenAPI. Changes: - toParam: add | :? DateOnly as d -> d.ToString("O") branch - toQueryParams: add cases for DateOnly, DateOnly[], Option, Option[] (using new toStrArrayDateOnly / toStrDateOnlyOpt private helpers, mirroring the existing DateTime/DateTimeOffset pattern) - Add 8 unit tests covering all new code paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 17 +++++++ .../RuntimeHelpersTests.fs | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 5ef888e..978aac3 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -61,6 +61,13 @@ module RuntimeHelpers = let inline private toStrArrayDateTimeOffsetOpt name values = values |> Array.choose(id) |> toStrArrayDateTimeOffset name + let inline private toStrArrayDateOnly name (values: DateOnly array) = + values + |> Array.map(fun value -> name, value.ToString("O")) + |> Array.toList + + let inline private toStrArrayDateOnlyOpt name values = + values |> Array.choose(id) |> toStrArrayDateOnly name let inline private toStrOpt name value = match value with @@ -77,10 +84,16 @@ module RuntimeHelpers = | Some(x) -> [ name, x.ToString("O") ] | None -> [] + let inline private toStrDateOnlyOpt name (value: DateOnly option) = + match value with + | Some(x) -> [ name, x.ToString("O") ] + | None -> [] + let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") | :? DateTimeOffset as dto -> dto.ToString("O") + | :? DateOnly as d -> d.ToString("O") | null -> null | _ -> let ty = obj.GetType() @@ -118,6 +131,7 @@ module RuntimeHelpers = | :? array as xs -> xs |> toStrArray name | :? array as xs -> xs |> toStrArrayDateTime name | :? array as xs -> xs |> toStrArrayDateTimeOffset name + | :? array as xs -> xs |> toStrArrayDateOnly name | :? array as xs -> xs |> toStrArray name | :? array> as xs -> xs |> toStrArrayOpt name | :? array> as xs -> xs |> toStrArrayOpt name @@ -127,6 +141,7 @@ module RuntimeHelpers = | :? array> as xs -> xs |> toStrArrayOpt name | :? array> as xs -> xs |> toStrArrayDateTimeOpt name | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name + | :? array> as xs -> xs |> toStrArrayDateOnlyOpt name | :? array> as xs -> xs |> toStrArray name | :? Option as x -> x |> toStrOpt name | :? Option as x -> x |> toStrOpt name @@ -138,7 +153,9 @@ module RuntimeHelpers = | :? Option as x -> x |> toStrDateTimeOffsetOpt name | :? DateTime as x -> [ name, x.ToString("O") ] | :? DateTimeOffset as x -> [ name, x.ToString("O") ] + | :? DateOnly as x -> [ name, x.ToString("O") ] | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrDateOnlyOpt name | _ -> [ name, obj.ToString() ] /// Cache of sorted declared public instance properties per type, to avoid repeated diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 4bad731..1536448 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -76,6 +76,23 @@ module ToParamTests = let result = toParam(box(Some dto)) result |> shouldEqual(dto.ToString("O")) + [] + let ``toParam formats DateOnly as ISO 8601``() = + let d = DateOnly(2025, 7, 4) + let result = toParam(box d) + result |> shouldEqual "2025-07-04" + + [] + let ``toParam unwraps Some(DateOnly) and formats as ISO 8601``() = + let d = DateOnly(2025, 1, 31) + let result = toParam(box(Some d)) + result |> shouldEqual "2025-01-31" + + [] + let ``toParam returns null for None(DateOnly)``() = + let result = toParam(box(None: DateOnly option)) + result |> shouldEqual null + module ToQueryParamsTests = @@ -295,6 +312,38 @@ module ToQueryParamsTests = let result = toQueryParams "ts" (box values) stubClient result |> shouldEqual [ ("ts", dto.ToString("O")) ] + [] + let ``toQueryParams handles DateOnly as ISO 8601``() = + let d = DateOnly(2025, 7, 4) + let result = toQueryParams "date" (box d) stubClient + result |> shouldEqual [ ("date", "2025-07-04") ] + + [] + let ``toQueryParams handles DateOnly array``() = + let values: DateOnly[] = [| DateOnly(2025, 1, 1); DateOnly(2025, 12, 31) |] + let result = toQueryParams "dates" (box values) stubClient + + result + |> shouldEqual [ ("dates", "2025-01-01"); ("dates", "2025-12-31") ] + + [] + let ``toQueryParams handles Option Some``() = + let d = DateOnly(2025, 6, 15) + let result = toQueryParams "date" (box(Some d)) stubClient + result |> shouldEqual [ ("date", "2025-06-15") ] + + [] + let ``toQueryParams handles Option None``() = + let result = toQueryParams "date" (box(None: DateOnly option)) stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams skips None items in Option array``() = + let d = DateOnly(2025, 3, 1) + let values: Option[] = [| Some d; None |] + let result = toQueryParams "dates" (box values) stubClient + result |> shouldEqual [ ("dates", "2025-03-01") ] + module CombineUrlTests = From df44a594ae2e20df1c627f6b4506924538587a6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Apr 2026 13:19:36 +0000 Subject: [PATCH 02/10] ci: trigger checks From 7aeb28c0ead4c63d9fd4dd8e4fd703a847337047 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:09:54 +0000 Subject: [PATCH 03/10] fix: support DateOnly serialization without netstandard compile break Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/67dc7d66-ec2a-47b3-a474-3c0180e94db9 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 165 +++++++++++------- 1 file changed, 99 insertions(+), 66 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 978aac3..88e3d3c 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -61,13 +61,38 @@ module RuntimeHelpers = let inline private toStrArrayDateTimeOffsetOpt name values = values |> Array.choose(id) |> toStrArrayDateTimeOffset name - let inline private toStrArrayDateOnly name (values: DateOnly array) = - values - |> Array.map(fun value -> name, value.ToString("O")) - |> Array.toList + let private dateOnlyTypeName = "System.DateOnly" + + let private isDateOnlyType(t: Type) = + not(isNull t) && t.FullName = dateOnlyTypeName + + let private isOptionOfDateOnlyType(t: Type) = + t.IsGenericType + && t.GetGenericTypeDefinition() = typedefof> + && isDateOnlyType(t.GetGenericArguments().[0]) + + let private tryFormatDateOnly(value: obj) = + if isNull value then + None + else + let ty = value.GetType() + + if isDateOnlyType ty then + let toStringWithFormat = ty.GetMethod("ToString", [| typeof |]) + + if isNull toStringWithFormat then + Some(value.ToString()) + else + Some(toStringWithFormat.Invoke(value, [| box "O" |]) :?> string) + else + None - let inline private toStrArrayDateOnlyOpt name values = - values |> Array.choose(id) |> toStrArrayDateOnly name + let inline private toStrArrayDateOnly name (values: Array) = + values + |> Seq.cast + |> Seq.choose(tryFormatDateOnly) + |> Seq.map(fun value -> name, value) + |> Seq.toList let inline private toStrOpt name value = match value with @@ -84,79 +109,87 @@ module RuntimeHelpers = | Some(x) -> [ name, x.ToString("O") ] | None -> [] - let inline private toStrDateOnlyOpt name (value: DateOnly option) = - match value with - | Some(x) -> [ name, x.ToString("O") ] - | None -> [] - let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") | :? DateTimeOffset as dto -> dto.ToString("O") - | :? DateOnly as d -> d.ToString("O") | null -> null | _ -> - 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 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] + else + null else - null - else - obj.ToString() + obj.ToString() let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = if isNull obj then [] else - match obj with - | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string - | :? Option> as x -> - match x with - | Some xs -> [ name, (client.Serialize xs).Trim('\"') ] - | None -> [] - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArrayDateTime name - | :? array as xs -> xs |> toStrArrayDateTimeOffset name - | :? array as xs -> xs |> toStrArrayDateOnly name - | :? array as xs -> xs |> toStrArray name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name - | :? array> as xs -> xs |> toStrArrayDateOnlyOpt name - | :? array> as xs -> xs |> toStrArray name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrDateTimeOpt name - | :? Option as x -> x |> toStrDateTimeOffsetOpt name - | :? DateTime as x -> [ name, x.ToString("O") ] - | :? DateTimeOffset as x -> [ name, x.ToString("O") ] - | :? DateOnly as x -> [ name, x.ToString("O") ] - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrDateOnlyOpt name - | _ -> [ name, obj.ToString() ] + match tryFormatDateOnly obj with + | Some formatted -> [ name, formatted ] + | None -> + match obj with + | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string + | :? Option> as x -> + match x with + | Some xs -> [ name, (client.Serialize xs).Trim('\"') ] + | None -> [] + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArrayDateTime name + | :? array as xs -> xs |> toStrArrayDateTimeOffset name + | :? array as xs -> xs |> toStrArray name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name + | :? array> as xs -> xs |> toStrArray name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrDateTimeOpt name + | :? Option as x -> x |> toStrDateTimeOffsetOpt name + | :? DateTime as x -> [ name, x.ToString("O") ] + | :? DateTimeOffset as x -> [ name, x.ToString("O") ] + | :? Option as x -> x |> toStrOpt name + | :? Array as xs when isDateOnlyType(xs.GetType().GetElementType()) -> xs |> toStrArrayDateOnly name + | :? Array as xs when isOptionOfDateOnlyType(xs.GetType().GetElementType()) -> + xs + |> Seq.cast + |> Seq.choose(fun value -> + let param = toParam value + + if isNull param then None else Some(name, param)) + |> Seq.toList + | _ when isOptionOfDateOnlyType(obj.GetType()) -> + let param = toParam obj + if isNull param then [] else [ name, param ] + | _ -> [ name, obj.ToString() ] /// Cache of sorted declared public instance properties per type, to avoid repeated /// reflection and sorting overhead when formatObject is called frequently. From 50bcb6dae7bc5ec0a09895a01200e6ef7614706d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:11:40 +0000 Subject: [PATCH 04/10] chore: address review nits in DateOnly helper Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/67dc7d66-ec2a-47b3-a474-3c0180e94db9 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 88e3d3c..846f652 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -87,7 +87,7 @@ module RuntimeHelpers = else None - let inline private toStrArrayDateOnly name (values: Array) = + let private toStrArrayDateOnly name (values: Array) = values |> Seq.cast |> Seq.choose(tryFormatDateOnly) From 9c37ed7333e3d6aa2e10d80b658e5e77e3fb9b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:13:46 +0000 Subject: [PATCH 05/10] perf: cache DateOnly reflection and harden array element checks Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/67dc7d66-ec2a-47b3-a474-3c0180e94db9 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 846f652..e0ef6b1 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -71,6 +71,12 @@ module RuntimeHelpers = && t.GetGenericTypeDefinition() = typedefof> && isDateOnlyType(t.GetGenericArguments().[0]) + let private dateOnlyToStringWithFormatCache = + Collections.Concurrent.ConcurrentDictionary() + + let private tryGetArrayElementType(arr: Array) = + arr.GetType().GetElementType() |> Option.ofObj + let private tryFormatDateOnly(value: obj) = if isNull value then None @@ -78,20 +84,21 @@ module RuntimeHelpers = let ty = value.GetType() if isDateOnlyType ty then - let toStringWithFormat = ty.GetMethod("ToString", [| typeof |]) + let toStringWithFormat = + dateOnlyToStringWithFormatCache.GetOrAdd(ty, fun t -> t.GetMethod("ToString", [| typeof |]) |> Option.ofObj) - if isNull toStringWithFormat then - Some(value.ToString()) - else - Some(toStringWithFormat.Invoke(value, [| box "O" |]) :?> string) + match toStringWithFormat with + | Some methodInfo -> Some(methodInfo.Invoke(value, [| box "O" |]) :?> string) + | None -> Some(value.ToString()) else None let private toStrArrayDateOnly name (values: Array) = values |> Seq.cast - |> Seq.choose(tryFormatDateOnly) - |> Seq.map(fun value -> name, value) + |> Seq.choose(fun value -> + tryFormatDateOnly value + |> Option.map(fun formatted -> name, formatted)) |> Seq.toList let inline private toStrOpt name value = @@ -177,8 +184,8 @@ module RuntimeHelpers = | :? DateTime as x -> [ name, x.ToString("O") ] | :? DateTimeOffset as x -> [ name, x.ToString("O") ] | :? Option as x -> x |> toStrOpt name - | :? Array as xs when isDateOnlyType(xs.GetType().GetElementType()) -> xs |> toStrArrayDateOnly name - | :? Array as xs when isOptionOfDateOnlyType(xs.GetType().GetElementType()) -> + | :? Array as xs when xs |> tryGetArrayElementType |> Option.exists isDateOnlyType -> xs |> toStrArrayDateOnly name + | :? Array as xs when xs |> tryGetArrayElementType |> Option.exists isOptionOfDateOnlyType -> xs |> Seq.cast |> Seq.choose(fun value -> From e6ff18a13e946420673bc83430552d235b753656 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:15:38 +0000 Subject: [PATCH 06/10] refactor: simplify DateOnly reflection helpers Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/67dc7d66-ec2a-47b3-a474-3c0180e94db9 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index e0ef6b1..541157c 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -71,10 +71,12 @@ module RuntimeHelpers = && t.GetGenericTypeDefinition() = typedefof> && isDateOnlyType(t.GetGenericArguments().[0]) - let private dateOnlyToStringWithFormatCache = - Collections.Concurrent.ConcurrentDictionary() + let private dateOnlyToStringWithFormat = + Type.GetType(dateOnlyTypeName, false) + |> Option.ofObj + |> Option.bind(fun ty -> ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj) - let private tryGetArrayElementType(arr: Array) = + let private getArrayElementType(arr: Array) = arr.GetType().GetElementType() |> Option.ofObj let private tryFormatDateOnly(value: obj) = @@ -85,7 +87,9 @@ module RuntimeHelpers = if isDateOnlyType ty then let toStringWithFormat = - dateOnlyToStringWithFormatCache.GetOrAdd(ty, fun t -> t.GetMethod("ToString", [| typeof |]) |> Option.ofObj) + match dateOnlyToStringWithFormat with + | Some methodInfo when methodInfo.DeclaringType = ty -> Some methodInfo + | _ -> ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj match toStringWithFormat with | Some methodInfo -> Some(methodInfo.Invoke(value, [| box "O" |]) :?> string) @@ -184,8 +188,8 @@ module RuntimeHelpers = | :? DateTime as x -> [ name, x.ToString("O") ] | :? DateTimeOffset as x -> [ name, x.ToString("O") ] | :? Option as x -> x |> toStrOpt name - | :? Array as xs when xs |> tryGetArrayElementType |> Option.exists isDateOnlyType -> xs |> toStrArrayDateOnly name - | :? Array as xs when xs |> tryGetArrayElementType |> Option.exists isOptionOfDateOnlyType -> + | :? Array as xs when xs |> getArrayElementType |> Option.exists isDateOnlyType -> xs |> toStrArrayDateOnly name + | :? Array as xs when xs |> getArrayElementType |> Option.exists isOptionOfDateOnlyType -> xs |> Seq.cast |> Seq.choose(fun value -> From 61c3020b07d8d71939c7aded3736fb94824f4465 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 19 Apr 2026 17:59:59 +0200 Subject: [PATCH 07/10] refactor: simplify DateOnly query param handling --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 125 ++++++------------ 1 file changed, 38 insertions(+), 87 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 541157c..e24f931 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -71,13 +71,8 @@ module RuntimeHelpers = && t.GetGenericTypeDefinition() = typedefof> && isDateOnlyType(t.GetGenericArguments().[0]) - let private dateOnlyToStringWithFormat = - Type.GetType(dateOnlyTypeName, false) - |> Option.ofObj - |> Option.bind(fun ty -> ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj) - - let private getArrayElementType(arr: Array) = - arr.GetType().GetElementType() |> Option.ofObj + let private isDateOnlyLikeType(t: Type) = + isDateOnlyType t || isOptionOfDateOnlyType t let private tryFormatDateOnly(value: obj) = if isNull value then @@ -86,40 +81,12 @@ module RuntimeHelpers = let ty = value.GetType() if isDateOnlyType ty then - let toStringWithFormat = - match dateOnlyToStringWithFormat with - | Some methodInfo when methodInfo.DeclaringType = ty -> Some methodInfo - | _ -> ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj - - match toStringWithFormat with + match ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj with | Some methodInfo -> Some(methodInfo.Invoke(value, [| box "O" |]) :?> string) | None -> Some(value.ToString()) else None - let private toStrArrayDateOnly name (values: Array) = - values - |> Seq.cast - |> Seq.choose(fun value -> - tryFormatDateOnly value - |> Option.map(fun formatted -> name, formatted)) - |> Seq.toList - - let inline private toStrOpt name value = - match value with - | Some(x) -> [ name, x.ToString() ] - | None -> [] - - let inline private toStrDateTimeOpt name (value: DateTime option) = - match value with - | Some(x) -> [ name, x.ToString("O") ] - | None -> [] - - let inline private toStrDateTimeOffsetOpt name (value: DateTimeOffset option) = - match value with - | Some(x) -> [ name, x.ToString("O") ] - | None -> [] - let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") @@ -150,57 +117,41 @@ module RuntimeHelpers = [] else - match tryFormatDateOnly obj with - | Some formatted -> [ name, formatted ] - | None -> - match obj with - | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string - | :? Option> as x -> - match x with - | Some xs -> [ name, (client.Serialize xs).Trim('\"') ] - | None -> [] - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArrayDateTime name - | :? array as xs -> xs |> toStrArrayDateTimeOffset name - | :? array as xs -> xs |> toStrArray name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name - | :? array> as xs -> xs |> toStrArray name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrDateTimeOpt name - | :? Option as x -> x |> toStrDateTimeOffsetOpt name - | :? DateTime as x -> [ name, x.ToString("O") ] - | :? DateTimeOffset as x -> [ name, x.ToString("O") ] - | :? Option as x -> x |> toStrOpt name - | :? Array as xs when xs |> getArrayElementType |> Option.exists isDateOnlyType -> xs |> toStrArrayDateOnly name - | :? Array as xs when xs |> getArrayElementType |> Option.exists isOptionOfDateOnlyType -> - xs - |> Seq.cast - |> Seq.choose(fun value -> - let param = toParam value - - if isNull param then None else Some(name, param)) - |> Seq.toList - | _ when isOptionOfDateOnlyType(obj.GetType()) -> - let param = toParam obj - if isNull param then [] else [ name, param ] - | _ -> [ name, obj.ToString() ] + match obj with + | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string + | :? Option> as x -> + match x with + | Some xs -> [ name, (client.Serialize xs).Trim('\"') ] + | None -> [] + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArrayDateTime name + | :? array as xs -> xs |> toStrArrayDateTimeOffset name + | :? array as xs -> xs |> toStrArray name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name + | :? array> as xs -> xs |> toStrArray name + | :? Array as xs when xs.GetType().GetElementType() |> Option.ofObj |> Option.exists isDateOnlyLikeType -> + xs + |> Seq.cast + |> Seq.choose(fun value -> + let param = toParam value + + if isNull param then None else Some(name, param)) + |> Seq.toList + | _ -> + let param = toParam obj + if isNull param then [] else [ name, param ] /// Cache of sorted declared public instance properties per type, to avoid repeated /// reflection and sorting overhead when formatObject is called frequently. From 851a96213d8057fa1a0a1485e99d148b9ef0600a Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 19 Apr 2026 18:51:15 +0200 Subject: [PATCH 08/10] fix: unwrap Option query param arrays --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 2 +- tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index e24f931..72d7d36 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -140,7 +140,7 @@ module RuntimeHelpers = | :? array> as xs -> xs |> toStrArrayOpt name | :? array> as xs -> xs |> toStrArrayDateTimeOpt name | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name - | :? array> as xs -> xs |> toStrArray name + | :? array> as xs -> xs |> toStrArrayOpt name | :? Array as xs when xs.GetType().GetElementType() |> Option.ofObj |> Option.exists isDateOnlyLikeType -> xs |> Seq.cast diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 1536448..e2d0b10 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -218,6 +218,13 @@ module ToQueryParamsTests = let result = toQueryParams "id" (box [| g1; g2 |]) stubClient result |> shouldEqual [ ("id", g1.ToString()); ("id", g2.ToString()) ] + [] + let ``toQueryParams skips None items in Option array``() = + let g = Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + let values: Option[] = [| Some g; None |] + let result = toQueryParams "id" (box values) stubClient + result |> shouldEqual [ ("id", g.ToString()) ] + [] let ``toQueryParams handles float32 array``() = let result = toQueryParams "v" (box [| 1.5f; 2.5f |]) stubClient From e30c714b724c3158ca6846019afd3819fb7adda1 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 19 Apr 2026 18:56:26 +0200 Subject: [PATCH 09/10] style: format RuntimeHelpers --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 72d7d36..6bcdd54 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -141,7 +141,11 @@ module RuntimeHelpers = | :? array> as xs -> xs |> toStrArrayDateTimeOpt name | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name | :? array> as xs -> xs |> toStrArrayOpt name - | :? Array as xs when xs.GetType().GetElementType() |> Option.ofObj |> Option.exists isDateOnlyLikeType -> + | :? Array as xs when + xs.GetType().GetElementType() + |> Option.ofObj + |> Option.exists isDateOnlyLikeType + -> xs |> Seq.cast |> Seq.choose(fun value -> From df5167c8137b1fb835aac935b73c76b123ff83a7 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sun, 19 Apr 2026 19:33:57 +0200 Subject: [PATCH 10/10] fix: use invariant DateOnly formatting --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 6bcdd54..9f2390c 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -81,9 +81,16 @@ module RuntimeHelpers = let ty = value.GetType() if isDateOnlyType ty then - match ty.GetMethod("ToString", [| typeof |]) |> Option.ofObj with - | Some methodInfo -> Some(methodInfo.Invoke(value, [| box "O" |]) :?> string) - | None -> Some(value.ToString()) + match value with + | :? IFormattable as formattable -> Some(formattable.ToString("yyyy-MM-dd", 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) + | None -> None else None