Skip to content

Commit caca191

Browse files
authored
Merge pull request #775 from microsoft/feat/optional-to-v2
feat: allow optional body parameter (#773)
2 parents 3c78907 + 20bbbff commit caca191

35 files changed

+993
-866
lines changed

.github/workflows/auto-merge-dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
steps:
2020
- name: Dependabot metadata
2121
id: metadata
22-
uses: dependabot/fetch-metadata@v2.4.0
22+
uses: dependabot/fetch-metadata@v2.5.0
2323
with:
2424
github-token: "${{ secrets.GITHUB_TOKEN }}"
2525

redocly.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
extends:
2+
- recommended
3+
4+
rules:
5+
operation-4xx-response: off
6+
nullable-type-sibling: off
7+
no-server-example.com: off
8+
no-server-trailing-slash: off
9+
no-unused-components: off
10+
security-defined: off
11+
info-license-url: off
12+
info-license: off
13+
no-empty-servers: off
14+
operation-summary: off
15+
tag-description: off

src/Microsoft.OpenApi.OData.Reader/EdmModelOpenApiExtensions.cs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
44
// ------------------------------------------------------------
55

6+
using System;
67
using System.Collections.Generic;
8+
using System.Linq;
79
using Microsoft.OData.Edm;
810
using Microsoft.OData.Edm.Validation;
911
using Microsoft.OpenApi.OData.Common;
1012
using Microsoft.OpenApi.OData.Edm;
1113
using Microsoft.OpenApi.OData.Generator;
14+
using Microsoft.OpenApi.OData.Vocabulary.Core;
1215

1316
namespace Microsoft.OpenApi.OData
1417
{
@@ -54,5 +57,162 @@ public static OpenApiDocument ConvertToOpenApi(this IEdmModel model, OpenApiConv
5457
ODataContext context = new(model, settings);
5558
return context.CreateDocument();
5659
}
60+
61+
/// <summary>
62+
/// Determines if a request body should be required for an OData action.
63+
/// </summary>
64+
/// <param name="action">The EDM action.</param>
65+
/// <returns>True if the request body should be required, false otherwise.</returns>
66+
public static bool ShouldRequestBodyBeRequired(this IEdmAction action)
67+
{
68+
if (action == null)
69+
{
70+
return true; // Safe default
71+
}
72+
73+
// Get non-binding parameters
74+
var parameters = action.IsBound
75+
? action.Parameters.Skip(1)
76+
: action.Parameters;
77+
78+
// If no parameters, body is already null (existing behavior handles this)
79+
if (!parameters.Any())
80+
{
81+
return true; // Won't matter since body will be null
82+
}
83+
84+
// Check if any parameter is non-nullable and not optional
85+
return parameters.Any(p => !p.Type.IsNullable && p is not IEdmOptionalParameter);
86+
}
87+
88+
/// <summary>
89+
/// Determines if a request body should be required for an entity or complex type.
90+
/// </summary>
91+
/// <param name="structuredType">The EDM structured type.</param>
92+
/// <param name="isUpdateOperation">Whether this is an update operation (excludes key properties).</param>
93+
/// <param name="model">The EDM model for additional context.</param>
94+
/// <returns>True if the request body should be required, false otherwise.</returns>
95+
public static bool ShouldRequestBodyBeRequired(
96+
this IEdmStructuredType structuredType,
97+
bool isUpdateOperation,
98+
IEdmModel? model = null)
99+
{
100+
if (structuredType == null)
101+
{
102+
return true; // Safe default
103+
}
104+
105+
return !AreAllPropertiesOptional(structuredType, isUpdateOperation, model);
106+
}
107+
108+
/// <summary>
109+
/// Checks if all properties in a structured type are optional.
110+
/// </summary>
111+
/// <param name="structuredType">The EDM structured type.</param>
112+
/// <param name="excludeKeyProperties">Whether to exclude key properties from analysis (for update operations).</param>
113+
/// <param name="model">The EDM model for additional context.</param>
114+
/// <returns>True if all properties are optional, false if any are required.</returns>
115+
private static bool AreAllPropertiesOptional(
116+
IEdmStructuredType structuredType,
117+
bool excludeKeyProperties,
118+
IEdmModel? model = null)
119+
{
120+
if (structuredType == null)
121+
{
122+
return false;
123+
}
124+
125+
// Collect all properties including inherited ones
126+
var allProperties = new List<IEdmProperty>();
127+
128+
// Get properties from current type and all base types
129+
IEdmStructuredType currentType = structuredType;
130+
while (currentType != null)
131+
{
132+
allProperties.AddRange(currentType.DeclaredStructuralProperties());
133+
allProperties.AddRange(currentType.DeclaredNavigationProperties());
134+
currentType = currentType.BaseType;
135+
}
136+
137+
// If no properties, consider optional (empty body)
138+
if (allProperties.Count == 0)
139+
{
140+
return true;
141+
}
142+
143+
// Get key property names if we need to exclude them
144+
HashSet<string>? keyNames = null;
145+
if (excludeKeyProperties && structuredType is IEdmEntityType entityType)
146+
{
147+
keyNames = new HashSet<string>(entityType.Key().Select(static k => k.Name), StringComparer.Ordinal);
148+
}
149+
150+
// Check if ALL remaining properties are optional
151+
foreach (var property in allProperties)
152+
{
153+
// Skip key properties if requested
154+
if (keyNames != null && keyNames.Contains(property.Name))
155+
{
156+
continue;
157+
}
158+
159+
// Skip computed properties (read-only)
160+
if (model != null && property is IEdmStructuralProperty &&
161+
(model.GetBoolean(property, CoreConstants.Computed) ?? false))
162+
{
163+
continue;
164+
}
165+
166+
// If this property is required, the body must be required
167+
if (!property.IsPropertyOptional())
168+
{
169+
return false;
170+
}
171+
}
172+
173+
return true;
174+
}
175+
176+
/// <summary>
177+
/// Checks if an individual property is optional.
178+
/// </summary>
179+
/// <param name="property">The EDM property.</param>
180+
/// <returns>True if the property is optional, false if required.</returns>
181+
private static bool IsPropertyOptional(this IEdmProperty property)
182+
{
183+
if (property == null)
184+
{
185+
return false;
186+
}
187+
188+
// Structural properties (primitive, enum, complex)
189+
if (property is IEdmStructuralProperty structuralProp)
190+
{
191+
// Has default value = optional
192+
if (!string.IsNullOrEmpty(structuralProp.DefaultValueString))
193+
{
194+
return true;
195+
}
196+
197+
// Type is nullable = optional
198+
if (structuralProp.Type.IsNullable)
199+
{
200+
return true;
201+
}
202+
203+
// Otherwise required
204+
return false;
205+
}
206+
207+
// Navigation properties
208+
if (property is IEdmNavigationProperty navProp)
209+
{
210+
// Navigation properties are optional if nullable
211+
return navProp.Type.IsNullable;
212+
}
213+
214+
// Unknown property type, treat as required (safe default)
215+
return false;
216+
}
57217
}
58218
}

src/Microsoft.OpenApi.OData.Reader/Generator/OpenApiRequestBodyGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ internal static class OpenApiRequestBodyGenerator
7777
OpenApiRequestBody requestBody = new OpenApiRequestBody
7878
{
7979
Description = "Action parameters",
80-
Required = true,
80+
Required = action.ShouldRequestBodyBeRequired(),
8181
Content = new Dictionary<string, OpenApiMediaType>()
8282
};
8383

src/Microsoft.OpenApi.OData.Reader/Operation/ComplexPropertyPostOperationHandler.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,12 @@ protected override void SetParameters(OpenApiOperation operation)
8484
}
8585
/// <inheritdoc/>
8686
protected override void SetRequestBody(OpenApiOperation operation)
87-
{
87+
{
8888
operation.RequestBody = new OpenApiRequestBody
8989
{
90-
Required = true,
90+
Required = ComplexPropertySegment?.ComplexType?.ShouldRequestBodyBeRequired(
91+
isUpdateOperation: false,
92+
Context?.Model) ?? true,
9193
Description = "New property values",
9294
Content = new Dictionary<string, OpenApiMediaType>
9395
{

src/Microsoft.OpenApi.OData.Reader/Operation/ComplexPropertyUpdateOperationHandler.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ protected override void SetRequestBody(OpenApiOperation operation)
6363
{
6464
operation.RequestBody = new OpenApiRequestBody
6565
{
66-
Required = true,
66+
Required = ComplexPropertySegment?.ComplexType?.ShouldRequestBodyBeRequired(
67+
isUpdateOperation: true,
68+
Context?.Model) ?? true,
6769
Description = "New property values",
6870
Content = new Dictionary<string, OpenApiMediaType>
6971
{

src/Microsoft.OpenApi.OData.Reader/Operation/EntitySetPostOperationHandler.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ protected override void SetBasicInfo(OpenApiOperation operation)
6969
protected override void SetRequestBody(OpenApiOperation operation)
7070
{
7171
// The requestBody field contains a Request Body Object for the request body
72-
// that references the schema of the entity sets entity type in the global schemas.
72+
// that references the schema of the entity set's entity type in the global schemas.
7373
operation.RequestBody = new OpenApiRequestBody
7474
{
75-
Required = true,
75+
Required = EntitySet?.EntityType?.ShouldRequestBodyBeRequired(
76+
isUpdateOperation: false,
77+
Context?.Model) ?? true,
7678
Description = "New entity",
7779
Content = GetContentDescription()
7880
};

src/Microsoft.OpenApi.OData.Reader/Operation/EntityUpdateOperationHandler.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ protected override void SetRequestBody(OpenApiOperation operation)
7777
{
7878
operation.RequestBody = new OpenApiRequestBody
7979
{
80-
Required = true,
80+
Required = EntitySet?.EntityType?.ShouldRequestBodyBeRequired(
81+
isUpdateOperation: true,
82+
Context?.Model) ?? true,
8183
Description = "New property values",
8284
Content = GetContent()
8385
};

src/Microsoft.OpenApi.OData.Reader/Operation/NavigationPropertyPostOperationHandler.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ protected override void SetRequestBody(OpenApiOperation operation)
6767

6868
operation.RequestBody = new OpenApiRequestBody
6969
{
70-
Required = true,
70+
Required = NavigationProperty?.ToEntityType()?.ShouldRequestBodyBeRequired(
71+
isUpdateOperation: false,
72+
Context?.Model) ?? true,
7173
Description = "New navigation property",
7274
Content = GetContent(schema, _insertRestriction?.RequestContentTypes)
7375
};

src/Microsoft.OpenApi.OData.Reader/Operation/NavigationPropertyUpdateOperationHandler.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ protected override void SetBasicInfo(OpenApiOperation operation)
5858
/// <inheritdoc/>
5959
protected override void SetRequestBody(OpenApiOperation operation)
6060
{
61-
var schema = Context is { Settings.EnableDerivedTypesReferencesForRequestBody: true } ?
61+
var schema = Context is { Settings.EnableDerivedTypesReferencesForRequestBody: true } ?
6262
EdmModelHelper.GetDerivedTypesReferenceSchema(NavigationProperty.ToEntityType(), Context.Model, _document) :
6363
null;
6464

6565
operation.RequestBody = new OpenApiRequestBody
6666
{
67-
Required = true,
67+
Required = NavigationProperty?.ToEntityType()?.ShouldRequestBodyBeRequired(
68+
isUpdateOperation: true,
69+
Context?.Model) ?? true,
6870
Description = "New navigation property values",
6971
Content = GetContent(schema, _updateRestriction?.RequestContentTypes)
7072
};

0 commit comments

Comments
 (0)