Skip to content

Commit f97f91a

Browse files
authored
Merge pull request #2839 from microsoft/fix/true-schema-component
fix: null reference exception for boolean component schemas
2 parents 78e17ca + a54c0fd commit f97f91a

10 files changed

Lines changed: 156 additions & 4 deletions

File tree

src/Microsoft.OpenApi/Models/OpenApiSchema.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ namespace Microsoft.OpenApi
1111
{
1212
/// <summary>
1313
/// The Schema Object allows the definition of input and output data types.
14+
///
15+
/// For OpenAPI 3.1+ (JSON Schema 2020-12), this class supports boolean schemas:
16+
/// - Deserialization: The boolean literal <c>true</c> deserializes to an empty schema (allows any value).
17+
/// The boolean literal <c>false</c> deserializes to a schema with <see cref="Not"/> set to an empty schema (disallows any value).
18+
/// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty <see cref="OpenApiSchema"/>
19+
/// for "true" behavior, or create a schema with only <see cref="Not"/> set to an empty schema for "false" behavior.
1420
/// </summary>
1521
public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer
1622
{

src/Microsoft.OpenApi/Reader/ParseNodes/ValueNode.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ public override string GetScalarValue()
2828
?? throw new OpenApiReaderException($"Expected a value at {Context.GetLocation()}.");
2929
}
3030

31+
/// <summary>
32+
/// Attempts to get the underlying value directly as the specified type without string conversion.
33+
/// </summary>
34+
/// <typeparam name="T">The type to retrieve the value as.</typeparam>
35+
/// <param name="value">The retrieved value if successful.</param>
36+
/// <returns>True if the value was successfully converted to the specified type; otherwise, false.</returns>
37+
public bool TryGetValue<T>(out T? value)
38+
{
39+
return _node.TryGetValue(out value);
40+
}
41+
3142
/// <summary>
3243
/// Create a <see cref="JsonNode"/>
3344
/// </summary>

src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,19 @@ internal static partial class OpenApiV31Deserializer
362362

363363
public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocument)
364364
{
365+
// Handle boolean schemas (true/false) for JSON Schema 2020-12 compatibility
366+
if (node is ValueNode valueNode && valueNode.TryGetValue<bool>(out var boolValue))
367+
{
368+
var boolSchema = new OpenApiSchema();
369+
if (!boolValue)
370+
{
371+
// false schema: represents "not valid" -> convert to "not: {}"
372+
boolSchema.Not = new OpenApiSchema();
373+
}
374+
// true schema: represents "always valid" -> return empty schema (default)
375+
return boolSchema;
376+
}
377+
365378
var mapNode = node.CheckMapNode(OpenApiConstants.Schema);
366379

367380
var pointer = mapNode.GetReferencePointer();

src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,19 @@ internal static partial class OpenApiV32Deserializer
362362

363363
public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocument)
364364
{
365+
// Handle boolean schemas (true/false) for JSON Schema 2020-12 compatibility
366+
if (node is ValueNode valueNode && valueNode.TryGetValue<bool>(out var boolValue))
367+
{
368+
var boolSchema = new OpenApiSchema();
369+
if (!boolValue)
370+
{
371+
// false schema: represents "not valid" -> convert to "not: {}"
372+
boolSchema.Not = new OpenApiSchema();
373+
}
374+
// true schema: represents "always valid" -> return empty schema (default)
375+
return boolSchema;
376+
}
377+
365378
var mapNode = node.CheckMapNode(OpenApiConstants.Schema);
366379

367380
var pointer = mapNode.GetReferencePointer();

src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public void RegisterComponents(OpenApiDocument document)
9393
{
9494
foreach (var item in document.Components.Schemas)
9595
{
96+
if (item.Value == null) continue;
9697
location = item.Value.Id ?? baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key;
9798
RegisterComponent(location, item.Value);
9899
}
@@ -103,6 +104,7 @@ public void RegisterComponents(OpenApiDocument document)
103104
{
104105
foreach (var item in document.Components.Parameters)
105106
{
107+
if (item.Value == null) continue;
106108
location = baseUri + ReferenceType.Parameter.GetDisplayName() + ComponentSegmentSeparator + item.Key;
107109
RegisterComponent(location, item.Value);
108110
}
@@ -113,6 +115,7 @@ public void RegisterComponents(OpenApiDocument document)
113115
{
114116
foreach (var item in document.Components.Responses)
115117
{
118+
if (item.Value == null) continue;
116119
location = baseUri + ReferenceType.Response.GetDisplayName() + ComponentSegmentSeparator + item.Key;
117120
RegisterComponent(location, item.Value);
118121
}
@@ -123,6 +126,7 @@ public void RegisterComponents(OpenApiDocument document)
123126
{
124127
foreach (var item in document.Components.RequestBodies)
125128
{
129+
if (item.Value == null) continue;
126130
location = baseUri + ReferenceType.RequestBody.GetDisplayName() + ComponentSegmentSeparator + item.Key;
127131
RegisterComponent(location, item.Value);
128132
}
@@ -133,6 +137,7 @@ public void RegisterComponents(OpenApiDocument document)
133137
{
134138
foreach (var item in document.Components.Links)
135139
{
140+
if (item.Value == null) continue;
136141
location = baseUri + ReferenceType.Link.GetDisplayName() + ComponentSegmentSeparator + item.Key;
137142
RegisterComponent(location, item.Value);
138143
}
@@ -143,6 +148,7 @@ public void RegisterComponents(OpenApiDocument document)
143148
{
144149
foreach (var item in document.Components.Callbacks)
145150
{
151+
if (item.Value == null) continue;
146152
location = baseUri + ReferenceType.Callback.GetDisplayName() + ComponentSegmentSeparator + item.Key;
147153
RegisterComponent(location, item.Value);
148154
}
@@ -153,6 +159,7 @@ public void RegisterComponents(OpenApiDocument document)
153159
{
154160
foreach (var item in document.Components.PathItems)
155161
{
162+
if (item.Value == null) continue;
156163
location = baseUri + ReferenceType.PathItem.GetDisplayName() + ComponentSegmentSeparator + item.Key;
157164
RegisterComponent(location, item.Value);
158165
}
@@ -163,6 +170,7 @@ public void RegisterComponents(OpenApiDocument document)
163170
{
164171
foreach (var item in document.Components.Examples)
165172
{
173+
if (item.Value == null) continue;
166174
location = baseUri + ReferenceType.Example.GetDisplayName() + ComponentSegmentSeparator + item.Key;
167175
RegisterComponent(location, item.Value);
168176
}
@@ -173,6 +181,7 @@ public void RegisterComponents(OpenApiDocument document)
173181
{
174182
foreach (var item in document.Components.Headers)
175183
{
184+
if (item.Value == null) continue;
176185
location = baseUri + ReferenceType.Header.GetDisplayName() + ComponentSegmentSeparator + item.Key;
177186
RegisterComponent(location, item.Value);
178187
}
@@ -183,6 +192,7 @@ public void RegisterComponents(OpenApiDocument document)
183192
{
184193
foreach (var item in document.Components.SecuritySchemes)
185194
{
195+
if (item.Value == null) continue;
186196
location = baseUri + ReferenceType.SecurityScheme.GetDisplayName() + ComponentSegmentSeparator + item.Key;
187197
RegisterComponent(location, item.Value);
188198
}
@@ -193,6 +203,7 @@ public void RegisterComponents(OpenApiDocument document)
193203
{
194204
foreach (var item in document.Components.MediaTypes)
195205
{
206+
if (item.Value == null) continue;
196207
location = baseUri + ReferenceType.MediaType.GetDisplayName() + ComponentSegmentSeparator + item.Key;
197208
RegisterComponent(location, item.Value);
198209
}

test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,5 +630,23 @@ public async Task ParseDocumentWithSelfExtensionWorks()
630630
Assert.Empty(result.Diagnostic.Errors);
631631
Assert.Empty(result.Diagnostic.Warnings);
632632
}
633+
634+
[Fact]
635+
public void LoadDocumentWithBooleanSchemaShouldNotThrowNullReferenceException()
636+
{
637+
// Arrange - OpenAPI 3.1 with a boolean schema in components/schemas (spec-valid per JSON Schema 2020-12)
638+
var bytes = "{\"openapi\":\"3.1.0\",\"components\":{\"schemas\":{\"X\":true}}}"u8.ToArray();
639+
using var ms = new MemoryStream(bytes);
640+
641+
// Act & Assert - should not throw NullReferenceException
642+
var exception = Record.Exception(() => OpenApiDocument.Load(ms, format: null, new OpenApiReaderSettings()));
643+
644+
// The parser should handle the boolean schema gracefully
645+
// Either accepting it or surfacing a structured diagnostic, but not throwing NullReferenceException
646+
if (exception != null)
647+
{
648+
Assert.IsNotType<NullReferenceException>(exception);
649+
}
650+
}
633651
}
634652
}

test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,5 +844,34 @@ public void ParseSchemaWithoutUnevaluatedPropertiesDefaultsToTrue()
844844
Assert.Equivalent(expected, actual);
845845
Assert.True(actual.UnevaluatedProperties); // Explicitly verify the default
846846
}
847+
848+
[Theory]
849+
[InlineData("{}")]
850+
[InlineData("true")]
851+
public void DeserializeTrueSchemaParsesAsEmptySchema(string schemaSource)
852+
{
853+
// Arrange & Act
854+
var schema = OpenApiModelFactory.Parse<OpenApiSchema>(schemaSource, OpenApiSpecVersion.OpenApi3_1, new(), out _, OpenApiConstants.Json);
855+
856+
// Assert - schema should deserialize without error
857+
Assert.NotNull(schema);
858+
}
859+
860+
[Fact]
861+
public void DeserializeFalseSchemaParsesAsNotEmptySchema()
862+
{
863+
// Arrange
864+
var schemaSource = "false";
865+
866+
// Act
867+
var schema = OpenApiModelFactory.Parse<OpenApiSchema>(schemaSource, OpenApiSpecVersion.OpenApi3_1, new(), out _, OpenApiConstants.Json);
868+
869+
// Assert - false schema should deserialize to not: {}
870+
Assert.NotNull(schema);
871+
Assert.NotNull(schema.Not);
872+
Assert.Empty(schema.Not.AnyOf ?? []);
873+
Assert.Empty(schema.Not.AllOf ?? []);
874+
Assert.Empty(schema.Not.OneOf ?? []);
875+
}
847876
}
848877
}

test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,24 @@ public async Task ParseDocumentWithSelfPropertyFromJsonWorks()
645645
Assert.Empty(result.Diagnostic.Errors);
646646
Assert.Empty(result.Diagnostic.Warnings);
647647
}
648+
649+
[Fact]
650+
public void LoadDocumentWithBooleanSchemaShouldNotThrowNullReferenceException()
651+
{
652+
// Arrange - OpenAPI 3.2 with a boolean schema in components/schemas (spec-valid per JSON Schema 2020-12)
653+
var bytes = "{\"openapi\":\"3.2.0\",\"components\":{\"schemas\":{\"X\":true}}}"u8.ToArray();
654+
using var ms = new MemoryStream(bytes);
655+
656+
// Act & Assert - should not throw NullReferenceException
657+
var exception = Record.Exception(() => OpenApiDocument.Load(ms, format: null, new OpenApiReaderSettings()));
658+
659+
// The parser should handle the boolean schema gracefully
660+
// Either accepting it or surfacing a structured diagnostic, but not throwing NullReferenceException
661+
if (exception != null)
662+
{
663+
Assert.IsNotType<NullReferenceException>(exception);
664+
}
665+
}
648666
}
649667
}
650668

test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

44
using System.Collections.Generic;
@@ -695,6 +695,35 @@ public void ParseSchemaWithUnevaluatedPropertiesComplexSchema()
695695
// Assert
696696
Assert.Equivalent(expected, actual);
697697
}
698+
699+
[Theory]
700+
[InlineData("{}")]
701+
[InlineData("true")]
702+
public void DeserializeTrueSchemaParsesAsEmptySchema(string schemaSource)
703+
{
704+
// Arrange & Act
705+
var schema = OpenApiModelFactory.Parse<OpenApiSchema>(schemaSource, OpenApiSpecVersion.OpenApi3_2, new(), out _, OpenApiConstants.Json);
706+
707+
// Assert - schema should deserialize without error
708+
Assert.NotNull(schema);
709+
}
710+
711+
[Fact]
712+
public void DeserializeFalseSchemaParsesAsNotEmptySchema()
713+
{
714+
// Arrange
715+
var schemaSource = "false";
716+
717+
// Act
718+
var schema = OpenApiModelFactory.Parse<OpenApiSchema>(schemaSource, OpenApiSpecVersion.OpenApi3_2, new(), out _, OpenApiConstants.Json);
719+
720+
// Assert - false schema should deserialize to not: {}
721+
Assert.NotNull(schema);
722+
Assert.NotNull(schema.Not);
723+
Assert.Empty(schema.Not.AnyOf ?? []);
724+
Assert.Empty(schema.Not.AllOf ?? []);
725+
Assert.Empty(schema.Not.OneOf ?? []);
726+
}
698727
}
699728
}
700729

test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,18 @@ public class OpenApiSchemaTests
204204
}
205205
};
206206

207-
[Fact]
208-
public async Task SerializeBasicSchemaAsV3JsonWorks()
207+
[Theory]
208+
[InlineData(OpenApiSpecVersion.OpenApi2_0)]
209+
[InlineData(OpenApiSpecVersion.OpenApi3_0)]
210+
[InlineData(OpenApiSpecVersion.OpenApi3_1)]
211+
[InlineData(OpenApiSpecVersion.OpenApi3_2)]
212+
public async Task SerializeBasicSchemaAsJsonWorks(OpenApiSpecVersion version)
209213
{
210214
// Arrange
211215
var expected = @"{ }";
212216

213217
// Act
214-
var actual = await BasicSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
218+
var actual = await BasicSchema.SerializeAsJsonAsync(version);
215219

216220
// Assert
217221
actual = actual.MakeLineBreaksEnvironmentNeutral();

0 commit comments

Comments
 (0)