diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index b1cc6fba..7cb53cb3 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -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 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 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 @@ -380,40 +418,35 @@ module RuntimeHelpers = else let vTy = v.GetType() - if vTy = typeof 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 do - if not firstEl then - sb.Append("; ") |> ignore - - firstEl <- false - - if isNull x then + if vTy.IsArray then + appendFormattedArray v + // Unwrap F# Option before formatting: Some(x) → format x, None → "null". + // Without this arm, Option and Option properties fall through + // to obj.ToString(), which produces "Some(07/04/2025)" (locale-specific) instead + // of the ISO 8601 value expected by callers. + elif + vTy.IsGenericType + && vTy.GetGenericTypeDefinition() = typedefof> + 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() diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 94570523..63d304f7 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -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 = @@ -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]}" + [] + let ``formatObject formats Option DateOnly Some as ISO 8601``() = + let obj = FmtOptionDateOnly(Some(DateOnly(2025, 7, 4))) + formatObject obj |> shouldEqual "{Date=2025-07-04}" + + [] + let ``formatObject formats Option DateOnly None as null``() = + let obj = FmtOptionDateOnly(None) + formatObject obj |> shouldEqual "{Date=null}" + + [] + 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}" + + [] + let ``formatObject formats Option TimeOnly None as null``() = + let obj = FmtOptionTimeOnly(None) + formatObject obj |> shouldEqual "{Time=null}" + + [] + let ``formatObject formats Option string Some with quotes``() = + let obj = FmtOptionString(Some "hello") + formatObject obj |> shouldEqual "{Name=\"hello\"}" + + [] + let ``formatObject formats Option string None as null``() = + let obj = FmtOptionString(None) + formatObject obj |> shouldEqual "{Name=null}" + + [] + let ``formatObject formats Option int Some as integer``() = + let obj = FmtOptionInt(Some 42) + formatObject obj |> shouldEqual "{Count=42}" + + [] + let ``formatObject formats Option int None as null``() = + let obj = FmtOptionInt(None) + formatObject obj |> shouldEqual "{Count=null}" + + [] + let ``formatObject formats Option string array Some as bracketed list``() = + let obj = FmtOptionStringArray(Some([| "first"; "second" |])) + formatObject obj |> shouldEqual "{Tags=[first; second]}" + + [] + 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 = [] let ``toFormUrlEncodedContent encodes key-value pairs``() =