diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 5efd58a740..c3ccf7b47f 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -43,6 +43,7 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string GETONE_DESCRIPTION = "Returns an entity."; private const string POST_DESCRIPTION = "Create entity."; private const string PUT_DESCRIPTION = "Replace or create entity."; + private const string PUT_PATCH_KEYLESS_DESCRIPTION = "Create entity (keyless). For entities with auto-generated primary keys, creates a new record without requiring the key in the URL."; private const string PATCH_DESCRIPTION = "Update or create entity."; private const string DELETE_DESCRIPTION = "Delete entity."; private const string SP_EXECUTE_DESCRIPTION = "Executes a stored procedure."; @@ -502,6 +503,31 @@ private Dictionary CreateOperations( openApiPathItemOperations.Add(OperationType.Post, postOperation); } + // For entities with auto-generated primary keys, add keyless PUT and PATCH operations. + // These routes allow creating records without specifying the primary key in the URL, + // which is useful for entities with identity/auto-generated keys. + if (DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition)) + { + string keylessBodySchemaReferenceId = $"{entityName}_NoAutoPK"; + bool keylessRequestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true); + + if (configuredRestOperations[OperationType.Put]) + { + OpenApiOperation putKeylessOperation = CreateBaseOperation(description: PUT_PATCH_KEYLESS_DESCRIPTION, tags: tags); + putKeylessOperation.RequestBody = CreateOpenApiRequestBodyPayload(keylessBodySchemaReferenceId, keylessRequestBodyRequired); + putKeylessOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Put, putKeylessOperation); + } + + if (configuredRestOperations[OperationType.Patch]) + { + OpenApiOperation patchKeylessOperation = CreateBaseOperation(description: PUT_PATCH_KEYLESS_DESCRIPTION, tags: tags); + patchKeylessOperation.RequestBody = CreateOpenApiRequestBodyPayload(keylessBodySchemaReferenceId, keylessRequestBodyRequired); + patchKeylessOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Patch, patchKeylessOperation); + } + } + return openApiPathItemOperations; } } diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs index ad9e3894f8..aef6ca9ab3 100644 --- a/src/Core/Services/RequestValidator.cs +++ b/src/Core/Services/RequestValidator.cs @@ -348,8 +348,12 @@ public void ValidateInsertRequestContext(InsertRequestContext insertRequestCtx) /// and vice versa. /// /// Upsert Request context containing the request body. + /// When true the primary key was provided in the URL route + /// and PK columns in the body are skipped (original behaviour). When false the primary key + /// is expected in the request body, so non-auto-generated PK columns must be present and + /// the full composite key (if applicable) must be supplied. /// - public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx) + public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx, bool isPrimaryKeyInUrl = true) { ISqlMetadataProvider sqlMetadataProvider = GetSqlMetadataProvider(upsertRequestCtx.EntityName); IEnumerable fieldsInRequestBody = upsertRequestCtx.FieldValuePairsInBody.Keys; @@ -385,13 +389,45 @@ public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx) unValidatedFields.Remove(exposedName!); } - // Primary Key(s) should not be present in the request body. We do not fail a request - // if a PK is autogenerated here, because an UPSERT request may only need to update a - // record. If an insert occurs on a table with autogenerated primary key, - // a database error will be returned. + // When the primary key is provided in the URL route, skip PK columns in body validation. + // When the primary key is NOT in the URL (body-based PK), we need to validate that + // all non-auto-generated PK columns are present in the body to form a complete key. if (sourceDefinition.PrimaryKey.Contains(column.Key)) { - continue; + if (isPrimaryKeyInUrl) + { + continue; + } + else + { + // Body-based PK: non-auto-generated PK columns MUST be present. + // Auto-generated PK columns are skipped — they cannot be supplied by the caller. + if (column.Value.IsAutoGenerated) + { + continue; + } + + if (!fieldsInRequestBody.Contains(exposedName)) + { + throw new DataApiBuilderException( + message: $"Invalid request body. Missing field in body: {exposedName}.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + // PK value must not be null for non-nullable PK columns. + if (!column.Value.IsNullable && + upsertRequestCtx.FieldValuePairsInBody[exposedName!] is null) + { + throw new DataApiBuilderException( + message: $"Invalid value for field {exposedName} in request body.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + unValidatedFields.Remove(exposedName!); + continue; + } } // Request body must have value defined for included non-nullable columns @@ -488,7 +524,6 @@ public void ValidateEntity(string entityName) /// Tries to get the table definition for the given entity from the Metadata provider. /// /// Target entity name. - /// enables referencing DB schema. /// private static SourceDefinition TryGetSourceDefinition(string entityName, ISqlMetadataProvider sqlMetadataProvider) diff --git a/src/Core/Services/RestService.cs b/src/Core/Services/RestService.cs index 72beb037c6..18b80a37f8 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -70,24 +70,25 @@ RequestValidator requestValidator ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName]; - if (dbObject.SourceType is not EntitySourceType.StoredProcedure) - { - await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement()); - } - else - { - await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement()); - } - QueryString? query = GetHttpContext().Request.QueryString; string queryString = query is null ? string.Empty : GetHttpContext().Request.QueryString.ToString(); - + + // Read the request body early so it can be used for downstream processing. string requestBody = string.Empty; using (StreamReader reader = new(GetHttpContext().Request.Body)) { requestBody = await reader.ReadToEndAsync(); } + if (dbObject.SourceType is not EntitySourceType.StoredProcedure) + { + await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement()); + } + else + { + await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement()); + } + RestRequestContext context; // If request has resolved to a stored procedure entity, initialize and validate appropriate request context @@ -144,7 +145,21 @@ RequestValidator requestValidator case EntityActionOperation.UpdateIncremental: case EntityActionOperation.Upsert: case EntityActionOperation.UpsertIncremental: - RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute); + // For Upsert/UpsertIncremental, a keyless URL is allowed. When the + // primary key route is absent, ValidateUpsertRequestContext checks that + // the body contains all non-auto-generated PK columns so the mutation + // engine can resolve the target row (or insert a new one). + // Update/UpdateIncremental always require the PK in the URL. + if (!string.IsNullOrEmpty(primaryKeyRoute)) + { + RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute); + } + else if (operationType is not EntityActionOperation.Upsert and + not EntityActionOperation.UpsertIncremental) + { + RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute); + } + JsonElement upsertPayloadRoot = RequestValidator.ValidateAndParseRequestBody(requestBody); context = new UpsertRequestContext( entityName, @@ -153,7 +168,9 @@ RequestValidator requestValidator operationType); if (context.DatabaseObject.SourceType is EntitySourceType.Table) { - _requestValidator.ValidateUpsertRequestContext((UpsertRequestContext)context); + _requestValidator.ValidateUpsertRequestContext( + (UpsertRequestContext)context, + primaryKeyInUrl: !string.IsNullOrEmpty(primaryKeyRoute)); } break; diff --git a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs index db6b58681b..dc35b1c4dd 100644 --- a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs @@ -120,9 +120,18 @@ public async Task TestAzureLogAnalyticsFlushServiceSucceed(string message, LogLe _ = Task.Run(() => flusherService.StartAsync(tokenSource.Token)); - await Task.Delay(2000); + // Poll until the log appears (the flusher service needs time to dequeue and upload) + int maxWaitMs = 10000; + int pollIntervalMs = 100; + int elapsed = 0; + while (customClient.LogAnalyticsLogs.Count == 0 && elapsed < maxWaitMs) + { + await Task.Delay(pollIntervalMs); + elapsed += pollIntervalMs; + } // Assert + Assert.IsTrue(customClient.LogAnalyticsLogs.Count > 0, $"Expected at least one log entry after waiting {elapsed}ms, but found none."); AzureLogAnalyticsLogs actualLog = customClient.LogAnalyticsLogs[0]; Assert.AreEqual(logLevel.ToString(), actualLog.LogLevel); Assert.AreEqual(message, actualLog.Message); diff --git a/src/Service.Tests/OpenApiDocumentor/DocumentVerbosityTests.cs b/src/Service.Tests/OpenApiDocumentor/DocumentVerbosityTests.cs index fa43617f4f..c21718c87b 100644 --- a/src/Service.Tests/OpenApiDocumentor/DocumentVerbosityTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/DocumentVerbosityTests.cs @@ -23,7 +23,7 @@ public class DocumentVerbosityTests private const string UNEXPECTED_CONTENTS_ERROR = "Unexpected number of response objects to validate."; /// - /// Validates that for the Book entity, 7 response object schemas generated by OpenApiDocumentor + /// Validates that for the Book entity, 9 response object schemas generated by OpenApiDocumentor /// contain a 'type' property with value 'object'. /// /// Two paths: @@ -32,9 +32,9 @@ public class DocumentVerbosityTests /// - Validate responses that return result contents: /// GET (200), PUT (200, 201), PATCH (200, 201) /// - "/Books" - /// - 2 operations GET(all) POST + /// - 4 operations GET(all) POST PUT(keyless) PATCH(keyless) /// - Validate responses that return result contents: - /// GET (200), POST (201) + /// GET (200), POST (201), PUT keyless (201), PATCH keyless (201) /// [TestMethod] public async Task ResponseObjectSchemaIncludesTypeProperty() @@ -71,10 +71,10 @@ public async Task ResponseObjectSchemaIncludesTypeProperty() .Select(pair => pair.Value) .ToList(); - // Validate that 7 response object schemas contain a 'type' property with value 'object' - // Test summary describes all 7 expected responses. + // Validate that 9 response object schemas contain a 'type' property with value 'object' + // Test summary describes all 9 expected responses. Assert.IsTrue( - condition: responses.Count == 7, + condition: responses.Count == 9, message: UNEXPECTED_CONTENTS_ERROR); foreach (OpenApiResponse response in responses) diff --git a/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs b/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs index 7c0e0225ae..5caed5a6b9 100644 --- a/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/ParameterValidationTests.cs @@ -117,8 +117,13 @@ public async Task TestQueryParametersExcludedFromNonReadOperationsOnTablesAndVie OpenApiPathItem pathWithouId = openApiDocument.Paths[$"/{entityName}"]; Assert.IsTrue(pathWithouId.Operations.ContainsKey(OperationType.Post)); Assert.IsFalse(pathWithouId.Operations[OperationType.Post].Parameters.Any(param => param.In is ParameterLocation.Query)); - Assert.IsFalse(pathWithouId.Operations.ContainsKey(OperationType.Put)); - Assert.IsFalse(pathWithouId.Operations.ContainsKey(OperationType.Patch)); + + // With keyless PUT/PATCH support, PUT and PATCH operations are present on the base path + // for entities with auto-generated primary keys. Validate they don't have query parameters. + Assert.IsTrue(pathWithouId.Operations.ContainsKey(OperationType.Put)); + Assert.IsFalse(pathWithouId.Operations[OperationType.Put].Parameters.Any(param => param.In is ParameterLocation.Query)); + Assert.IsTrue(pathWithouId.Operations.ContainsKey(OperationType.Patch)); + Assert.IsFalse(pathWithouId.Operations[OperationType.Patch].Parameters.Any(param => param.In is ParameterLocation.Query)); Assert.IsFalse(pathWithouId.Operations.ContainsKey(OperationType.Delete)); // Assert that Query Parameters Excluded From NonReadOperations for path with id. diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs index eeb97badc9..e711b648dc 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs @@ -18,6 +18,13 @@ public class MsSqlPatchApiTests : PatchApiTestBase { private static Dictionary _queryMap = new() { + { + "PatchOne_Insert_KeylessWithAutoGenPK_Test", + $"SELECT [id], [title], [publisher_id] FROM { _integrationTableName } " + + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [title] = 'My New Book' " + + $"AND [publisher_id] = 1234 " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, { "PatchOne_Insert_NonAutoGenPK_Test", $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs index de72fe8b80..65cf224e75 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs @@ -13,6 +13,17 @@ public class MySqlPatchApiTests : PatchApiTestBase { protected static Dictionary _queryMap = new() { + { + "PatchOne_Insert_KeylessWithAutoGenPK_Test", + @"SELECT JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id) AS data + FROM ( + SELECT id, title, publisher_id + FROM " + _integrationTableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'My New Book' AND publisher_id = 1234 + ) AS subq + " + }, { "PatchOne_Insert_NonAutoGenPK_Test", @"SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number ) AS data diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs index 8aad5b0654..ba5971c02c 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs @@ -5,7 +5,6 @@ using System.Net; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -346,6 +345,34 @@ public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy() return Task.CompletedTask; } + /// + /// Tests the PatchOne functionality with a REST PATCH request + /// without a primary key route on an entity with an auto-generated primary key. + /// With keyless PATCH support, ValidateUpsertRequestContext allows this because + /// all PK columns are auto-generated. The mutation engine then performs an insert + /// and succeeds with 201 Created. + /// + [TestMethod] + public virtual async Task PatchOne_Insert_KeylessWithAutoGenPK_Test() + { + string requestBody = @" + { + ""title"": ""My New Book"", + ""publisher_id"": 1234 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Insert_KeylessWithAutoGenPK_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + /// /// Tests successful execution of PATCH update requests on views /// when requests try to modify fields belonging to one base table @@ -931,8 +958,9 @@ await SetupAndRunRestApiTest( /// /// Tests the Patch functionality with a REST PATCH request - /// without a primary key route. We expect a failure and so - /// no sql query is provided. + /// without a primary key route. For non-auto-generated PK entities, + /// ValidateUpsertRequestContext detects the missing required + /// non-auto-generated PK field in the body and returns a BadRequest. /// [TestMethod] public virtual async Task PatchWithNoPrimaryKeyRouteTest() @@ -951,11 +979,45 @@ await SetupAndRunRestApiTest( operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, exceptionExpected: true, - expectedErrorMessage: RequestValidator.PRIMARY_KEY_NOT_PROVIDED_ERR_MESSAGE, - expectedStatusCode: HttpStatusCode.BadRequest + expectedErrorMessage: "Invalid request body. Missing field in body: id.", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() ); } + /// + /// Tests the Patch functionality with a REST PATCH request + /// without a primary key route on an entity with a composite non-auto-generated primary key, + /// where the body only contains a partial key. ValidateUpsertRequestContext detects the + /// missing non-auto-generated PK field and returns a BadRequest. + /// + [TestMethod] + public virtual async Task PatchWithNoPrimaryKeyRouteAndPartialCompositeKeyInBodyTest() + { + // Body only contains categoryid but not pieceid — both are required + // since neither is auto-generated. + string requestBody = @" + { + ""categoryid"": 100, + ""categoryName"": ""SciFi"", + ""piecesAvailable"": 5, + ""piecesRequired"": 3 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: string.Empty, + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + exceptionExpected: true, + expectedErrorMessage: "Invalid request body. Missing field in body: pieceid.", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() + ); + } + /// /// Test to validate failure of PATCH operation failing to satisfy the database policy for the update operation. /// (because a record exists for given PK). @@ -988,7 +1050,7 @@ await SetupAndRunRestApiTest( } /// - /// Test to validate failure of PATCH operation failing to satisfy the database policy for the update operation. + /// Test to validate failure of PATCH operation failing to satisfy the database policy for the insert operation. /// (because no record exists for given PK). /// [TestMethod] diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs index 441c96425c..0a808bae58 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs @@ -13,6 +13,18 @@ public class PostgreSqlPatchApiTests : PatchApiTestBase { protected static Dictionary _queryMap = new() { + { + "PatchOne_Insert_KeylessWithAutoGenPK_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, publisher_id + FROM " + _integrationTableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'My New Book' AND publisher_id = 1234 + ) AS subq + " + }, { "PatchOne_Insert_Mapping_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs index 5b2745e203..7ae15bb510 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs @@ -18,6 +18,13 @@ public class MsSqlPutApiTests : PutApiTestBase { private static Dictionary _queryMap = new() { + { + "PutOne_Insert_KeylessWithAutoGenPK_Test", + $"SELECT [id], [title], [publisher_id] FROM { _integrationTableName } " + + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [title] = 'My New Book' " + + $"AND [publisher_id] = 1234 " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, { "PutOne_Update_Test", $"SELECT [id], [title], [publisher_id] FROM { _integrationTableName } " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs index 7708186f65..89024053b4 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs @@ -13,6 +13,18 @@ public class MySqlPutApiTests : PutApiTestBase { protected static Dictionary _queryMap = new() { + { + "PutOne_Insert_KeylessWithAutoGenPK_Test", + @" + SELECT JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id) AS data + FROM ( + SELECT id, title, publisher_id + FROM " + _integrationTableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'My New Book' AND publisher_id = 1234 + ) AS subq + " + }, { "PutOne_Update_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs index c9e527876b..e9f8bcaac1 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs @@ -14,6 +14,18 @@ public class PostgreSqlPutApiTests : PutApiTestBase { protected static Dictionary _queryMap = new() { + { + "PutOne_Insert_KeylessWithAutoGenPK_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, publisher_id + FROM " + _integrationTableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'My New Book' AND publisher_id = 1234 + ) AS subq + " + }, { "PutOne_Update_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs index eb6cfb3767..503a8388a4 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -227,6 +227,34 @@ public virtual Task PutOneUpdateTestOnTableWithSecurityPolicy() return Task.CompletedTask; } + /// + /// Tests the PutOne functionality with a REST PUT request + /// without a primary key route on an entity with an auto-generated primary key. + /// With keyless PUT support, ValidateUpsertRequestContext allows this because + /// all PK columns are auto-generated. The mutation engine then performs an insert + /// and succeeds with 201 Created. + /// + [TestMethod] + public virtual async Task PutOne_Insert_KeylessWithAutoGenPK_Test() + { + string requestBody = @" + { + ""title"": ""My New Book"", + ""publisher_id"": 1234 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PutOne_Insert_KeylessWithAutoGenPK_Test)), + operationType: EntityActionOperation.Upsert, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + /// /// Tests the PutOne functionality with a REST PUT request using /// headers that include as a key "If-Match" with an item that does exist, @@ -998,8 +1026,9 @@ await SetupAndRunRestApiTest( /// /// Tests the Put functionality with a REST PUT request - /// without a primary key route. We expect a failure and so - /// no sql query is provided. + /// without a primary key route. For non-auto-generated PK entities, + /// ValidateUpsertRequestContext detects the missing required + /// non-auto-generated PK field in the body and returns a BadRequest. /// [TestMethod] public virtual async Task PutWithNoPrimaryKeyRouteTest() @@ -1018,11 +1047,75 @@ await SetupAndRunRestApiTest( operationType: EntityActionOperation.Upsert, requestBody: requestBody, exceptionExpected: true, + expectedErrorMessage: "Invalid request body. Missing field in body: id.", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() + ); + } + + /// + /// Tests that a PUT request with If-Match header (strict update semantics) + /// still requires a primary key route. When If-Match is present, the operation + /// becomes Update (not Upsert), so it cannot be converted to Insert. + /// + [TestMethod] + public virtual async Task PutWithNoPrimaryKeyRouteAndIfMatchHeaderTest() + { + Dictionary headerDictionary = new(); + headerDictionary.Add("If-Match", "*"); + string requestBody = @" + { + ""title"": ""Batman Returns"", + ""publisher_id"": 1234 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: string.Empty, + operationType: EntityActionOperation.Upsert, + headers: new HeaderDictionary(headerDictionary), + requestBody: requestBody, + exceptionExpected: true, expectedErrorMessage: RequestValidator.PRIMARY_KEY_NOT_PROVIDED_ERR_MESSAGE, expectedStatusCode: HttpStatusCode.BadRequest ); } + /// + /// Tests the Put functionality with a REST PUT request + /// without a primary key route on an entity with a composite non-auto-generated primary key, + /// where the body only contains a partial key. ValidateUpsertRequestContext detects the + /// missing non-auto-generated PK field and returns a BadRequest. + /// + [TestMethod] + public virtual async Task PutWithNoPrimaryKeyRouteAndPartialCompositeKeyInBodyTest() + { + // Body only contains categoryid but not pieceid — both are required + // since neither is auto-generated. + string requestBody = @" + { + ""categoryid"": 100, + ""categoryName"": ""SciFi"", + ""piecesAvailable"": 5, + ""piecesRequired"": 3 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: string.Empty, + operationType: EntityActionOperation.Upsert, + requestBody: requestBody, + exceptionExpected: true, + expectedErrorMessage: "Invalid request body. Missing field in body: pieceid.", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() + ); + } + /// /// Tests that a cast failure of primary key value type results in HTTP 400 Bad Request. /// e.g. Attempt to cast a string '{}' to the 'publisher_id' column type of int will fail.