Skip to content
9 changes: 9 additions & 0 deletions src/SwaggerProvider.DesignTime/DefinitionCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,15 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
else
typeof<DateTimeOffset>
| HasFlag JsonSchemaType.String, "date-time" -> typeof<DateTimeOffset>
| 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<string>
else
typeof<string>
| HasFlag JsonSchemaType.String, "uuid" -> typeof<Guid>
| HasFlag JsonSchemaType.String, _ -> typeof<string>
| HasFlag JsonSchemaType.Array, _ ->
Expand Down
60 changes: 40 additions & 20 deletions src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<option<_>>
&& isDateOnlyType(t.GetGenericArguments().[0])

let private isOptionOfTimeOnlyType(t: Type) =
t.IsGenericType
&& t.GetGenericTypeDefinition() = typedefof<option<_>>
&& 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<string>; typeof<IFormatProvider> |])
|> 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")
Expand All @@ -103,21 +120,24 @@ module RuntimeHelpers =
match tryFormatDateOnly obj with
| Some formatted -> formatted
| None ->
let ty = obj.GetType()

// Unwrap F# Option<T>: Some(x) -> toParam(x), None -> null
if
ty.IsGenericType
&& ty.GetGenericTypeDefinition() = typedefof<option<_>>
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<T>: Some(x) -> toParam(x), None -> null
if
ty.IsGenericType
&& ty.GetGenericTypeDefinition() = typedefof<option<_>>
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()
Comment thread
sergey-tihon marked this conversation as resolved.

let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) =
if isNull obj then
Expand Down Expand Up @@ -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<obj>
Expand Down
77 changes: 77 additions & 0 deletions tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ module ToParamTests =
let result = toParam(box(None: DateOnly option))
result |> shouldEqual null

[<Fact>]
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"

[<Fact>]
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"

[<Fact>]
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"

[<Fact>]
let ``toParam returns null for None(TimeOnly)``() =
let result = toParam(box(None: TimeOnly option))
result |> shouldEqual null


module ToQueryParamsTests =

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

[<Fact>]
let ``toQueryParams handles TimeOnly``() =
let t = TimeOnly(14, 30, 0)
let result = toQueryParams "time" (box t) stubClient
result |> shouldEqual [ ("time", "14:30:00") ]

[<Fact>]
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") ]

[<Fact>]
let ``toQueryParams handles Option<TimeOnly> Some``() =
let t = TimeOnly(12, 0, 0)
let result = toQueryParams "time" (box(Some t)) stubClient
result |> shouldEqual [ ("time", "12:00:00") ]

[<Fact>]
let ``toQueryParams handles Option<TimeOnly> None``() =
let result = toQueryParams "time" (box(None: TimeOnly option)) stubClient
result |> shouldEqual []

[<Fact>]
let ``toQueryParams skips None items in Option<TimeOnly> array``() =
let t = TimeOnly(8, 0, 0)
let values: Option<TimeOnly>[] = [| Some t; None |]
let result = toQueryParams "times" (box values) stubClient
result |> shouldEqual [ ("times", "08:00:00") ]


module CombineUrlTests =

Expand Down Expand Up @@ -779,6 +833,19 @@ module ToFormUrlEncodedContentTests =
decodedValue |> shouldEqual "2025-07-04"
}

[<Fact>]
let ``toFormUrlEncodedContent formats TimeOnly as HH:mm:ss.FFFFFFF``() =
task {
let t = TimeOnly(9, 5, 3, 123)
use content = toFormUrlEncodedContent(seq { ("time", box t) })

let! body = content.ReadAsStringAsync()
let encodedValue = body.Substring("time=".Length)
let decodedValue = WebUtility.UrlDecode(encodedValue)

decodedValue |> shouldEqual "09:05:03.123"
}

[<Fact>]
let ``toFormUrlEncodedContent skips values when toParam returns null``() =
task {
Expand Down Expand Up @@ -867,6 +934,16 @@ module ToMultipartFormDataContentTests =
body |> shouldEqual "2025-07-04"
}

[<Fact>]
let ``toMultipartFormDataContent formats TimeOnly as HH:mm:ss.FFFFFFF``() =
task {
let t = TimeOnly(9, 5, 3, 123)
use content = toMultipartFormDataContent(seq { ("time", box t) })
let part = content |> Seq.exactlyOne
let! body = part.ReadAsStringAsync()
body |> shouldEqual "09:05:03.123"
}

[<Fact>]
let ``toMultipartFormDataContent skips values when toParam returns null``() =
task {
Expand Down
31 changes: 21 additions & 10 deletions tests/SwaggerProvider.Tests/Schema.TestHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.
/// `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 compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) =
let private compileV3SchemaCoreWithOptions (schemaStr: string) (provideNullable: bool) (useDateOnly: bool) (asAsync: bool) =
let settings = OpenApiReaderSettings()
settings.AddYamlReader()

Expand All @@ -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) =
Expand Down Expand Up @@ -75,18 +79,25 @@ 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")

match testType.GetDeclaredProperty("Value") with
| 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 compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type =
compilePropertyTypeWithOptions provideNullable false propYaml required

/// 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
13 changes: 13 additions & 0 deletions tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ let ``required string date format maps to DateTimeOffset``() =

ty |> shouldEqual typeof<DateTimeOffset>

[<Fact>]
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<string>
Comment thread
sergey-tihon marked this conversation as resolved.

[<Fact>]
Comment thread
sergey-tihon marked this conversation as resolved.
let ``required string time format maps to TimeOnly when useDateOnly is true``() =
let ty =
compilePropertyTypeWithDateOnly " type: string\n format: time\n" true

ty |> shouldEqual typeof<TimeOnly>

[<Fact>]
let ``required string uuid format maps to Guid``() =
let ty = compilePropertyType " type: string\n format: uuid\n" true
Expand Down
Loading