Skip to content

Commit a80df2f

Browse files
github-actions[bot]CopilotCopilotsergey-tihonCopilot
authored
[Repo Assist] eng: add TimeOnly support for format:time schemas (+11 tests, 295→306) (#394)
* eng: add TimeOnly support for format:time schemas (+11 tests, 295→306) 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<TimeOnly> 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> * ci: trigger checks * 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> * 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> * Update tests/SwaggerProvider.Tests/Schema.TestHelpers.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: cover TimeOnly formatting in form and multipart helpers Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/a92e7efa-e4e0-43e5-a5d3-7183e7cf81f4 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Co-authored-by: Sergey Tihon <sergey.tihon@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 072285f commit a80df2f

5 files changed

Lines changed: 160 additions & 30 deletions

File tree

src/SwaggerProvider.DesignTime/DefinitionCompiler.fs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,15 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
563563
else
564564
typeof<DateTimeOffset>
565565
| HasFlag JsonSchemaType.String, "date-time" -> typeof<DateTimeOffset>
566+
| HasFlag JsonSchemaType.String, "time" ->
567+
// Use TimeOnly only when the target runtime supports it (.NET 6+).
568+
// useDateOnly is true for net6+ targets — TimeOnly was added in the same release.
569+
if useDateOnly then
570+
System.Type.GetType("System.TimeOnly")
571+
|> Option.ofObj
572+
|> Option.defaultValue typeof<string>
573+
else
574+
typeof<string>
566575
| HasFlag JsonSchemaType.String, "uuid" -> typeof<Guid>
567576
| HasFlag JsonSchemaType.String, _ -> typeof<string>
568577
| HasFlag JsonSchemaType.Array, _ ->

src/SwaggerProvider.Runtime/RuntimeHelpers.fs

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,38 +62,55 @@ module RuntimeHelpers =
6262
values |> Array.choose(id) |> toStrArrayDateTimeOffset name
6363

6464
let private dateOnlyTypeName = "System.DateOnly"
65+
let private timeOnlyTypeName = "System.TimeOnly"
6566

6667
let private isDateOnlyType(t: Type) =
6768
not(isNull t) && t.FullName = dateOnlyTypeName
6869

70+
let private isTimeOnlyType(t: Type) =
71+
not(isNull t) && t.FullName = timeOnlyTypeName
72+
6973
let private isOptionOfDateOnlyType(t: Type) =
7074
t.IsGenericType
7175
&& t.GetGenericTypeDefinition() = typedefof<option<_>>
7276
&& isDateOnlyType(t.GetGenericArguments().[0])
7377

78+
let private isOptionOfTimeOnlyType(t: Type) =
79+
t.IsGenericType
80+
&& t.GetGenericTypeDefinition() = typedefof<option<_>>
81+
&& isTimeOnlyType(t.GetGenericArguments().[0])
82+
7483
let private isDateOnlyLikeType(t: Type) =
7584
isDateOnlyType t || isOptionOfDateOnlyType t
7685

77-
let private tryFormatDateOnly(value: obj) =
86+
let private isTimeOnlyLikeType(t: Type) =
87+
isTimeOnlyType t || isOptionOfTimeOnlyType t
88+
89+
let private tryFormatViaMethods (typeName: string) (format: string) (value: obj) =
7890
if isNull value then
7991
None
8092
else
8193
let ty = value.GetType()
8294

83-
if isDateOnlyType ty then
95+
if ty.FullName = typeName then
8496
match value with
85-
| :? IFormattable as formattable -> Some(formattable.ToString("yyyy-MM-dd", Globalization.CultureInfo.InvariantCulture))
97+
| :? IFormattable as formattable -> Some(formattable.ToString(format, Globalization.CultureInfo.InvariantCulture))
8698
| _ ->
8799
match
88100
ty.GetMethod("ToString", [| typeof<string>; typeof<IFormatProvider> |])
89101
|> Option.ofObj
90102
with
91-
| Some methodInfo ->
92-
Some(methodInfo.Invoke(value, [| box "yyyy-MM-dd"; box Globalization.CultureInfo.InvariantCulture |]) :?> string)
103+
| Some methodInfo -> Some(methodInfo.Invoke(value, [| box format; box Globalization.CultureInfo.InvariantCulture |]) :?> string)
93104
| None -> None
94105
else
95106
None
96107

108+
let private tryFormatDateOnly(value: obj) =
109+
tryFormatViaMethods dateOnlyTypeName "yyyy-MM-dd" value
110+
111+
let private tryFormatTimeOnly(value: obj) =
112+
tryFormatViaMethods timeOnlyTypeName "HH:mm:ss.FFFFFFF" value
113+
97114
let rec toParam(obj: obj) =
98115
match obj with
99116
| :? DateTime as dt -> dt.ToString("O")
@@ -103,21 +120,24 @@ module RuntimeHelpers =
103120
match tryFormatDateOnly obj with
104121
| Some formatted -> formatted
105122
| None ->
106-
let ty = obj.GetType()
107-
108-
// Unwrap F# Option<T>: Some(x) -> toParam(x), None -> null
109-
if
110-
ty.IsGenericType
111-
&& ty.GetGenericTypeDefinition() = typedefof<option<_>>
112-
then
113-
let (case, values) = Microsoft.FSharp.Reflection.FSharpValue.GetUnionFields(obj, ty)
114-
115-
if case.Name = "Some" && values.Length > 0 then
116-
toParam values.[0]
123+
match tryFormatTimeOnly obj with
124+
| Some formatted -> formatted
125+
| None ->
126+
let ty = obj.GetType()
127+
128+
// Unwrap F# Option<T>: Some(x) -> toParam(x), None -> null
129+
if
130+
ty.IsGenericType
131+
&& ty.GetGenericTypeDefinition() = typedefof<option<_>>
132+
then
133+
let (case, values) = Microsoft.FSharp.Reflection.FSharpValue.GetUnionFields(obj, ty)
134+
135+
if case.Name = "Some" && values.Length > 0 then
136+
toParam values.[0]
137+
else
138+
null
117139
else
118-
null
119-
else
120-
obj.ToString()
140+
obj.ToString()
121141

122142
let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) =
123143
if isNull obj then
@@ -151,7 +171,7 @@ module RuntimeHelpers =
151171
| :? Array as xs when
152172
xs.GetType().GetElementType()
153173
|> Option.ofObj
154-
|> Option.exists isDateOnlyLikeType
174+
|> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t)
155175
->
156176
xs
157177
|> Seq.cast<obj>

tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,30 @@ module ToParamTests =
9393
let result = toParam(box(None: DateOnly option))
9494
result |> shouldEqual null
9595

96+
[<Fact>]
97+
let ``toParam formats TimeOnly as HH:mm:ss.FFFFFFF``() =
98+
let t = TimeOnly(14, 30, 0)
99+
let result = toParam(box t)
100+
result |> shouldEqual "14:30:00"
101+
102+
[<Fact>]
103+
let ``toParam formats TimeOnly with sub-second precision``() =
104+
let t = TimeOnly(9, 5, 3, 123)
105+
let result = toParam(box t)
106+
// "HH:mm:ss.FFFFFFF" — trailing zeros stripped by F specifier
107+
result |> shouldEqual "09:05:03.123"
108+
109+
[<Fact>]
110+
let ``toParam unwraps Some(TimeOnly) and formats correctly``() =
111+
let t = TimeOnly(8, 0, 0)
112+
let result = toParam(box(Some t))
113+
result |> shouldEqual "08:00:00"
114+
115+
[<Fact>]
116+
let ``toParam returns null for None(TimeOnly)``() =
117+
let result = toParam(box(None: TimeOnly option))
118+
result |> shouldEqual null
119+
96120

97121
module ToQueryParamsTests =
98122

@@ -351,6 +375,36 @@ module ToQueryParamsTests =
351375
let result = toQueryParams "dates" (box values) stubClient
352376
result |> shouldEqual [ ("dates", "2025-03-01") ]
353377

378+
[<Fact>]
379+
let ``toQueryParams handles TimeOnly``() =
380+
let t = TimeOnly(14, 30, 0)
381+
let result = toQueryParams "time" (box t) stubClient
382+
result |> shouldEqual [ ("time", "14:30:00") ]
383+
384+
[<Fact>]
385+
let ``toQueryParams handles TimeOnly array``() =
386+
let values: TimeOnly[] = [| TimeOnly(9, 0, 0); TimeOnly(17, 30, 0) |]
387+
let result = toQueryParams "times" (box values) stubClient
388+
result |> shouldEqual [ ("times", "09:00:00"); ("times", "17:30:00") ]
389+
390+
[<Fact>]
391+
let ``toQueryParams handles Option<TimeOnly> Some``() =
392+
let t = TimeOnly(12, 0, 0)
393+
let result = toQueryParams "time" (box(Some t)) stubClient
394+
result |> shouldEqual [ ("time", "12:00:00") ]
395+
396+
[<Fact>]
397+
let ``toQueryParams handles Option<TimeOnly> None``() =
398+
let result = toQueryParams "time" (box(None: TimeOnly option)) stubClient
399+
result |> shouldEqual []
400+
401+
[<Fact>]
402+
let ``toQueryParams skips None items in Option<TimeOnly> array``() =
403+
let t = TimeOnly(8, 0, 0)
404+
let values: Option<TimeOnly>[] = [| Some t; None |]
405+
let result = toQueryParams "times" (box values) stubClient
406+
result |> shouldEqual [ ("times", "08:00:00") ]
407+
354408

355409
module CombineUrlTests =
356410

@@ -779,6 +833,19 @@ module ToFormUrlEncodedContentTests =
779833
decodedValue |> shouldEqual "2025-07-04"
780834
}
781835

836+
[<Fact>]
837+
let ``toFormUrlEncodedContent formats TimeOnly as HH:mm:ss.FFFFFFF``() =
838+
task {
839+
let t = TimeOnly(9, 5, 3, 123)
840+
use content = toFormUrlEncodedContent(seq { ("time", box t) })
841+
842+
let! body = content.ReadAsStringAsync()
843+
let encodedValue = body.Substring("time=".Length)
844+
let decodedValue = WebUtility.UrlDecode(encodedValue)
845+
846+
decodedValue |> shouldEqual "09:05:03.123"
847+
}
848+
782849
[<Fact>]
783850
let ``toFormUrlEncodedContent skips values when toParam returns null``() =
784851
task {
@@ -867,6 +934,16 @@ module ToMultipartFormDataContentTests =
867934
body |> shouldEqual "2025-07-04"
868935
}
869936

937+
[<Fact>]
938+
let ``toMultipartFormDataContent formats TimeOnly as HH:mm:ss.FFFFFFF``() =
939+
task {
940+
let t = TimeOnly(9, 5, 3, 123)
941+
use content = toMultipartFormDataContent(seq { ("time", box t) })
942+
let part = content |> Seq.exactlyOne
943+
let! body = part.ReadAsStringAsync()
944+
body |> shouldEqual "09:05:03.123"
945+
}
946+
870947
[<Fact>]
871948
let ``toMultipartFormDataContent skips values when toParam returns null``() =
872949
task {

tests/SwaggerProvider.Tests/Schema.TestHelpers.fs

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ open SwaggerProvider.Internal.Compilers
77

88
/// Core: parse, validate, and compile an OpenAPI v3 schema string.
99
/// `provideNullable` controls whether optional value-type properties use Nullable<T>.
10+
/// `useDateOnly` controls whether `date` and `time` formats map to DateOnly and TimeOnly types.
1011
/// `asAsync` controls whether operation return types are Async<'T> or Task<'T>.
11-
let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) =
12+
let private compileV3SchemaCoreWithOptions (schemaStr: string) (provideNullable: bool) (useDateOnly: bool) (asAsync: bool) =
1213
let settings = OpenApiReaderSettings()
1314
settings.AddYamlReader()
1415

@@ -31,11 +32,14 @@ let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asA
3132
| null -> failwith "Failed to parse OpenAPI schema: Document is null."
3233
| doc -> doc
3334

34-
let defCompiler = DefinitionCompiler(schema, provideNullable, false)
35+
let defCompiler = DefinitionCompiler(schema, provideNullable, useDateOnly)
3536
let opCompiler = OperationCompiler(schema, defCompiler, true, false, asAsync)
3637
opCompiler.CompileProvidedClients(defCompiler.Namespace)
3738
defCompiler.Namespace.GetProvidedTypes()
3839

40+
let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) =
41+
compileV3SchemaCoreWithOptions schemaStr provideNullable false asAsync
42+
3943
/// Parse and compile a full OpenAPI v3 schema string, then return all provided types.
4044
/// Pass asAsync=true to generate Async<'T> operation return types, or false for Task<'T>.
4145
let compileV3Schema (schemaStr: string) (asAsync: bool) =
@@ -75,18 +79,25 @@ components:
7579
requiredBlock
7680
propYaml
7781

78-
/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`.
79-
let compilePropertyType (propYaml: string) (required: bool) : Type =
80-
compileSchemaAndGetValueType(buildPropertySchema propYaml required)
81-
82-
/// Compile a minimal v3 schema with configurable DefinitionCompiler options.
83-
/// Returns the .NET type of the `Value` property on `TestType`.
84-
let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type =
82+
let private compilePropertyTypeWithOptions (provideNullable: bool) (useDateOnly: bool) (propYaml: string) (required: bool) : Type =
8583
let types =
86-
compileV3SchemaCore (buildPropertySchema propYaml required) provideNullable false
84+
compileV3SchemaCoreWithOptions (buildPropertySchema propYaml required) provideNullable useDateOnly false
8785

8886
let testType = types |> List.find(fun t -> t.Name = "TestType")
8987

9088
match testType.GetDeclaredProperty("Value") with
9189
| null -> failwith "Property 'Value' not found on TestType"
9290
| prop -> prop.PropertyType
91+
92+
/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`.
93+
let compilePropertyType (propYaml: string) (required: bool) : Type =
94+
compilePropertyTypeWithOptions false false propYaml required
95+
96+
/// Compile a minimal v3 schema with configurable DefinitionCompiler options.
97+
/// Returns the .NET type of the `Value` property on `TestType`.
98+
let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type =
99+
compilePropertyTypeWithOptions provideNullable false propYaml required
100+
101+
/// Compile a minimal v3 schema where date/time formats map to DateOnly/TimeOnly types.
102+
let compilePropertyTypeWithDateOnly (propYaml: string) (required: bool) : Type =
103+
compilePropertyTypeWithOptions false true propYaml required

tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ let ``required string date format maps to DateTimeOffset``() =
5959

6060
ty |> shouldEqual typeof<DateTimeOffset>
6161

62+
[<Fact>]
63+
let ``required string time format falls back to string when useDateOnly is false``() =
64+
// The test helper compiles with useDateOnly=false, so TimeOnly is not used
65+
let ty = compilePropertyType " type: string\n format: time\n" true
66+
ty |> shouldEqual typeof<string>
67+
68+
[<Fact>]
69+
let ``required string time format maps to TimeOnly when useDateOnly is true``() =
70+
let ty =
71+
compilePropertyTypeWithDateOnly " type: string\n format: time\n" true
72+
73+
ty |> shouldEqual typeof<TimeOnly>
74+
6275
[<Fact>]
6376
let ``required string uuid format maps to Guid``() =
6477
let ty = compilePropertyType " type: string\n format: uuid\n" true

0 commit comments

Comments
 (0)