diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 87fb96bc32..e30a0f524b 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -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 @@ -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. /// + /// Runtime entities from configuration. + /// Default data source name. + /// Whether extra fields are rejected in request bodies. /// Collection of schemas for entities defined in the runtime configuration. - private Dictionary CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName) + private Dictionary CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName, bool isRequestBodyStrict) { Dictionary schemas = new(); // for rest scenario we need the default datasource name. @@ -1006,18 +1011,20 @@ private Dictionary 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. @@ -1037,7 +1044,8 @@ private Dictionary 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 @@ -1053,7 +1061,8 @@ private Dictionary 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)); } } @@ -1066,10 +1075,10 @@ private Dictionary CreateComponentSchemas(RuntimeEntities /// Additionally, the property typeMetadata is sourced by converting the stored procedure /// parameter's SystemType to JsonDataType. /// - /// /// Collection of stored procedure parameter metadata. + /// When true, sets additionalProperties to true indicating extra fields are allowed. /// OpenApiSchema object representing a stored procedure's request body. - private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary fields) + private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary fields, bool allowAdditionalProperties) { Dictionary properties = new(); HashSet required = new(); @@ -1097,7 +1106,8 @@ private static OpenApiSchema CreateSpRequestComponentSchema(DictionaryList of mapped (alias) field names. /// Metadata provider for database objects. /// Runtime entities from configuration. + /// When true, sets additionalProperties to true indicating extra fields are allowed. /// Raised when an entity's database metadata can't be found, /// indicating a failure due to the provided entityName. /// Entity's OpenApiSchema representation. - private static OpenApiSchema CreateComponentSchema(string entityName, HashSet fields, ISqlMetadataProvider metadataProvider, RuntimeEntities entities) + private static OpenApiSchema CreateComponentSchema(string entityName, HashSet fields, ISqlMetadataProvider metadataProvider, RuntimeEntities entities, bool allowAdditionalProperties) { if (!metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { @@ -1166,7 +1177,8 @@ private static OpenApiSchema CreateComponentSchema(string entityName, HashSet GenerateOpenApiDocumentAsync( RuntimeEntities runtimeEntities, string configFileName, string databaseEnvironment) + { + return await GenerateOpenApiDocumentAsync( + runtimeEntities: runtimeEntities, + configFileName: configFileName, + databaseEnvironment: databaseEnvironment, + restOptions: null); + } + + /// + /// 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. + /// + /// + /// + /// + /// Optional REST runtime options to customize request-body-strict setting. + /// Generated OpenApiDocument + internal static async Task 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 }; diff --git a/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs new file mode 100644 index 0000000000..7a1622e353 --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs @@ -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 +{ + /// + /// 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. + /// + [TestCategory(TestCategory.MSSQL)] + [TestClass] + public class RequestBodyStrictTests + { + private const string CUSTOM_CONFIG = "request-body-strict.MsSql.json"; + private const string MSSQL_ENVIRONMENT = TestCategory.MSSQL; + + /// + /// Validates that when request-body-strict is true (default), the OpenAPI schema has + /// additionalProperties set to false, indicating that extra fields are not allowed. + /// + [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 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."); + } + + /// + /// 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. + /// + [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 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."); + } + } +}