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
1 change: 1 addition & 0 deletions src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
3 changes: 2 additions & 1 deletion src/SwaggerProvider.DesignTime/Provider.SwaggerClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ module SwaggerCache =
let providedTypes = Caching.createInMemoryCache(TimeSpan.FromMinutes 5.0)

/// The Swagger Type Provider.
[<TypeProvider; Obsolete("Use OpenApiClientTypeProvider when possible, it supports v2 & v3 schema formats.")>]
[<TypeProvider;
Obsolete("SwaggerClientProvider is deprecated and will be removed in a future release. Use OpenApiClientProvider instead, which supports both v2 and v3 schema formats.")>]
type public SwaggerTypeProvider(cfg: TypeProviderConfig) as this =
inherit
TypeProviderForNamespaces(
Expand Down
12 changes: 12 additions & 0 deletions src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,18 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
typedefof<Option<obj>>

ProvidedTypeBuilder.MakeGenericType(baseGenTy, [ tyType ])
else if
not provideNullable
&& (tyType = typeof<string>
|| tyType = typeof<IO.Stream>
|| tyType = typeof<byte>.MakeArrayType(1))
then
// Scalar reference types (string, Stream, byte[*]) are wrapped in Option<T> when non-required,
// unless the caller prefers Nullable semantics — in that case the CLR reference-type nullability
// is relied upon directly (Nullable<T> 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<Option<obj>>, [ tyType ])
else
tyType

Expand Down
4 changes: 4 additions & 0 deletions src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ module RuntimeHelpers =

match obj with
| :? array<byte> as xs -> [ name, (client.Serialize xs).Trim('\"') ] // TODO: Need to verify how servers parse byte[] from query string
| :? Option<array<byte>> as x ->
match x with
| Some xs -> [ name, (client.Serialize xs).Trim('\"') ]
| None -> []
| :? array<bool> as xs -> xs |> toStrArray name
| :? array<int32> as xs -> xs |> toStrArray name
| :? array<int64> as xs -> xs |> toStrArray name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ let ``Send file as IO.Stream``() =
[<Fact>]
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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |])
}

[<Fact>]
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 |])
}

[<Fact>]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ let ``Update Double POST Test``() =

[<Fact>]
let ``Update String GET Test``() =
api.GetApiUpdateString("Serge") |> asyncEqual "Hello, Serge"
api.GetApiUpdateString(Some "Serge") |> asyncEqual "Hello, Serge"

[<Fact>]
let ``Update String POST Test``() =
api.PostApiUpdateString("Serge") |> asyncEqual "Hello, Serge"
api.PostApiUpdateString(Some "Serge") |> asyncEqual "Hello, Serge"


[<Fact>]
Expand All @@ -83,11 +83,11 @@ let ``Update Guid POST Test``() =

[<Fact>]
let ``Update Enum GET Test``() =
api.GetApiUpdateEnum("Absolute") |> asyncEqual "Absolute"
api.GetApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute"

[<Fact>]
let ``Update Enum POST Test``() =
api.PostApiUpdateEnum("Absolute") |> asyncEqual "Absolute"
api.PostApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute"


[<Fact>]
Expand Down Expand Up @@ -159,7 +159,7 @@ let ``Update Object Point POST Test``() =
[<Fact>]
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
Expand All @@ -170,8 +170,8 @@ let ``Send and Receive object with byte[]``() =
let ``Send byte[] in query``() =
task {
let bytes = api.Deserialize("\"NDI0Mg==\"", typeof<byte[]>) :?> byte[] // base64 encoded "4242"
let! y = api.GetApiUpdateObjectFileDescriptionClass(bytes)
y.Bytes |> shouldEqual(bytes)
let! y = api.GetApiUpdateObjectFileDescriptionClass(Some bytes)
y.Bytes |> shouldEqual(Some bytes)
}

[<Fact>]
Expand Down
79 changes: 79 additions & 0 deletions tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.
/// This class validates that System.Text.Json correctly round-trips Option<string>.
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

[<Fact>]
let ``FSharpConverter: JSON null on string option property becomes None``() =
let r =
JsonSerializer.Deserialize<StringOptionClass>("""{"Value":null}""", optionsWithFSharpConverter)

r.Value |> shouldEqual None

[<Fact>]
let ``FSharpConverter: JSON string on string option property becomes Some value``() =
let r =
JsonSerializer.Deserialize<StringOptionClass>("""{"Value":"hello"}""", optionsWithFSharpConverter)

r.Value |> shouldEqual(Some "hello")

[<Fact>]
let ``FSharpConverter: missing field on string option property becomes None``() =
let r =
JsonSerializer.Deserialize<StringOptionClass>("""{}""", optionsWithFSharpConverter)

r.Value |> shouldEqual None

[<Fact>]
let ``plain STJ: JSON null on string option property becomes None``() =
let r = JsonSerializer.Deserialize<StringOptionClass>("""{"Value":null}""")
r.Value |> shouldEqual None

[<Fact>]
let ``plain STJ: JSON string on string option property becomes Some value``() =
let r = JsonSerializer.Deserialize<StringOptionClass>("""{"Value":"hello"}""")
r.Value |> shouldEqual(Some "hello")

[<Fact>]
let ``FSharpConverter: JSON null on byte array option property becomes None``() =
let r =
JsonSerializer.Deserialize<ByteArrayOptionClass>("""{"Data":null}""", optionsWithFSharpConverter)

r.Data |> shouldEqual None

[<Fact>]
let ``FSharpConverter: JSON base64 on byte array option property becomes Some value``() =
let r =
JsonSerializer.Deserialize<ByteArrayOptionClass>("""{"Data":"AQID"}""", optionsWithFSharpConverter)

r.Data |> shouldEqual(Some [| 1uy; 2uy; 3uy |])

[<Fact>]
let ``FSharpConverter: missing field on byte array option property becomes None``() =
let r =
JsonSerializer.Deserialize<ByteArrayOptionClass>("""{}""", optionsWithFSharpConverter)

r.Data |> shouldEqual None
43 changes: 33 additions & 10 deletions tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -130,22 +130,31 @@ let ``optional Guid maps to Option<Guid>``() =

ty |> shouldEqual(typedefof<Option<_>>.MakeGenericType(typeof<Guid>))

// ── Optional reference types are NOT wrapped (they are already nullable) ─────
// ── Optional reference types are wrapped in Option<T> ────────────────────────

[<Fact>]
let ``optional string is not wrapped in Option``() =
let ``optional string maps to Option<string>``() =
let ty = compilePropertyType " type: string\n" false

// string is a reference type — not wrapped in Option<T> even when non-required
ty |> shouldEqual typeof<string>
ty
|> shouldEqual(typedefof<Option<_>>.MakeGenericType(typeof<string>))

[<Fact>]
let ``optional byte array is not wrapped in Option``() =
let ``optional byte array maps to Option<byte[]>``() =
let ty =
compilePropertyType " type: string\n format: byte\n" false

// byte[*] is a reference type — not wrapped in Option<T>
ty |> shouldEqual(typeof<byte>.MakeArrayType(1))
// byte[*] is a reference type — wrapped in Option<T> when non-required
ty
|> shouldEqual(typedefof<Option<_>>.MakeGenericType(typeof<byte>.MakeArrayType(1)))

[<Fact>]
let ``optional binary maps to Option<Stream>``() =
let ty =
compilePropertyType " type: string\n format: binary\n" false

ty
|> shouldEqual(typedefof<Option<_>>.MakeGenericType(typeof<IO.Stream>))

// ── $ref primitive-type alias helpers ────────────────────────────────────────

Expand Down Expand Up @@ -403,8 +412,22 @@ let ``PreferNullable: required integer is not wrapped (Nullable only for optiona
ty |> shouldEqual typeof<int32>

[<Fact>]
let ``PreferNullable: optional string is not wrapped (reference type)``() =
// Reference types like string are not wrapped in Nullable<T> 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<T> is not valid for reference types).
let ty = compilePropertyTypeWith true " type: string\n" false
ty |> shouldEqual typeof<string>

[<Fact>]
let ``PreferNullable: optional binary stays as plain byte array``() =
let ty =
compilePropertyTypeWith true " type: string\n format: byte\n" false

ty |> shouldEqual(typeof<byte>.MakeArrayType(1))

[<Fact>]
let ``PreferNullable: optional binary (base64) stays as plain Stream``() =
let ty =
compilePropertyTypeWith true " type: string\n format: binary\n" false

ty |> shouldEqual typeof<IO.Stream>
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ let ``v2 petstore Pet type has correct property types``() =
idProp.PropertyType |> shouldEqual typeof<int64>
// required string
nameProp.PropertyType |> shouldEqual typeof<string>
// optional string — string is a reference type, not wrapped in Option
tagProp.PropertyType |> shouldEqual typeof<string>
// optional string — now wrapped in Option<string>
tagProp.PropertyType |> shouldEqual typeof<string option>

[<Fact>]
let ``v2 petstore schema generates API client types with operations``() =
Expand Down
Loading