diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 7c19a95..7685e5f 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -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 diff --git a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs index 573d05f..a2acfde 100644 --- a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs @@ -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()) @@ -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/Nullable 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 @@ -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 diff --git a/tests/SwaggerProvider.ProviderTests/Schemas/v3/nullable-date.yaml b/tests/SwaggerProvider.ProviderTests/Schemas/v3/nullable-date.yaml new file mode 100644 index 0000000..8a010a5 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/Schemas/v3/nullable-date.yaml @@ -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 diff --git a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj index d221006..b33f456 100644 --- a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj +++ b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj @@ -26,6 +26,7 @@ + diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swagger.NullableDate.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swagger.NullableDate.Tests.fs new file mode 100644 index 0000000..3d16a48 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/v3/Swagger.NullableDate.Tests.fs @@ -0,0 +1,86 @@ +module Swagger.NullableDate.Tests + +open SwaggerProvider +open Xunit +open FsUnitTyped +open System.Text.Json +open System.Text.Json.Serialization + +[] +let Schema = __SOURCE_DIRECTORY__ + "/../Schemas/v3/nullable-date.yaml" + +type TestApi = OpenApiClientProvider + +[] +let ``PersonDto should have nullable birthDate property``() = + let personType = typeof + let birthDateProp = personType.GetProperty("BirthDate") + birthDateProp |> shouldNotEqual null + + // The property should be Option (default) or Nullable (with PreferNullable=true) + let propType = birthDateProp.PropertyType + propType.IsGenericType |> shouldEqual true + + let genericTypeDef = propType.GetGenericTypeDefinition() + + let hasNullableWrapper = + genericTypeDef = typedefof> + || genericTypeDef = typedefof> + + hasNullableWrapper |> shouldEqual true + +[] +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(jsonWithNullBirthDate, options) + + // Verify the properties + person.Id |> shouldEqual "04a38328-4202-44ef-9f2b-ee85b1cd1a48" + person.Name |> shouldEqual "Test" + person.BirthDate |> shouldEqual None + +[] +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(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"