diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index b9d453d2a..fb99868ee 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -9,6 +9,10 @@ jobs:
ci:
name: Continuous Integration
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ code-quality: write
+ pull-requests: read
env:
ARTIFACTS_FOLDER: ${{ github.workspace }}/Artifacts
GITHUB_RUN_NUMBER: ${{ github.run_number }}
@@ -27,6 +31,7 @@ jobs:
id: checkout_repo
uses: actions/checkout@v6
with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
@@ -40,7 +45,36 @@ jobs:
id: run_unit_tests
shell: pwsh
run: |
- dotnet test Microsoft.OpenApi.slnx -c Release -v n
+ dotnet test Microsoft.OpenApi.slnx -c Release --no-build -v n --collect:"XPlat Code Coverage"
+
+ - name: Install report generator
+ shell: pwsh
+ run: |
+ dotnet tool install --global dotnet-reportgenerator-globaltool
+
+ - name: Generate coverage report
+ shell: pwsh
+ run: |
+ reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./reports/coverage -reporttypes:"Html;MarkdownSummaryGithub;Cobertura"
+
+ - name: Add coverage to job summary
+ shell: bash
+ run: |
+ cat ./reports/coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload coverage report
+ if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') || (github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch)
+ uses: actions/upload-code-coverage@v1
+ with:
+ file: ./reports/coverage/Cobertura.xml
+ language: CSharp
+ label: code-coverage/dotnet
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage
+ path: reports/coverage
validate-trimming:
name: Validate Project for Trimming
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 7cc8e7abd..1153ec074 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "3.5.4"
+ ".": "3.5.5"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 672d1121f..a72932424 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [3.5.5](https://github.com/microsoft/OpenAPI.NET/compare/v3.5.4...v3.5.5) (2026-05-28)
+
+
+### Bug Fixes
+
+* **reader:** preserve Null flag when nullable appears before type in V3.0/V3.1/V3.2 deserializers ([2b9d7f4](https://github.com/microsoft/OpenAPI.NET/commit/2b9d7f46bfcef3e2d5da3f73fd4bf83be677c79a))
+
## [3.5.4](https://github.com/microsoft/OpenAPI.NET/compare/v3.5.3...v3.5.4) (2026-05-26)
diff --git a/Directory.Build.props b/Directory.Build.props
index 46e756a9e..8339d1656 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,7 +12,7 @@
https://github.com/Microsoft/OpenAPI.NET
© Microsoft Corporation. All rights reserved.
OpenAPI .NET
- 3.5.4
+ 3.5.5
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index 39c32c979..296626a93 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -198,14 +198,17 @@ internal static partial class OpenApiV31Deserializer
"type",
(o, n, doc) =>
{
+ // Preserve any Null flag set by a preceding "nullable: true" handler
+ var preserveNull = o.Type.HasValue && o.Type.Value.HasFlag(JsonSchemaType.Null);
if (n is ValueNode)
{
- o.Type = n.GetScalarValue()?.ToJsonSchemaType();
+ var parsedType = n.GetScalarValue()?.ToJsonSchemaType();
+ o.Type = preserveNull ? parsedType | JsonSchemaType.Null : parsedType;
}
else
{
var list = n.CreateSimpleList((n2, p) => n2.GetScalarValue(), doc);
- JsonSchemaType combinedType = 0;
+ JsonSchemaType combinedType = preserveNull ? JsonSchemaType.Null : 0;
foreach(var type in list.Where(static t => t is not null).Select(static t => t!.ToJsonSchemaType()))
{
combinedType |= type;
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index c18c48462..f0df47b99 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -198,14 +198,17 @@ internal static partial class OpenApiV32Deserializer
"type",
(o, n, doc) =>
{
+ // Preserve any Null flag set by a preceding "nullable: true" handler
+ var preserveNull = o.Type.HasValue && o.Type.Value.HasFlag(JsonSchemaType.Null);
if (n is ValueNode)
{
- o.Type = n.GetScalarValue()?.ToJsonSchemaType();
+ var parsedType = n.GetScalarValue()?.ToJsonSchemaType();
+ o.Type = preserveNull ? parsedType | JsonSchemaType.Null : parsedType;
}
else
{
var list = n.CreateSimpleList((n2, p) => n2.GetScalarValue(), doc);
- JsonSchemaType combinedType = 0;
+ JsonSchemaType combinedType = preserveNull ? JsonSchemaType.Null : 0;
foreach(var type in list.Where(static t => t is not null).Select(static t => t!.ToJsonSchemaType()))
{
combinedType |= type;
diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj
index d0766cb56..8e30f7f56 100644
--- a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj
+++ b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj
index d8ea9ff74..2c37b7724 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj
+++ b/test/Microsoft.OpenApi.Readers.Tests/Microsoft.OpenApi.Readers.Tests.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs
index b65b2d8c3..ec66dcbb9 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs
@@ -132,6 +132,18 @@ public void ParseSchemaWithTypeArrayWorks()
Assert.Equivalent(expected, actual);
}
+ [Theory]
+ [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")]
+ [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")]
+ public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson)
+ {
+ // Act
+ var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_1, new(), out _, "json", SettingsFixture.ReaderSettings);
+
+ // Assert
+ Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type);
+ }
+
[Fact]
public void TestSchemaCopyConstructorWithTypeArrayWorks()
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs
index 7f91b0327..621cd156c 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs
@@ -131,6 +131,18 @@ public void ParseSchemaWithTypeArrayWorks()
Assert.Equivalent(expected, actual);
}
+ [Theory]
+ [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")]
+ [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")]
+ public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson)
+ {
+ // Act
+ var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_2, new(), out _, "json", SettingsFixture.ReaderSettings);
+
+ // Assert
+ Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type);
+ }
+
[Fact]
public void TestSchemaCopyConstructorWithTypeArrayWorks()
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs
index e16e40594..8241e55ed 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs
@@ -133,6 +133,18 @@ public void ParsePathFragmentShouldSucceed()
}, openApiAny);
}
+ [Theory]
+ [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")]
+ [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")]
+ public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson)
+ {
+ // Act
+ var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_0, new(), out _, "json", SettingsFixture.ReaderSettings);
+
+ // Assert
+ Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type);
+ }
+
[Fact]
public void ParseDictionarySchemaShouldSucceed()
{
diff --git a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj
index 427c84eb1..ba1164688 100644
--- a/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj
+++ b/test/Microsoft.OpenApi.Tests/Microsoft.OpenApi.Tests.csproj
@@ -11,7 +11,7 @@
-
+