diff --git a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs index 576a7e7e..59e8783c 100644 --- a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs @@ -119,6 +119,7 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = |> Seq.toList let useDateOnly = cfg.SystemRuntimeAssemblyVersion.Major >= 6 + let defCompiler = DefinitionCompiler(schema, preferNullable, useDateOnly) let opCompiler = diff --git a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs index 24153569..cb5099de 100644 --- a/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs @@ -13,7 +13,8 @@ module SwaggerCache = let providedTypes = Caching.createInMemoryCache(TimeSpan.FromMinutes 5.0) /// The Swagger Type Provider. -[] +[] type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this = inherit TypeProviderForNamespaces( diff --git a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs index 306a344e..a8407842 100644 --- a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs @@ -582,6 +582,18 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b typedefof> ProvidedTypeBuilder.MakeGenericType(baseGenTy, [ tyType ]) + else if + not provideNullable + && (tyType = typeof + || tyType = typeof + || tyType = typeof.MakeArrayType(1)) + then + // Scalar reference types (string, Stream, byte[*]) are wrapped in Option when non-required, + // unless the caller prefers Nullable semantics — in that case the CLR reference-type nullability + // is relied upon directly (Nullable is not valid for reference types). + // Collection types (arrays, maps) and provided object types are left unwrapped — they + // naturally express absence via null/empty. + ProvidedTypeBuilder.MakeGenericType(typedefof>, [ tyType ]) else tyType diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 239f99c1..2b936305 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -106,6 +106,10 @@ module RuntimeHelpers = match obj with | :? array as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string + | :? Option> as x -> + match x with + | Some xs -> [ name, (client.Serialize xs).Trim('\"') ] + | None -> [] | :? array as xs -> xs |> toStrArray name | :? array as xs -> xs |> toStrArray name | :? array as xs -> xs |> toStrArray name diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swagger.PetStore.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swagger.PetStore.Tests.fs index fdad3284..ede3ce50 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swagger.PetStore.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swagger.PetStore.Tests.fs @@ -69,12 +69,12 @@ let ``call provided methods``() = let id = 3247L try - do! store.DeletePet(id, apiKey) + do! store.DeletePet(id, Some apiKey) with _ -> () - let tag = PetStore.Tag(None, "foobar") - tag.Name |> shouldEqual "foobar" + let tag = PetStore.Tag(None, Some "foobar") + tag.Name |> shouldEqual(Some "foobar") let pet = PetStore.Pet("foo", [||], Some id) pet.ToString() |> shouldContainText(id.ToString()) diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs index 4e1d4bab..3c3dc539 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -74,7 +74,7 @@ let ``Call async generated method without CancellationToken uses default token`` let ``Call method with required param and explicit CancellationToken``() = task { use cts = new CancellationTokenSource() - let! result = api.GetApiUpdateString("Serge", cts.Token) + let! result = api.GetApiUpdateString(Some "Serge", cts.Token) result |> shouldEqual "Hello, Serge" } diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.FileController.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.FileController.Tests.fs index 82108ed6..380eaa3b 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.FileController.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.FileController.Tests.fs @@ -38,7 +38,9 @@ let ``Send file as IO.Stream``() = [] let ``Send file and get it back``() = task { - let data = WebAPI.OperationTypes.PostApiReturnFileSingle_formData(toStream text) + let data = + WebAPI.OperationTypes.PostApiReturnFileSingle_formData(Some(toStream text)) + let! stream = api.PostApiReturnFileSingle(data) let! actual = fromStream stream actual |> shouldEqual text @@ -48,7 +50,7 @@ let ``Send file and get it back``() = let ``Send form-with-file and get it back as IO.Stream``() = task { let data = - WebAPI.OperationTypes.PostApiReturnFileFormWithFile_formData("newName.txt", toStream text) + WebAPI.OperationTypes.PostApiReturnFileFormWithFile_formData(Some "newName.txt", Some(toStream text)) let! stream = api.PostApiReturnFileFormWithFile(data) let! actual = fromStream stream diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs index 6bb52375..34fd8730 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.ReturnControllers.Tests.fs @@ -166,16 +166,16 @@ let ``Return Object Point POST Test``() = let ``Return FileDescription GET Test``() = task { let! file = api.GetApiReturnFileDescription() - file.Name |> shouldEqual("1.txt") - file.Bytes |> shouldEqual([| 1uy; 2uy; 3uy |]) + file.Name |> shouldEqual(Some "1.txt") + file.Bytes |> shouldEqual(Some [| 1uy; 2uy; 3uy |]) } [] let ``Return FileDescription POST Test``() = task { let! file = api.PostApiReturnFileDescription() - file.Name |> shouldEqual("1.txt") - file.Bytes |> shouldEqual([| 1uy; 2uy; 3uy |]) + file.Name |> shouldEqual(Some "1.txt") + file.Bytes |> shouldEqual(Some [| 1uy; 2uy; 3uy |]) } [] diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.UpdateControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.UpdateControllers.Tests.fs index b9e3e311..337b8cfa 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.UpdateControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.UpdateControllers.Tests.fs @@ -56,11 +56,11 @@ let ``Update Double POST Test``() = [] let ``Update String GET Test``() = - api.GetApiUpdateString("Serge") |> asyncEqual "Hello, Serge" + api.GetApiUpdateString(Some "Serge") |> asyncEqual "Hello, Serge" [] let ``Update String POST Test``() = - api.PostApiUpdateString("Serge") |> asyncEqual "Hello, Serge" + api.PostApiUpdateString(Some "Serge") |> asyncEqual "Hello, Serge" [] @@ -83,11 +83,11 @@ let ``Update Guid POST Test``() = [] let ``Update Enum GET Test``() = - api.GetApiUpdateEnum("Absolute") |> asyncEqual "Absolute" + api.GetApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute" [] let ``Update Enum POST Test``() = - api.PostApiUpdateEnum("Absolute") |> asyncEqual "Absolute" + api.PostApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute" [] @@ -159,7 +159,7 @@ let ``Update Object Point POST Test``() = [] let ``Send and Receive object with byte[]``() = task { - let x = WebAPI.FileDescription(Name = "2.txt", Bytes = [| 42uy |]) + let x = WebAPI.FileDescription(Name = Some "2.txt", Bytes = Some [| 42uy |]) let! y = api.PostApiUpdateObjectFileDescriptionClass(x) x.Name |> shouldEqual y.Name x.Bytes |> shouldEqual y.Bytes @@ -170,8 +170,8 @@ let ``Send and Receive object with byte[]``() = let ``Send byte[] in query``() = task { let bytes = api.Deserialize("\"NDI0Mg==\"", typeof) :?> byte[] // base64 encoded "4242" - let! y = api.GetApiUpdateObjectFileDescriptionClass(bytes) - y.Bytes |> shouldEqual(bytes) + let! y = api.GetApiUpdateObjectFileDescriptionClass(Some bytes) + y.Bytes |> shouldEqual(Some bytes) } [] diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 598eba7b..859e73a8 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -788,3 +788,82 @@ module GetPropertyValuesTests = secondName |> shouldEqual "custom_name" firstValue |> shouldEqual(box "value1") secondValue |> shouldEqual(box "value2") + +/// Test type for JSON deserialization tests — a plain .NET class mirroring the generated provider types. +/// The DefinitionCompiler now wraps all non-required reference types in Option. +/// This class validates that System.Text.Json correctly round-trips Option. +type StringOptionClass() = + let mutable _value: string option = None + + member _.Value + with get () = _value + and set (v) = _value <- v + +/// Test type mirroring a generated provider type with an optional byte-array property. +type ByteArrayOptionClass() = + let mutable _data: byte array option = None + + member _.Data + with get () = _data + and set (v) = _data <- v + +/// Verify that the JSON serializers used by SwaggerProvider correctly map a JSON null to None +/// (and not Some(null)) for optional reference-type properties — confirming that no extra +/// sanitization is needed in the generated property setter. +module OptionDeserializationTests = + let private optionsWithFSharpConverter = + let opts = JsonSerializerOptions() + opts.Converters.Add(JsonFSharpConverter()) + opts + + [] + let ``FSharpConverter: JSON null on string option property becomes None``() = + let r = + JsonSerializer.Deserialize("""{"Value":null}""", optionsWithFSharpConverter) + + r.Value |> shouldEqual None + + [] + let ``FSharpConverter: JSON string on string option property becomes Some value``() = + let r = + JsonSerializer.Deserialize("""{"Value":"hello"}""", optionsWithFSharpConverter) + + r.Value |> shouldEqual(Some "hello") + + [] + let ``FSharpConverter: missing field on string option property becomes None``() = + let r = + JsonSerializer.Deserialize("""{}""", optionsWithFSharpConverter) + + r.Value |> shouldEqual None + + [] + let ``plain STJ: JSON null on string option property becomes None``() = + let r = JsonSerializer.Deserialize("""{"Value":null}""") + r.Value |> shouldEqual None + + [] + let ``plain STJ: JSON string on string option property becomes Some value``() = + let r = JsonSerializer.Deserialize("""{"Value":"hello"}""") + r.Value |> shouldEqual(Some "hello") + + [] + let ``FSharpConverter: JSON null on byte array option property becomes None``() = + let r = + JsonSerializer.Deserialize("""{"Data":null}""", optionsWithFSharpConverter) + + r.Data |> shouldEqual None + + [] + let ``FSharpConverter: JSON base64 on byte array option property becomes Some value``() = + let r = + JsonSerializer.Deserialize("""{"Data":"AQID"}""", optionsWithFSharpConverter) + + r.Data |> shouldEqual(Some [| 1uy; 2uy; 3uy |]) + + [] + let ``FSharpConverter: missing field on byte array option property becomes None``() = + let r = + JsonSerializer.Deserialize("""{}""", optionsWithFSharpConverter) + + r.Data |> shouldEqual None diff --git a/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs index b6d41e59..fcfe998d 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs @@ -130,22 +130,31 @@ let ``optional Guid maps to Option``() = ty |> shouldEqual(typedefof>.MakeGenericType(typeof)) -// ── Optional reference types are NOT wrapped (they are already nullable) ───── +// ── Optional reference types are wrapped in Option ──────────────────────── [] -let ``optional string is not wrapped in Option``() = +let ``optional string maps to Option``() = let ty = compilePropertyType " type: string\n" false - // string is a reference type — not wrapped in Option even when non-required - ty |> shouldEqual typeof + ty + |> shouldEqual(typedefof>.MakeGenericType(typeof)) [] -let ``optional byte array is not wrapped in Option``() = +let ``optional byte array maps to Option``() = let ty = compilePropertyType " type: string\n format: byte\n" false - // byte[*] is a reference type — not wrapped in Option - ty |> shouldEqual(typeof.MakeArrayType(1)) + // byte[*] is a reference type — wrapped in Option when non-required + ty + |> shouldEqual(typedefof>.MakeGenericType(typeof.MakeArrayType(1))) + +[] +let ``optional binary maps to Option``() = + let ty = + compilePropertyType " type: string\n format: binary\n" false + + ty + |> shouldEqual(typedefof>.MakeGenericType(typeof)) // ── $ref primitive-type alias helpers ──────────────────────────────────────── @@ -403,8 +412,22 @@ let ``PreferNullable: required integer is not wrapped (Nullable only for optiona ty |> shouldEqual typeof [] -let ``PreferNullable: optional string is not wrapped (reference type)``() = - // Reference types like string are not wrapped in Nullable since they are - // already nullable by nature — same behaviour as Option mode. +let ``PreferNullable: optional string stays as plain string``() = + // With provideNullable=true, reference types are left as plain CLR-nullable types + // (Nullable is not valid for reference types). let ty = compilePropertyTypeWith true " type: string\n" false ty |> shouldEqual typeof + +[] +let ``PreferNullable: optional binary stays as plain byte array``() = + let ty = + compilePropertyTypeWith true " type: string\n format: byte\n" false + + ty |> shouldEqual(typeof.MakeArrayType(1)) + +[] +let ``PreferNullable: optional binary (base64) stays as plain Stream``() = + let ty = + compilePropertyTypeWith true " type: string\n format: binary\n" false + + ty |> shouldEqual typeof diff --git a/tests/SwaggerProvider.Tests/v3/Schema.V2SchemaCompilationTests.fs b/tests/SwaggerProvider.Tests/v3/Schema.V2SchemaCompilationTests.fs index 490659f6..013e9786 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.V2SchemaCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.V2SchemaCompilationTests.fs @@ -143,8 +143,8 @@ let ``v2 petstore Pet type has correct property types``() = idProp.PropertyType |> shouldEqual typeof // required string nameProp.PropertyType |> shouldEqual typeof - // optional string — string is a reference type, not wrapped in Option - tagProp.PropertyType |> shouldEqual typeof + // optional string — now wrapped in Option + tagProp.PropertyType |> shouldEqual typeof [] let ``v2 petstore schema generates API client types with operations``() =