Skip to content
Merged
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
91 changes: 62 additions & 29 deletions src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -359,12 +359,50 @@ module RuntimeHelpers =
(name, prop))
)

// Appends a single value to the StringBuilder used by formatObject.
// Handles string (quoted), DateOnly/TimeOnly (ISO 8601), and all other types (ToString).
let private appendFormattedValue (sb: System.Text.StringBuilder) (x: obj) (xTy: Type) =
if xTy = typeof<string> then
sb.Append('"').Append(x.ToString()).Append('"') |> ignore
elif xTy.FullName = dateOnlyTypeName then
sb.Append(formatDateOrTimeValue "yyyy-MM-dd" xTy x) |> ignore
elif xTy.FullName = timeOnlyTypeName then
sb.Append(formatDateOrTimeValue "HH:mm:ss.FFFFFFF" xTy x) |> ignore
else
sb.Append(x.ToString()) |> ignore

/// Formats a generated API object as a string in the form `{Prop1=value1; Prop2=value2}`.
/// Only declared public instance properties are included, sorted alphabetically by name.
/// Used by the emitted ToString() override to keep the generated method body O(1) in size.
let formatObject(obj: obj) : string =
let props = getProperties(obj.GetType())
let sb = System.Text.StringBuilder()

let appendFormattedArray(v: obj) =
let vTy = v.GetType()
sb.Append('[') |> ignore
let mutable firstEl = true
let elTy = vTy.GetElementType()
let isDateOnly = not(isNull elTy) && elTy.FullName = dateOnlyTypeName
let isTimeOnly = not(isNull elTy) && elTy.FullName = timeOnlyTypeName

for x in (v :?> Array) |> Seq.cast<obj> do
if not firstEl then
sb.Append("; ") |> ignore

firstEl <- false

if isNull x then
sb.Append("null") |> ignore
elif isDateOnly then
sb.Append(formatDateOrTimeValue "yyyy-MM-dd" elTy x) |> ignore
elif isTimeOnly then
sb.Append(formatDateOrTimeValue "HH:mm:ss.FFFFFFF" elTy x) |> ignore
else
sb.Append(x.ToString()) |> ignore

sb.Append(']') |> ignore

sb.Append('{') |> ignore

for i in 0 .. props.Length - 1 do
Expand All @@ -380,40 +418,35 @@ module RuntimeHelpers =
else
let vTy = v.GetType()

if vTy = typeof<string> then
sb.Append('"').Append(v.ToString()).Append('"') |> ignore
elif vTy.FullName = dateOnlyTypeName then
sb.Append(formatDateOrTimeValue "yyyy-MM-dd" vTy v) |> ignore
elif vTy.FullName = timeOnlyTypeName then
sb.Append(formatDateOrTimeValue "HH:mm:ss.FFFFFFF" vTy v) |> ignore
elif vTy.IsArray then
sb.Append('[') |> ignore
let mutable firstEl = true
let elTy = vTy.GetElementType()
let isDateOnly = not(isNull elTy) && elTy.FullName = dateOnlyTypeName
let isTimeOnly = not(isNull elTy) && elTy.FullName = timeOnlyTypeName

for x in (v :?> Array) |> Seq.cast<obj> do
if not firstEl then
sb.Append("; ") |> ignore

firstEl <- false

if isNull x then
if vTy.IsArray then
appendFormattedArray v
// Unwrap F# Option<T> before formatting: Some(x) β†’ format x, None β†’ "null".
// Without this arm, Option<DateOnly> and Option<TimeOnly> properties fall through
// to obj.ToString(), which produces "Some(07/04/2025)" (locale-specific) instead
// of the ISO 8601 value expected by callers.
elif
Comment thread
sergey-tihon marked this conversation as resolved.
vTy.IsGenericType
&& vTy.GetGenericTypeDefinition() = typedefof<option<_>>
then
let tagReader = optionTagReaderCache.GetOrAdd(vTy, optionTagReaderFactory)

if tagReader v = 1 then // 1 = Some
let valueProp = optionValueCache.GetOrAdd(vTy, optionValueFactory)
let inner = valueProp.GetValue(v)

if isNull inner then
sb.Append("null") |> ignore
else
let xTy = x.GetType()
let innerTy = inner.GetType()

if isDateOnly then
sb.Append(formatDateOrTimeValue "yyyy-MM-dd" xTy x) |> ignore
elif isTimeOnly then
sb.Append(formatDateOrTimeValue "HH:mm:ss.FFFFFFF" xTy x) |> ignore
if innerTy.IsArray then
appendFormattedArray inner
else
sb.Append(x.ToString()) |> ignore

sb.Append(']') |> ignore
appendFormattedValue sb inner innerTy
else
sb.Append("null") |> ignore
else
sb.Append(v.ToString()) |> ignore
appendFormattedValue sb v vTy

sb.Append('}').ToString()

Expand Down
71 changes: 70 additions & 1 deletion tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,24 @@ type FmtDateOnlyArray(dates: DateOnly[]) =
type FmtTimeOnlyArray(times: TimeOnly[]) =
member _.Times = times

type FmtOptionDateOnly(date: DateOnly option) =
member _.Date = date

type FmtOptionTimeOnly(time: TimeOnly option) =
member _.Time = time

type FmtOptionString(name: string option) =
member _.Name = name

type FmtOptionInt(count: int option) =
member _.Count = count

type FmtOptionStringArray(tags: string[] option) =
member _.Tags = tags

type FmtOptionDateOnlyArray(dates: DateOnly[] option) =
member _.Dates = dates


module FormatObjectTests =

Expand Down Expand Up @@ -1240,8 +1258,59 @@ module FormatObjectTests =
let obj = FmtTimeOnlyArray([| TimeOnly(8, 0, 0); TimeOnly(14, 30, 45, 500) |])
formatObject obj |> shouldEqual "{Times=[08:00:00; 14:30:45.5]}"

[<Fact>]
let ``formatObject formats Option DateOnly Some as ISO 8601``() =
let obj = FmtOptionDateOnly(Some(DateOnly(2025, 7, 4)))
formatObject obj |> shouldEqual "{Date=2025-07-04}"

[<Fact>]
let ``formatObject formats Option DateOnly None as null``() =
let obj = FmtOptionDateOnly(None)
formatObject obj |> shouldEqual "{Date=null}"

[<Fact>]
let ``formatObject formats Option TimeOnly Some using HH:mm:ss.FFFFFFF``() =
let obj = FmtOptionTimeOnly(Some(TimeOnly(14, 30, 0)))
formatObject obj |> shouldEqual "{Time=14:30:00}"

[<Fact>]
let ``formatObject formats Option TimeOnly None as null``() =
let obj = FmtOptionTimeOnly(None)
formatObject obj |> shouldEqual "{Time=null}"

[<Fact>]
let ``formatObject formats Option string Some with quotes``() =
let obj = FmtOptionString(Some "hello")
formatObject obj |> shouldEqual "{Name=\"hello\"}"

[<Fact>]
let ``formatObject formats Option string None as null``() =
let obj = FmtOptionString(None)
formatObject obj |> shouldEqual "{Name=null}"

[<Fact>]
let ``formatObject formats Option int Some as integer``() =
let obj = FmtOptionInt(Some 42)
formatObject obj |> shouldEqual "{Count=42}"

[<Fact>]
let ``formatObject formats Option int None as null``() =
let obj = FmtOptionInt(None)
formatObject obj |> shouldEqual "{Count=null}"

[<Fact>]
let ``formatObject formats Option string array Some as bracketed list``() =
let obj = FmtOptionStringArray(Some([| "first"; "second" |]))
formatObject obj |> shouldEqual "{Tags=[first; second]}"

[<Fact>]
let ``formatObject formats Option DateOnly array Some as ISO 8601 list``() =
let obj =
FmtOptionDateOnlyArray(Some([| DateOnly(2025, 1, 1); DateOnly(2025, 12, 31) |]))

formatObject obj |> shouldEqual "{Dates=[2025-01-01; 2025-12-31]}"


module ToFormUrlEncodedContentTests =

[<Fact>]
let ``toFormUrlEncodedContent encodes key-value pairs``() =
Expand Down
Loading