Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()

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
54 changes: 54 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
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