diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 7685e5f1..91c52508 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -5,8 +5,6 @@ on: branches: - master pull_request: - branches: - - master jobs: build: diff --git a/global.json b/global.json index badcd443..b24aad66 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", - "rollForward": "minor" + "version": "10.0.100", + "rollForward": "latestPatch" } } diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 05c23962..9ee42e10 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -85,38 +85,42 @@ module RuntimeHelpers = | _ -> obj.ToString() let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = - match obj with - | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArray name - | :? array as xs -> xs |> toStrArrayDateTime name - | :? array as xs -> xs |> toStrArrayDateTimeOffset name - | :? array as xs -> xs |> toStrArray name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOpt name - | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name - | :? array> as xs -> xs |> toStrArray name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrOpt name - | :? Option as x -> x |> toStrDateTimeOpt name - | :? Option as x -> x |> toStrDateTimeOffsetOpt name - | :? DateTime as x -> [ name, x.ToString("O") ] - | :? DateTimeOffset as x -> [ name, x.ToString("O") ] - | :? Option as x -> x |> toStrOpt name - | _ -> [ name, (if isNull obj then null else obj.ToString()) ] + if isNull obj then + [] + else + + match obj with + | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArray name + | :? array as xs -> xs |> toStrArrayDateTime name + | :? array as xs -> xs |> toStrArrayDateTimeOffset name + | :? array as xs -> xs |> toStrArray name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOpt name + | :? array> as xs -> xs |> toStrArrayDateTimeOffsetOpt name + | :? array> as xs -> xs |> toStrArray name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrOpt name + | :? Option as x -> x |> toStrDateTimeOpt name + | :? Option as x -> x |> toStrDateTimeOffsetOpt name + | :? DateTime as x -> [ name, x.ToString("O") ] + | :? DateTimeOffset as x -> [ name, x.ToString("O") ] + | :? Option as x -> x |> toStrOpt name + | _ -> [ name, obj.ToString() ] let getPropertyNameAttribute name = { new Reflection.CustomAttributeData() with @@ -145,6 +149,22 @@ module RuntimeHelpers = content | _ -> failwith $"Unexpected parameter type {boxedStream.GetType().Name} instead of IO.Stream" + // Unwraps F# option values: returns the inner value for Some, null for None. + // This prevents `Some(value)` from being sent as-is in form data. + let private unwrapFSharpOption(value: obj) : obj = + if isNull value then + null + else + let ty = value.GetType() + + if + ty.IsGenericType + && ty.GetGenericTypeDefinition() = typedefof> + then + ty.GetProperty("Value").GetValue(value) + else + value + let getPropertyValues(object: obj) = if isNull object then Seq.empty @@ -162,6 +182,7 @@ module RuntimeHelpers = | _ -> prop.Name prop.GetValue(object) + |> unwrapFSharpOption |> Option.ofObj |> Option.map(fun value -> (name, value))) diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs new file mode 100644 index 00000000..f6a9a3b6 --- /dev/null +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -0,0 +1,338 @@ +namespace SwaggerProvider.Tests.RuntimeHelpersTests + +open System +open System.IO +open System.Net.Http +open System.Text.Json +open Xunit +open FsUnitTyped +open Swagger.Internal.RuntimeHelpers + +/// Unit tests for RuntimeHelpers — the runtime parameter serialization and HTTP utilities. +/// These functions are used by every generated API client but previously had no dedicated tests. +module ToParamTests = + + [] + let ``toParam formats DateTime as ISO 8601 round-trip``() = + let dt = DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc) + let result = toParam(box dt) + result |> shouldEqual(dt.ToString("O")) + + [] + let ``toParam formats DateTimeOffset as ISO 8601 round-trip``() = + let dto = DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.FromHours(2.0)) + let result = toParam(box dto) + result |> shouldEqual(dto.ToString("O")) + + [] + let ``toParam returns null for null input``() = + let result = toParam null + result |> shouldEqual null + + [] + let ``toParam uses ToString for integers``() = + let result = toParam(box 42) + result |> shouldEqual "42" + + [] + let ``toParam uses ToString for strings``() = + let result = toParam(box "hello world") + result |> shouldEqual "hello world" + + [] + let ``toParam uses ToString for Guid``() = + let g = Guid("d3b07384-d9a2-4e3f-9a4b-1234567890ab") + let result = toParam(box g) + result |> shouldEqual(g.ToString()) + + +module ToQueryParamsTests = + + let private stubClient = + { new Swagger.ProvidedApiClientBase(null, JsonSerializerOptions()) with + override _.Serialize(v) = + JsonSerializer.Serialize v + + override _.Deserialize(s, t) = + JsonSerializer.Deserialize(s, t) } + + [] + let ``toQueryParams handles string array``() = + let result = toQueryParams "tag" (box [| "alpha"; "beta"; "gamma" |]) stubClient + + result + |> shouldEqual [ ("tag", "alpha"); ("tag", "beta"); ("tag", "gamma") ] + + [] + let ``toQueryParams handles int32 array``() = + let result = toQueryParams "id" (box [| 1; 2; 3 |]) stubClient + result |> shouldEqual [ ("id", "1"); ("id", "2"); ("id", "3") ] + + [] + let ``toQueryParams handles int64 array``() = + let result = toQueryParams "id" (box [| 1L; 2L; 3L |]) stubClient + result |> shouldEqual [ ("id", "1"); ("id", "2"); ("id", "3") ] + + [] + let ``toQueryParams handles bool array``() = + let result = toQueryParams "flag" (box [| true; false |]) stubClient + result |> shouldEqual [ ("flag", "True"); ("flag", "False") ] + + [] + let ``toQueryParams formats DateTime array as ISO 8601``() = + let dt = DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc) + let result = toQueryParams "d" (box [| dt |]) stubClient + result |> shouldHaveLength 1 + fst result[0] |> shouldEqual "d" + snd result[0] |> shouldEqual(dt.ToString("O")) + + [] + let ``toQueryParams formats DateTimeOffset array as ISO 8601``() = + let dto = DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero) + let result = toQueryParams "d" (box [| dto |]) stubClient + result |> shouldHaveLength 1 + snd result[0] |> shouldEqual(dto.ToString("O")) + + [] + let ``toQueryParams handles Option Some``() = + let result = toQueryParams "q" (box(Some "hello")) stubClient + result |> shouldEqual [ ("q", "hello") ] + + [] + let ``toQueryParams handles Option None``() = + let result = toQueryParams "q" (box(Option.None)) stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams handles Option Some``() = + let result = toQueryParams "n" (box(Some 99)) stubClient + result |> shouldEqual [ ("n", "99") ] + + [] + let ``toQueryParams handles Option None``() = + let result = toQueryParams "n" (box(Option.None)) stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams handles Option Some as ISO 8601``() = + let dt = DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc) + let result = toQueryParams "d" (box(Some dt)) stubClient + result |> shouldHaveLength 1 + snd result[0] |> shouldEqual(dt.ToString("O")) + + [] + let ``toQueryParams handles Option None``() = + let result = toQueryParams "d" (box(Option.None)) stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams handles Option Some``() = + let g = Guid.NewGuid() + let result = toQueryParams "id" (box(Some g)) stubClient + result |> shouldEqual [ ("id", g.ToString()) ] + + [] + let ``toQueryParams handles plain DateTime as ISO 8601``() = + let dt = DateTime(2024, 6, 1, 8, 0, 0, DateTimeKind.Utc) + let result = toQueryParams "dt" (box dt) stubClient + result |> shouldHaveLength 1 + snd result[0] |> shouldEqual(dt.ToString("O")) + + [] + let ``toQueryParams handles plain DateTimeOffset as ISO 8601``() = + let dto = DateTimeOffset(2024, 6, 1, 8, 0, 0, TimeSpan.FromHours(-5.0)) + let result = toQueryParams "dto" (box dto) stubClient + result |> shouldHaveLength 1 + snd result[0] |> shouldEqual(dto.ToString("O")) + + [] + let ``toQueryParams handles plain string``() = + let result = toQueryParams "q" (box "search term") stubClient + result |> shouldEqual [ ("q", "search term") ] + + [] + let ``toQueryParams returns empty list for null input (treated as Option None)``() = + // In F#, None for any option type is compiled as null at the .NET level, + // so a null obj is treated as Option None and returns an empty list. + let result = toQueryParams "q" null stubClient + result |> shouldEqual [] + + [] + let ``toQueryParams skips None items in Option array``() = + let values: Option[] = [| Some 1; None; Some 3 |] + let result = toQueryParams "n" (box values) stubClient + result |> shouldEqual [ ("n", "1"); ("n", "3") ] + + [] + let ``toQueryParams handles Guid array``() = + let g1 = Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + let g2 = Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + let result = toQueryParams "id" (box [| g1; g2 |]) stubClient + result |> shouldEqual [ ("id", g1.ToString()); ("id", g2.ToString()) ] + + [] + let ``toQueryParams handles float32 array``() = + let result = toQueryParams "v" (box [| 1.5f; 2.5f |]) stubClient + result |> shouldEqual [ ("v", "1.5"); ("v", "2.5") ] + + [] + let ``toQueryParams handles double array``() = + let result = toQueryParams "v" (box [| 1.5; 2.5 |]) stubClient + result |> shouldEqual [ ("v", "1.5"); ("v", "2.5") ] + + [] + let ``toQueryParams handles byte array as base64``() = + // byte[] is serialized via client.Serialize (JSON base64) with surrounding quotes trimmed + let bytes = [| 72uy; 101uy; 108uy; 108uy; 111uy |] // "Hello" in ASCII + let expected = (JsonSerializer.Serialize bytes).Trim('"') + let result = toQueryParams "data" (box bytes) stubClient + result |> shouldEqual [ ("data", expected) ] + + [] + let ``toQueryParams skips None items in Option array``() = + let values: Option[] = [| Some "a"; None; Some "c" |] + let result = toQueryParams "q" (box values) stubClient + result |> shouldEqual [ ("q", "a"); ("q", "c") ] + + [] + let ``toQueryParams skips None items in Option array``() = + let values: Option[] = [| Some 1.5f; None; Some 3.5f |] + let result = toQueryParams "v" (box values) stubClient + result |> shouldEqual [ ("v", "1.5"); ("v", "3.5") ] + + [] + let ``toQueryParams skips None items in Option array``() = + let values: Option[] = [| Some 1.5; None; Some 3.5 |] + let result = toQueryParams "v" (box values) stubClient + result |> shouldEqual [ ("v", "1.5"); ("v", "3.5") ] + + +module CombineUrlTests = + + [] + let ``combineUrl joins paths without extra slashes``() = + combineUrl "http://example.com/api" "v1/users" + |> shouldEqual "http://example.com/api/v1/users" + + [] + let ``combineUrl trims trailing slash from left``() = + combineUrl "http://example.com/api/" "v1/users" + |> shouldEqual "http://example.com/api/v1/users" + + [] + let ``combineUrl trims leading slash from right``() = + combineUrl "http://example.com/api" "/v1/users" + |> shouldEqual "http://example.com/api/v1/users" + + [] + let ``combineUrl trims both slashes``() = + combineUrl "http://example.com/api/" "/v1/users" + |> shouldEqual "http://example.com/api/v1/users" + + [] + let ``combineUrl works with empty path segment``() = + combineUrl "http://example.com" "" + |> shouldEqual "http://example.com/" + + +module CreateHttpRequestTests = + + [] + let ``createHttpRequest creates GET request``() = + use req = createHttpRequest "GET" "v1/users" [] + req.Method |> shouldEqual HttpMethod.Get + + [] + let ``createHttpRequest creates POST request``() = + use req = createHttpRequest "POST" "v1/users" [] + req.Method |> shouldEqual HttpMethod.Post + + [] + let ``createHttpRequest creates DELETE request``() = + use req = createHttpRequest "DELETE" "v1/users/42" [] + req.Method |> shouldEqual HttpMethod.Delete + + [] + let ``createHttpRequest is case-insensitive for method``() = + use req = createHttpRequest "get" "v1/users" [] + req.Method |> shouldEqual HttpMethod.Get + + [] + let ``createHttpRequest appends query parameters``() = + use req = createHttpRequest "GET" "v1/users" [ ("page", "2"); ("size", "10") ] + let uri = req.RequestUri.ToString() + uri |> shouldContainText "page=2" + uri |> shouldContainText "size=10" + + [] + let ``createHttpRequest skips null query parameter values``() = + use req = createHttpRequest "GET" "v1/users" [ ("q", null) ] + let uri = req.RequestUri.ToString() + uri |> shouldNotContainText "q=" + + [] + let ``createHttpRequest includes path in request URI``() = + use req = createHttpRequest "GET" "v1/pets/42" [] + req.RequestUri.ToString() |> shouldContainText "v1/pets/42" + + +module FillHeadersTests = + + [] + let ``fillHeaders adds standard headers``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeaders req [ ("Accept", "application/json"); ("X-Api-Key", "secret") ] + req.Headers.Contains("Accept") |> shouldEqual true + req.Headers.Contains("X-Api-Key") |> shouldEqual true + + [] + let ``fillHeaders skips null-value headers``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeaders req [ ("X-Missing", null) ] + req.Headers.Contains("X-Missing") |> shouldEqual false + + [] + let ``fillHeaders silently ignores Content-Type header``() = + // Content-Type must be set on HttpContent, not on the request; this should not throw + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + // Should not raise an exception even though Content-Type cannot be added to request headers + fillHeaders req [ ("Content-Type", "application/json") ] + + +module ToContentTests = + + [] + let ``toStringContent returns JSON content type``() = + use c = toStringContent "{\"key\":\"value\"}" + c.Headers.ContentType.MediaType |> shouldEqual "application/json" + + [] + let ``toStringContent preserves the body``() = + let body = "{\"name\":\"Alice\"}" + use c = toStringContent body + let text = c.ReadAsStringAsync() |> Async.AwaitTask |> Async.RunSynchronously + text |> shouldEqual body + + [] + let ``toTextContent returns text/plain content type``() = + use c = toTextContent "hello" + c.Headers.ContentType.MediaType |> shouldEqual "text/plain" + + [] + let ``toStreamContent sets provided content type``() = + use stream = new MemoryStream([| 1uy; 2uy; 3uy |]) + use c = toStreamContent(box stream, "application/octet-stream") + + c.Headers.ContentType.MediaType + |> shouldEqual "application/octet-stream" + + [] + let ``toStreamContent omits content type when empty``() = + use stream = new MemoryStream([| 1uy; 2uy |]) + use c = toStreamContent(box stream, "") + c.Headers.ContentType |> shouldEqual null + + [] + let ``toStreamContent throws for non-stream input``() = + Assert.Throws(fun () -> toStreamContent(box "not a stream", "application/json") |> ignore) + |> ignore diff --git a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj index d2c7357e..c69a40be 100644 --- a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj +++ b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj @@ -18,6 +18,7 @@ +