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
19 changes: 18 additions & 1 deletion .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,28 @@ jobs:
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Setup .NET 10.0 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: |
~/.nuget/packages
~/.local/share/NuGet
%LOCALAPPDATA%\NuGet\v3-cache
key: ${{ runner.os }}-nuget-${{ hashFiles('**/paket.lock') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Cache .paket directory
uses: actions/cache@v4
with:
path: .paket
key: ${{ runner.os }}-paket-${{ hashFiles('**/paket.lock') }}
restore-keys: |
${{ runner.os }}-paket-
- name: Install local tools
run: dotnet tool restore
- name: Paket Restore
Expand Down
21 changes: 18 additions & 3 deletions src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable) as this =
| true -> getReq schemaObj
|> Set.ofSeq

// Helper to check if a schema has the Null type flag (OpenAPI 3.0 nullable)
let isSchemaNullable(schema: IOpenApiSchema) =
not(isNull schema)
&& schema.Type.HasValue
&& schema.Type.Value.HasFlag(JsonSchemaType.Null)

// Generate fields and properties
let members =
let generateProperty = generateProperty(UniqueNameGenerator())
Expand All @@ -279,7 +285,13 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable) as this =
if String.IsNullOrEmpty propName then
failwithf $"Property cannot be created with empty name. TypeName:%A{tyName}; SchemaObj:%A{schemaObj}"

let isRequired = schemaObjRequired.Contains propName
// Check if the property is nullable (OpenAPI 3.0 nullable becomes Null type flag in 3.1)
let isNullable = isSchemaNullable propSchema

// A property is "required" for type generation if it's in the required list AND not nullable.
// Nullable properties must be wrapped as Option<T>/Nullable<T> to represent null values,
// even if they're in the required list (required + nullable means must be present but can be null).
let isRequired = schemaObjRequired.Contains propName && not isNullable

let pTy =
compileBySchema ns (ns.ReserveUniqueName tyName (nicePascalName propName)) propSchema isRequired ns.RegisterType false
Expand All @@ -303,14 +315,17 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable) as this =
let ctorParams, fields =
let required, optional =
List.zip (List.ofSeq schemaObjProperties) members
|> List.partition(fun (x, _) -> schemaObjRequired.Contains x.Key)
|> List.partition(fun (x, _) ->
let isNullable = isSchemaNullable x.Value
schemaObjRequired.Contains x.Key && not isNullable)

required @ optional
|> List.map(fun (x, (f, p)) ->
let paramName = niceCamelName p.Name
let isNullable = isSchemaNullable x.Value

let prParam =
if schemaObjRequired.Contains x.Key then
if schemaObjRequired.Contains x.Key && not isNullable then
ProvidedParameter(paramName, f.FieldType)
else
let paramDefaultValue = this.GetDefaultValue f.FieldType
Expand Down
31 changes: 31 additions & 0 deletions tests/SwaggerProvider.ProviderTests/Schemas/v3/nullable-date.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
openapi: 3.0.0
info:
title: Nullable Date Test API
version: 1.0.0
paths:
/test:
get:
operationId: getTest
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/PersonDto'
components:
schemas:
PersonDto:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
birthDate:
type: string
format: date
nullable: true
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<Compile Include="v3\Swagger.I0181.Tests.fs" />
<Compile Include="v3\Swagger.I0219.Tests.fs" />
<Compile Include="v3\Swagger.I0279.Tests.fs" />
<Compile Include="v3\Swagger.NullableDate.Tests.fs" />
<Compile Include="v3\Swashbuckle.ReturnControllers.Tests.fs" />
<Compile Include="v3\Swashbuckle.ReturnTextControllers.Tests.fs" />
<Compile Include="v3\Swashbuckle.UpdateControllers.Tests.fs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
module Swagger.NullableDate.Tests

open SwaggerProvider
open Xunit
open FsUnitTyped
open System.Text.Json
open System.Text.Json.Serialization

[<Literal>]
let Schema = __SOURCE_DIRECTORY__ + "/../Schemas/v3/nullable-date.yaml"

type TestApi = OpenApiClientProvider<Schema>

[<Fact>]
let ``PersonDto should have nullable birthDate property``() =
let personType = typeof<TestApi.PersonDto>
let birthDateProp = personType.GetProperty("BirthDate")
birthDateProp |> shouldNotEqual null

// The property should be Option<DateTimeOffset> (default) or Nullable<DateTimeOffset> (with PreferNullable=true)
let propType = birthDateProp.PropertyType
propType.IsGenericType |> shouldEqual true

let genericTypeDef = propType.GetGenericTypeDefinition()

let hasNullableWrapper =
genericTypeDef = typedefof<Option<_>>
|| genericTypeDef = typedefof<System.Nullable<_>>

hasNullableWrapper |> shouldEqual true

[<Fact>]
let ``PersonDto can deserialize JSON with null birthDate using type provider deserialization``() =
// This JSON is from the issue - a person with null birthDate
let jsonWithNullBirthDate =
"""{
"id": "04a38328-4202-44ef-9f2b-ee85b1cd1a48",
"name": "Test",
"birthDate": null
}"""

// Use the same deserialization code as the type provider (System.Text.Json with JsonFSharpConverter)
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter())

// Deserialize - this should not throw
let person =
JsonSerializer.Deserialize<TestApi.PersonDto>(jsonWithNullBirthDate, options)

// Verify the properties
person.Id |> shouldEqual "04a38328-4202-44ef-9f2b-ee85b1cd1a48"
person.Name |> shouldEqual "Test"
person.BirthDate |> shouldEqual None

[<Fact>]
let ``PersonDto can deserialize JSON with valid birthDate using type provider deserialization``() =
// Test with a valid date value
let jsonWithValidBirthDate =
"""{
"id": "test-id-123",
"name": "John Doe",
"birthDate": "1990-05-15"
}"""

// Use the same deserialization code as the type provider
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter())

// Deserialize
let person =
JsonSerializer.Deserialize<TestApi.PersonDto>(jsonWithValidBirthDate, options)

// Verify the properties
person.Id |> shouldEqual "test-id-123"
person.Name |> shouldEqual "John Doe"

// BirthDate should be Some value
person.BirthDate |> shouldNotEqual None

match person.BirthDate with
| Some date ->
// Verify the date is correct (1990-05-15)
date.Year |> shouldEqual 1990
date.Month |> shouldEqual 5
date.Day |> shouldEqual 15
| None -> failwith "Expected Some date but got None"
Loading