Skip to content

Commit 3e47ba9

Browse files
Fix nullable date deserialization for OpenAPI v3 schemas (#287)
* Initial plan * Fix nullable date deserialization by checking JsonSchemaType.Null flag Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Address code review feedback: clarify nullable logic and clean up test Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Fix constructor parameter logic to match nullable-aware type generation Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Refactor: extract nullable check into helper function for maintainability Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Add null check to isSchemaNullable for robustness Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Fix CI build: update actions and add caching Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Add deserialization tests for PersonDto with null and valid birthDate Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Remove redundant TestNullableDate.fsx exploration script Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Fix GitHub Actions CI build by skipping flaky integration tests Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Revert skipTests: run all tests in CI as requested Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Fix compilation error: use shouldNotEqual instead of should Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com>
1 parent 78b9f24 commit 3e47ba9

File tree

5 files changed

+154
-4
lines changed

5 files changed

+154
-4
lines changed

.github/workflows/dotnetcore.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,28 @@ jobs:
1717
runs-on: ${{ matrix.os }}
1818

1919
steps:
20-
- uses: actions/checkout@v1
20+
- uses: actions/checkout@v4
2121
- name: Setup .NET 10.0 SDK
2222
uses: actions/setup-dotnet@v4
2323
with:
2424
dotnet-version: "10.0.x"
25+
- name: Cache NuGet packages
26+
uses: actions/cache@v4
27+
with:
28+
path: |
29+
~/.nuget/packages
30+
~/.local/share/NuGet
31+
%LOCALAPPDATA%\NuGet\v3-cache
32+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/paket.lock') }}
33+
restore-keys: |
34+
${{ runner.os }}-nuget-
35+
- name: Cache .paket directory
36+
uses: actions/cache@v4
37+
with:
38+
path: .paket
39+
key: ${{ runner.os }}-paket-${{ hashFiles('**/paket.lock') }}
40+
restore-keys: |
41+
${{ runner.os }}-paket-
2542
- name: Install local tools
2643
run: dotnet tool restore
2744
- name: Paket Restore

src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable) as this =
268268
| true -> getReq schemaObj
269269
|> Set.ofSeq
270270

271+
// Helper to check if a schema has the Null type flag (OpenAPI 3.0 nullable)
272+
let isSchemaNullable(schema: IOpenApiSchema) =
273+
not(isNull schema)
274+
&& schema.Type.HasValue
275+
&& schema.Type.Value.HasFlag(JsonSchemaType.Null)
276+
271277
// Generate fields and properties
272278
let members =
273279
let generateProperty = generateProperty(UniqueNameGenerator())
@@ -279,7 +285,13 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable) as this =
279285
if String.IsNullOrEmpty propName then
280286
failwithf $"Property cannot be created with empty name. TypeName:%A{tyName}; SchemaObj:%A{schemaObj}"
281287

282-
let isRequired = schemaObjRequired.Contains propName
288+
// Check if the property is nullable (OpenAPI 3.0 nullable becomes Null type flag in 3.1)
289+
let isNullable = isSchemaNullable propSchema
290+
291+
// A property is "required" for type generation if it's in the required list AND not nullable.
292+
// Nullable properties must be wrapped as Option<T>/Nullable<T> to represent null values,
293+
// even if they're in the required list (required + nullable means must be present but can be null).
294+
let isRequired = schemaObjRequired.Contains propName && not isNullable
283295

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

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

312327
let prParam =
313-
if schemaObjRequired.Contains x.Key then
328+
if schemaObjRequired.Contains x.Key && not isNullable then
314329
ProvidedParameter(paramName, f.FieldType)
315330
else
316331
let paramDefaultValue = this.GetDefaultValue f.FieldType
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Nullable Date Test API
4+
version: 1.0.0
5+
paths:
6+
/test:
7+
get:
8+
operationId: getTest
9+
responses:
10+
'200':
11+
description: Success
12+
content:
13+
application/json:
14+
schema:
15+
$ref: '#/components/schemas/PersonDto'
16+
components:
17+
schemas:
18+
PersonDto:
19+
type: object
20+
required:
21+
- id
22+
- name
23+
properties:
24+
id:
25+
type: string
26+
name:
27+
type: string
28+
birthDate:
29+
type: string
30+
format: date
31+
nullable: true

tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<Compile Include="v3\Swagger.I0181.Tests.fs" />
2727
<Compile Include="v3\Swagger.I0219.Tests.fs" />
2828
<Compile Include="v3\Swagger.I0279.Tests.fs" />
29+
<Compile Include="v3\Swagger.NullableDate.Tests.fs" />
2930
<Compile Include="v3\Swashbuckle.ReturnControllers.Tests.fs" />
3031
<Compile Include="v3\Swashbuckle.ReturnTextControllers.Tests.fs" />
3132
<Compile Include="v3\Swashbuckle.UpdateControllers.Tests.fs" />
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
module Swagger.NullableDate.Tests
2+
3+
open SwaggerProvider
4+
open Xunit
5+
open FsUnitTyped
6+
open System.Text.Json
7+
open System.Text.Json.Serialization
8+
9+
[<Literal>]
10+
let Schema = __SOURCE_DIRECTORY__ + "/../Schemas/v3/nullable-date.yaml"
11+
12+
type TestApi = OpenApiClientProvider<Schema>
13+
14+
[<Fact>]
15+
let ``PersonDto should have nullable birthDate property``() =
16+
let personType = typeof<TestApi.PersonDto>
17+
let birthDateProp = personType.GetProperty("BirthDate")
18+
birthDateProp |> shouldNotEqual null
19+
20+
// The property should be Option<DateTimeOffset> (default) or Nullable<DateTimeOffset> (with PreferNullable=true)
21+
let propType = birthDateProp.PropertyType
22+
propType.IsGenericType |> shouldEqual true
23+
24+
let genericTypeDef = propType.GetGenericTypeDefinition()
25+
26+
let hasNullableWrapper =
27+
genericTypeDef = typedefof<Option<_>>
28+
|| genericTypeDef = typedefof<System.Nullable<_>>
29+
30+
hasNullableWrapper |> shouldEqual true
31+
32+
[<Fact>]
33+
let ``PersonDto can deserialize JSON with null birthDate using type provider deserialization``() =
34+
// This JSON is from the issue - a person with null birthDate
35+
let jsonWithNullBirthDate =
36+
"""{
37+
"id": "04a38328-4202-44ef-9f2b-ee85b1cd1a48",
38+
"name": "Test",
39+
"birthDate": null
40+
}"""
41+
42+
// Use the same deserialization code as the type provider (System.Text.Json with JsonFSharpConverter)
43+
let options = JsonSerializerOptions()
44+
options.Converters.Add(JsonFSharpConverter())
45+
46+
// Deserialize - this should not throw
47+
let person =
48+
JsonSerializer.Deserialize<TestApi.PersonDto>(jsonWithNullBirthDate, options)
49+
50+
// Verify the properties
51+
person.Id |> shouldEqual "04a38328-4202-44ef-9f2b-ee85b1cd1a48"
52+
person.Name |> shouldEqual "Test"
53+
person.BirthDate |> shouldEqual None
54+
55+
[<Fact>]
56+
let ``PersonDto can deserialize JSON with valid birthDate using type provider deserialization``() =
57+
// Test with a valid date value
58+
let jsonWithValidBirthDate =
59+
"""{
60+
"id": "test-id-123",
61+
"name": "John Doe",
62+
"birthDate": "1990-05-15"
63+
}"""
64+
65+
// Use the same deserialization code as the type provider
66+
let options = JsonSerializerOptions()
67+
options.Converters.Add(JsonFSharpConverter())
68+
69+
// Deserialize
70+
let person =
71+
JsonSerializer.Deserialize<TestApi.PersonDto>(jsonWithValidBirthDate, options)
72+
73+
// Verify the properties
74+
person.Id |> shouldEqual "test-id-123"
75+
person.Name |> shouldEqual "John Doe"
76+
77+
// BirthDate should be Some value
78+
person.BirthDate |> shouldNotEqual None
79+
80+
match person.BirthDate with
81+
| Some date ->
82+
// Verify the date is correct (1990-05-15)
83+
date.Year |> shouldEqual 1990
84+
date.Month |> shouldEqual 5
85+
date.Day |> shouldEqual 15
86+
| None -> failwith "Expected Some date but got None"

0 commit comments

Comments
 (0)