Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
string url = string.IsNullOrEmpty(runtimeBaseRoute) ? restEndpointPath : runtimeBaseRoute + "/" + restEndpointPath;
OpenApiComponents components = new()
{
Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName)
Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, runtimeConfig.IsRequestBodyStrict)
};

// Collect all entity tags and their descriptions for the top-level tags array
Expand Down Expand Up @@ -976,9 +976,14 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch
/// 2) {EntityName}_NoAutoPK -> No auto-generated primary keys present in schema, used for POST requests where PK is not autogenerated and GET (all).
/// 3) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all).
/// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity.
/// When isRequestBodyStrict is false, request body schemas will have additionalProperties set to true
/// to indicate that extra fields are allowed and will be ignored.
/// </summary>
/// <param name="entities">Runtime entities from configuration.</param>
/// <param name="defaultDataSourceName">Default data source name.</param>
/// <param name="isRequestBodyStrict">Whether extra fields are rejected in request bodies.</param>
/// <returns>Collection of schemas for entities defined in the runtime configuration.</returns>
private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName)
private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName, bool isRequestBodyStrict)
{
Dictionary<string, OpenApiSchema> schemas = new();
// for rest scenario we need the default datasource name.
Expand Down Expand Up @@ -1006,18 +1011,20 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
if (dbObject.SourceType is EntitySourceType.StoredProcedure)
{
// Request body schema whose properties map to stored procedure parameters
// When isRequestBodyStrict is false, additionalProperties is set to true to indicate extra fields are allowed.
DatabaseStoredProcedure spObject = (DatabaseStoredProcedure)dbObject;
schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters));
schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters, allowAdditionalProperties: !isRequestBodyStrict));

// Response body schema whose properties map to the stored procedure's first result set columns
// as described by sys.dm_exec_describe_first_result_set.
schemas.Add(entityName + SP_RESPONSE_SUFFIX, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities));
schemas.Add(entityName + SP_RESPONSE_SUFFIX, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, allowAdditionalProperties: false));
}
else
{
// Create component schema for FULL entity with all primary key columns (included auto-generated)
// which will typically represent the response body of a request or a stored procedure's request body.
schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities));
// When isRequestBodyStrict is false, additionalProperties is set to true to indicate extra fields are allowed.
schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, allowAdditionalProperties: !isRequestBodyStrict));

// Create an entity's request body component schema excluding autogenerated primary keys.
// A POST request requires any non-autogenerated primary key references to be in the request body.
Expand All @@ -1037,7 +1044,8 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
}
}

schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities));
// When isRequestBodyStrict is false, additionalProperties is set to true to indicate extra fields are allowed.
schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, allowAdditionalProperties: !isRequestBodyStrict));

// Create an entity's request body component schema excluding all primary keys
// by removing the tracked non-autogenerated primary key column names and removing them from
Expand All @@ -1053,7 +1061,8 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
}
}

schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities));
// When isRequestBodyStrict is false, additionalProperties is set to true to indicate extra fields are allowed.
schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, allowAdditionalProperties: !isRequestBodyStrict));
}
}

Expand All @@ -1066,10 +1075,10 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
/// Additionally, the property typeMetadata is sourced by converting the stored procedure
/// parameter's SystemType to JsonDataType.
/// </summary>
/// </summary>
/// <param name="fields">Collection of stored procedure parameter metadata.</param>
/// <param name="allowAdditionalProperties">When true, sets additionalProperties to true indicating extra fields are allowed.</param>
/// <returns>OpenApiSchema object representing a stored procedure's request body.</returns>
private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary<string, ParameterDefinition> fields)
private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary<string, ParameterDefinition> fields, bool allowAdditionalProperties)
{
Dictionary<string, OpenApiSchema> properties = new();
HashSet<string> required = new();
Expand Down Expand Up @@ -1097,7 +1106,8 @@ private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary<string, P
{
Type = SCHEMA_OBJECT_TYPE,
Properties = properties,
Required = required
Required = required,
AdditionalPropertiesAllowed = allowAdditionalProperties
};

return schema;
Expand All @@ -1115,10 +1125,11 @@ private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary<string, P
/// <param name="fields">List of mapped (alias) field names.</param>
/// <param name="metadataProvider">Metadata provider for database objects.</param>
/// <param name="entities">Runtime entities from configuration.</param>
/// <param name="allowAdditionalProperties">When true, sets additionalProperties to true indicating extra fields are allowed.</param>
/// <exception cref="DataApiBuilderException">Raised when an entity's database metadata can't be found,
/// indicating a failure due to the provided entityName.</exception>
/// <returns>Entity's OpenApiSchema representation.</returns>
private static OpenApiSchema CreateComponentSchema(string entityName, HashSet<string> fields, ISqlMetadataProvider metadataProvider, RuntimeEntities entities)
private static OpenApiSchema CreateComponentSchema(string entityName, HashSet<string> fields, ISqlMetadataProvider metadataProvider, RuntimeEntities entities, bool allowAdditionalProperties)
{
if (!metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null)
{
Expand Down Expand Up @@ -1166,7 +1177,8 @@ private static OpenApiSchema CreateComponentSchema(string entityName, HashSet<st
{
Type = SCHEMA_OBJECT_TYPE,
Properties = properties,
Description = entityConfig?.Description
Description = entityConfig?.Description,
AdditionalPropertiesAllowed = allowAdditionalProperties
};

return schema;
Expand Down
33 changes: 29 additions & 4 deletions src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,43 @@ internal static async Task<OpenApiDocument> GenerateOpenApiDocumentAsync(
RuntimeEntities runtimeEntities,
string configFileName,
string databaseEnvironment)
{
return await GenerateOpenApiDocumentAsync(
runtimeEntities: runtimeEntities,
configFileName: configFileName,
databaseEnvironment: databaseEnvironment,
restOptions: null);
}

/// <summary>
/// Bootstraps a test server instance using a runtime config file generated
/// from the provided entity collection and REST options. The test server is only used to generate
/// and return the OpenApiDocument for use this method's callers.
/// </summary>
/// <param name="runtimeEntities"></param>
/// <param name="configFileName"></param>
/// <param name="databaseEnvironment"></param>
/// <param name="restOptions">Optional REST runtime options to customize request-body-strict setting.</param>
/// <returns>Generated OpenApiDocument</returns>
internal static async Task<OpenApiDocument> GenerateOpenApiDocumentAsync(
RuntimeEntities runtimeEntities,
string configFileName,
string databaseEnvironment,
RestRuntimeOptions restOptions)
{
TestHelper.SetupDatabaseEnvironment(databaseEnvironment);
FileSystem fileSystem = new();
FileSystemRuntimeConfigLoader loader = new(fileSystem);
loader.TryLoadKnownConfig(out RuntimeConfig config);

// Create custom REST options if provided, otherwise use existing config
RuntimeOptions runtimeWithRestOptions = restOptions is not null
? config.Runtime with { Rest = restOptions, Host = config.Runtime?.Host with { Mode = HostMode.Production } }
: config.Runtime with { Host = config.Runtime?.Host with { Mode = HostMode.Production } };

RuntimeConfig configWithCustomHostMode = config with
{
Runtime = config.Runtime with
{
Host = config.Runtime?.Host with { Mode = HostMode.Production }
},
Runtime = runtimeWithRestOptions,
Entities = runtimeEntities
};

Expand Down
146 changes: 146 additions & 0 deletions src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Microsoft.OpenApi.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
{
/// <summary>
/// Integration tests validating that OpenAPI document schemas respect the request-body-strict configuration.
/// When request-body-strict is false, schemas should have additionalProperties set to true to indicate
/// that extra fields in request bodies are allowed and will be ignored.
/// </summary>
[TestCategory(TestCategory.MSSQL)]
[TestClass]
public class RequestBodyStrictTests
{
private const string CUSTOM_CONFIG = "request-body-strict.MsSql.json";
private const string MSSQL_ENVIRONMENT = TestCategory.MSSQL;

/// <summary>
/// Validates that when request-body-strict is true (default), the OpenAPI schema has
/// additionalProperties set to false, indicating that extra fields are not allowed.
/// </summary>
[TestMethod]
public async Task OpenApiSchema_WhenRequestBodyStrictTrue_AdditionalPropertiesIsFalse()
{
// Arrange
Entity entity = new(
Source: new(Object: "books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(Singular: null, Plural: null, Enabled: false),
Rest: new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
Mappings: null,
Relationships: null);

Dictionary<string, Entity> entities = new()
{
{ "Book", entity }
};

RuntimeEntities runtimeEntities = new(entities);

// request-body-strict: true (default)
RestRuntimeOptions restOptions = new(Enabled: true, Path: "/api", RequestBodyStrict: true);

// Act - Create OpenApi document
OpenApiDocument openApiDocument = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
runtimeEntities: runtimeEntities,
configFileName: CUSTOM_CONFIG,
databaseEnvironment: MSSQL_ENVIRONMENT,
restOptions: restOptions);

// Assert - Validate that Book schema has additionalProperties set to false
Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book"),
"Schema 'Book' should exist in OpenAPI document.");

OpenApiSchema bookSchema = openApiDocument.Components.Schemas["Book"];
Assert.IsFalse(
bookSchema.AdditionalPropertiesAllowed,
"When request-body-strict is true, additionalProperties should be false.");

// Validate _NoAutoPK and _NoPK schemas as well
Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book_NoAutoPK"),
"Schema 'Book_NoAutoPK' should exist in OpenAPI document.");
Assert.IsFalse(
openApiDocument.Components.Schemas["Book_NoAutoPK"].AdditionalPropertiesAllowed,
"When request-body-strict is true, Book_NoAutoPK additionalProperties should be false.");

Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book_NoPK"),
"Schema 'Book_NoPK' should exist in OpenAPI document.");
Assert.IsFalse(
openApiDocument.Components.Schemas["Book_NoPK"].AdditionalPropertiesAllowed,
"When request-body-strict is true, Book_NoPK additionalProperties should be false.");
}

/// <summary>
/// Validates that when request-body-strict is false, the OpenAPI schema has
/// additionalProperties set to true, indicating that extra fields are allowed.
/// This addresses GitHub issue #2947 and #1838.
/// </summary>
[TestMethod]
public async Task OpenApiSchema_WhenRequestBodyStrictFalse_AdditionalPropertiesIsTrue()
{
// Arrange
Entity entity = new(
Source: new(Object: "books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(Singular: null, Plural: null, Enabled: false),
Rest: new(Methods: EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
Mappings: null,
Relationships: null);

Dictionary<string, Entity> entities = new()
{
{ "Book", entity }
};

RuntimeEntities runtimeEntities = new(entities);

// request-body-strict: false (non-strict mode)
RestRuntimeOptions restOptions = new(Enabled: true, Path: "/api", RequestBodyStrict: false);

// Act - Create OpenApi document
OpenApiDocument openApiDocument = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(
runtimeEntities: runtimeEntities,
configFileName: CUSTOM_CONFIG,
databaseEnvironment: MSSQL_ENVIRONMENT,
restOptions: restOptions);

// Assert - Validate that Book schema has additionalProperties set to true
Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book"),
"Schema 'Book' should exist in OpenAPI document.");

OpenApiSchema bookSchema = openApiDocument.Components.Schemas["Book"];
Assert.IsTrue(
bookSchema.AdditionalPropertiesAllowed,
"When request-body-strict is false, additionalProperties should be true to indicate extra fields are allowed.");

// Validate _NoAutoPK and _NoPK schemas as well
Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book_NoAutoPK"),
"Schema 'Book_NoAutoPK' should exist in OpenAPI document.");
Assert.IsTrue(
openApiDocument.Components.Schemas["Book_NoAutoPK"].AdditionalPropertiesAllowed,
"When request-body-strict is false, Book_NoAutoPK additionalProperties should be true.");

Assert.IsTrue(
openApiDocument.Components.Schemas.ContainsKey("Book_NoPK"),
"Schema 'Book_NoPK' should exist in OpenAPI document.");
Assert.IsTrue(
openApiDocument.Components.Schemas["Book_NoPK"].AdditionalPropertiesAllowed,
"When request-body-strict is false, Book_NoPK additionalProperties should be true.");
}
}
}