From 2e654308a18184e6471c2faf8dac2729c726a962 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 20 Feb 2026 14:58:22 -0800 Subject: [PATCH 1/9] update docs --- .../Services/OpenAPI/OpenApiDocumentor.cs | 27 +++++++++++++++++++ src/Core/Services/RestService.cs | 10 +++++++ 2 files changed, 37 insertions(+) diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 5efd58a740..419a10749b 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -43,7 +43,9 @@ 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_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 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 DELETE_DESCRIPTION = "Delete entity."; private const string SP_EXECUTE_DESCRIPTION = "Executes a stored procedure."; @@ -502,6 +504,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_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: 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/RestService.cs b/src/Core/Services/RestService.cs index e63f23cc5f..7a19269525 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -70,6 +70,16 @@ RequestValidator requestValidator ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName]; + // When a PUT or PATCH request arrives without a primary key in the route, + // convert it to an Insert operation. This supports entities with identity/auto-generated + // keys where the caller doesn't know the key value before creation. + if (string.IsNullOrEmpty(primaryKeyRoute) && + (operationType is EntityActionOperation.Upsert || + operationType is EntityActionOperation.UpsertIncremental)) + { + operationType = EntityActionOperation.Insert; + } + if (dbObject.SourceType is not EntitySourceType.StoredProcedure) { await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement()); From 0696d6f3c9231fe487bfe36c891b01720ef37994 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 20 Feb 2026 17:01:54 -0800 Subject: [PATCH 2/9] add MsSql test support --- .../RestApiTests/Patch/PatchApiTestBase.cs | 1137 ++++++++++++++--- .../RestApiTests/Put/MsSqlPutApiTests.cs | 7 + .../RestApiTests/Put/PutApiTestBase.cs | 61 +- 3 files changed, 992 insertions(+), 213 deletions(-) diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs index 8aad5b0654..452072a918 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs @@ -347,120 +347,810 @@ public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy() } /// - /// Tests successful execution of PATCH update requests on views - /// when requests try to modify fields belonging to one base table - /// in the view. + /// 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: 200 OK where sqlQuery validates update. + /// + [TestMethod] + public virtual async Task PatchOne_Update_Test() + { + string requestBody = @" + { + ""title"": ""Heart of Darkness"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/8", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// + [TestMethod] + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + string requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/1", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + + /// + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. + /// + [TestMethod] + 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: 200 OK where sqlQuery validates update. + /// + [TestMethod] + public virtual async Task PatchOne_Update_Test() + { + string requestBody = @" + { + ""title"": ""Heart of Darkness"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/8", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// + [TestMethod] + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + string requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/1", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + + /// + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. + /// + [TestMethod] + 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: 200 OK where sqlQuery validates update. + /// + [TestMethod] + public virtual async Task PatchOne_Update_Test() + { + string requestBody = @" + { + ""title"": ""Heart of Darkness"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/8", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// + [TestMethod] + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + string requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/1", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + + /// + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. + /// + [TestMethod] + 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: 200 OK where sqlQuery validates update. + /// + [TestMethod] + public virtual async Task PatchOne_Update_Test() + { + string requestBody = @" + { + ""title"": ""Heart of Darkness"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/8", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// + [TestMethod] + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + string requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/1", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + + /// + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. + /// + [TestMethod] + 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: 200 OK where sqlQuery validates update. + /// + [TestMethod] + public virtual async Task PatchOne_Update_Test() + { + string requestBody = @" + { + ""title"": ""Heart of Darkness"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/8", + queryString: null, + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Test to validate successful execution of a request when a computed field is missing from the request body. /// - /// [TestMethod] - public virtual async Task PatchOneUpdateViewTest() + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() { - // PATCH update on simple view based on stocks table. + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. string requestBody = @" { - ""categoryName"": ""Historical"" + ""book_name"": ""New book"", + ""copies_sold"": 50 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/2/pieceid/1", + primaryKeyRoute: $"id/1", queryString: null, - entityNameOrPath: _simple_subset_stocks, - sqlQuery: GetQuery("PatchOneUpdateStocksViewSelected"), + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, expectedStatusCode: HttpStatusCode.OK ); - } - /// - /// Tests the PatchOne functionality with a REST PATCH request using - /// headers that include as a key "If-Match" with an item that does exist, - /// resulting in an update occuring. Verify update with Find. - /// - [TestMethod] - public virtual async Task PatchOne_Update_IfMatchHeaders_Test() - { - Dictionary headerDictionary = new(); - headerDictionary.Add("If-Match", "*"); - string requestBody = @" + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" { - ""title"": ""The Hobbit Returns to The Shire"", - ""publisher_id"": 1234 + ""book_name"": ""New book"", + ""copies_sold"": 50 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "id/1", + primaryKeyRoute: $"id/2", queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_IfMatchHeaders_Test)), + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), operationType: EntityActionOperation.UpsertIncremental, - headers: new HeaderDictionary(headerDictionary), requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty ); } /// - /// Test to validate successful execution of PATCH operation which satisfies the database policy for the update operation it resolves into. + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. /// [TestMethod] - public virtual async Task PatchOneUpdateWithDatabasePolicy() + public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy() { - // PATCH operation resolves to update because we have a record present for given PK. - // Since the database policy for update operation ("@item.pieceid ne 1") is satisfied by the operation, it executes successfully. - string requestBody = @" - { - ""piecesAvailable"": 4 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/100/pieceid/99", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOneUpdateWithDatabasePolicy"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK, - clientRoleHeader: "database_policy_tester" - ); + return Task.CompletedTask; } /// - /// Test to validate successful execution of PATCH operation which satisfies the database policy for the insert operation it resolves into. + /// 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, this converts to an Insert operation and succeeds + /// with 201 Created. /// [TestMethod] - public virtual async Task PatchOneInsertWithDatabasePolicy() + public virtual async Task PatchOne_Insert_KeylessWithAutoGenPK_Test() { - // PATCH operation resolves to insert because we don't have a record present for given PK. - // Since the database policy for insert operation ("@item.pieceid ne 6 and @item.piecesAvailable gt 0") is satisfied by the operation, it executes successfully. string requestBody = @" { - ""piecesAvailable"": 4, - ""categoryName"": ""SciFi"" + ""title"": ""My New Book"", + ""publisher_id"": 1234 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/0/pieceid/7", + primaryKeyRoute: string.Empty, queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOneInsertWithDatabasePolicy"), + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Insert_KeylessWithAutoGenPK_Test)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, expectedStatusCode: HttpStatusCode.Created, - clientRoleHeader: "database_policy_tester", expectedLocationHeader: string.Empty ); } /// - /// Tests that for a successful PATCH API request, the response returned takes into account that no read action is configured for the role. + /// Tests REST PatchOne which results in incremental update /// URI Path: PK of existing record. /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 200 OK since the PATCH operation results in an update - /// Response Body: Empty because the role policy_tester_noread has no read action configured. + /// Expects: 200 OK where sqlQuery validates update. /// [TestMethod] - public virtual async Task PatchOne_Update_NoReadTest() + public virtual async Task PatchOne_Update_Test() { string requestBody = @" { @@ -471,85 +1161,150 @@ await SetupAndRunRestApiTest( primaryKeyRoute: "id/8", queryString: null, entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_NoReadTest)), + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""categoryName"": """" + + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK, - clientRoleHeader: "test_role_with_noread" + expectedStatusCode: HttpStatusCode.OK ); } /// - /// Tests that for a successful PATCH API request, the response returned takes into account the include and exclude fields configured for the read action. - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 200 OK as PATCH operation results in an update operation. - /// Response Body: Contains only the id, title fields as publisher_id field is excluded in the read configuration. + /// Test to validate successful execution of a request when a computed field is missing from the request body. /// [TestMethod] - public virtual async Task Patch_Update_WithExcludeFieldsTest() + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. string requestBody = @" { - ""title"": ""Heart of Darkness"" + ""book_name"": ""New book"", + ""copies_sold"": 50 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", + primaryKeyRoute: $"id/1", queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(Patch_Update_WithExcludeFieldsTest)), + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" + { + ""book_name"": ""New book"", + ""copies_sold"": 50 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK, - clientRoleHeader: "test_role_with_excluded_fields" + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty ); } /// - /// Tests that for a successful PATCH API request, the response returned takes into account the database policy configured for the read action. - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 200 OK - /// Response Body: Empty. The read action for the role used in this test has a database policy - /// defined which states that title cannot be equal to Test. Since, this test updates the title - /// to Test the response must be empty. + /// Tests that the PATCH updates can only update the rows which are accessible after applying the + /// security policy which uses data from session context. /// [TestMethod] - public virtual async Task Patch_Update_WithReadDatabasePolicyTest() + 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, this converts to an Insert operation and succeeds + /// with 201 Created. + /// + [TestMethod] + public virtual async Task PatchOne_Insert_KeylessWithAutoGenPK_Test() { string requestBody = @" { - ""title"": ""Test"" + ""title"": ""My New Book"", + ""publisher_id"": 1234 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", + primaryKeyRoute: string.Empty, queryString: null, entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_NoReadTest)), + sqlQuery: GetQuery(nameof(PatchOne_Insert_KeylessWithAutoGenPK_Test)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK, - clientRoleHeader: "test_role_with_policy_excluded_fields" + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty ); } /// - /// Tests that for a successful PATCH API request, the response returned takes into account the database policy configured for the read action. + /// Tests REST PatchOne which results in incremental update /// URI Path: PK of existing record. /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 200 OK - /// Response Body: Non-Empty and does not contain the publisher_id field. The read action for the role used in this test has a database policy - /// defined which states that title cannot be equal to Test. Since, this test updates the title - /// to a different the response must be non-empty. Also, since the role excludes the publisher_id field, the repsonse should not - /// contain publisher_id field. + /// Expects: 200 OK where sqlQuery validates update. /// [TestMethod] - public virtual async Task Patch_Update_WithReadDatabasePolicyUnsatisfiedTest() + public virtual async Task PatchOne_Update_Test() { string requestBody = @" { @@ -560,144 +1315,103 @@ await SetupAndRunRestApiTest( primaryKeyRoute: "id/8", queryString: null, entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(Patch_Update_WithExcludeFieldsTest)), + sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK, - clientRoleHeader: "test_role_with_policy_excluded_fields" + expectedStatusCode: HttpStatusCode.OK ); - } - /// - /// Test to validate that for a PATCH API request that results in a successful insert operation, - /// the response returned takes into account that no read action is configured for the role. - /// URI Path: Contains a Non-existent PK. - /// Req Body: Valid Parameter with intended insert data. - /// Expects: - /// Status: 201 Created since the PATCH results in an insert operation - /// Response Body: Empty because the role policy_tester_noread has no read action configured. - /// - [TestMethod] - public virtual async Task PatchInsert_NoReadTest() - { - string requestBody = @" + requestBody = @" { - ""piecesAvailable"": 4, - ""categoryName"": ""SciFi"", - ""piecesRequired"": 4 + ""content"": ""That's a great book"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/567/book_id/1", + queryString: null, + entityNameOrPath: _entityWithCompositePrimaryKey, + sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + + requestBody = @" + { + ""piecesAvailable"": ""10"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/0/pieceid/7", + primaryKeyRoute: "categoryid/1/pieceid/1", queryString: null, entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchInsert_NoReadTest"), + sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - clientRoleHeader: "test_role_with_noread", - expectedLocationHeader: string.Empty + expectedStatusCode: HttpStatusCode.OK ); - } - /// - /// Tests that for a PATCH API request that results in a successful insert operation, - /// the response returned takes into account the include and exclude fields configured for the read action. - /// URI Path: Contains a non-existent PK. - /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 201 Created as PATCH results in an insert operation. - /// Response Body: Does not contain the categoryName field as it is excluded in the read configuration. - /// - [TestMethod] - public virtual async Task Patch_Insert_WithExcludeFieldsTest() - { - string requestBody = @" + requestBody = @" { - ""piecesAvailable"": 4, - ""categoryName"": ""SciFi"", - ""piecesRequired"": 4 + ""categoryName"": """" + }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/0/pieceid/7", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("Patch_Insert_WithExcludeFieldsTest"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - clientRoleHeader: "test_role_with_excluded_fields", - expectedLocationHeader: string.Empty - ); + primaryKeyRoute: "categoryid/1/pieceid/1", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); } /// - /// Tests that for a PATCH API request that results in a successful insert operation, - /// the response returned takes into account the database policy configured for the read action. - /// URI Path: Contains a non-existent PK. - /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 201 Created as PATCH results in an insert operation. - /// Response Body: Empty. The database policy configured for the read action states that piecesAvailable cannot be 0. - /// Since, the PATCH request inserts a record with piecesAvailable = 0, the response must be empty. + /// Test to validate successful execution of a request when a computed field is missing from the request body. /// [TestMethod] - public virtual async Task Patch_Insert_WithReadDatabasePolicyTest() + public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() { + // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. string requestBody = @" { - ""piecesAvailable"": 0, - ""categoryName"": ""SciFi"", - ""piecesRequired"": 4 + ""book_name"": ""New book"", + ""copies_sold"": 50 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/0/pieceid/7", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchInsert_NoReadTest"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - clientRoleHeader: "test_role_with_policy_excluded_fields", - expectedLocationHeader: string.Empty - ); - } + primaryKeyRoute: $"id/1", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); - /// - /// Tests that for a PATCH API request that results in a successful insert operation, - /// the response returned takes into account the database policy and the include/exlude fields - /// configured for the read action. - /// URI Path: Contains a non-existent PK. - /// Req Body: Valid Parameter with intended update. - /// Expects: - /// Status: 201 Created as PATCH results in an insert operation. - /// Response Body: Non-empty and should not contain the categoryName. The database policy configured for the read action states that piecesAvailable cannot be 0. - /// But, the PATCH request inserts a record with piecesAvailable = 4, so the policy is unsatisfied. Hence, the response should be non-empty. - /// The policy also excludes the categoryName field, so the response should not contain the categoryName field. - /// - [TestMethod] - public virtual async Task Patch_Insert_WithReadDatabasePolicyUnsatisfiedTest() - { - string requestBody = @" + // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') + // is missing from the request body. Successful execution of the PATCH request confirms that we did not + // attempt to NULL out the 'last_sold_on_update' field. + requestBody = @" { - ""piecesAvailable"": 4, - ""categoryName"": ""SciFi"", - ""piecesRequired"": 4 + ""book_name"": ""New book"", + ""copies_sold"": 50 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/0/pieceid/7", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("Patch_Insert_WithExcludeFieldsTest"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - clientRoleHeader: "test_role_with_policy_excluded_fields", - expectedLocationHeader: string.Empty - ); + primaryKeyRoute: $"id/2", + queryString: null, + entityNameOrPath: _entityWithReadOnlyFields, + sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); } #endregion @@ -931,8 +1645,10 @@ 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. With keyless PUT/PATCH support, + /// this converts to an Insert operation. Since the entity has a + /// non-auto-generated PK and it's missing from the request body, + /// the Insert validation catches it with a BadRequest. /// [TestMethod] public virtual async Task PatchWithNoPrimaryKeyRouteTest() @@ -951,8 +1667,9 @@ 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() ); } @@ -988,7 +1705,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/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/PutApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs index eb6cfb3767..1a6178340f 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -227,6 +227,33 @@ 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, this converts to an Insert operation 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 +1025,8 @@ 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. With keyless PUT/PATCH support, + /// this converts to an Insert operation and succeeds with 201 Created. /// [TestMethod] public virtual async Task PutWithNoPrimaryKeyRouteTest() @@ -1017,12 +1044,40 @@ await SetupAndRunRestApiTest( sqlQuery: string.Empty, operationType: EntityActionOperation.Upsert, requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + + /// + /// 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 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. From 9afa09fa23a18e5d9b9e09b545bd74c616fd15c0 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 11:05:05 -0800 Subject: [PATCH 3/9] update tests --- .../RestApiTests/Patch/MsSqlPatchApiTests.cs | 7 + .../RestApiTests/Patch/MySqlPatchApiTests.cs | 11 + .../RestApiTests/Patch/PatchApiTestBase.cs | 1092 +++-------------- .../Patch/PostgreSqlPatchApiTests.cs | 12 + .../RestApiTests/Put/MySqlPutApiTests.cs | 12 + .../RestApiTests/Put/PostgreSqlPutApiTests.cs | 12 + .../RestApiTests/Put/PutApiTestBase.cs | 10 +- 7 files changed, 263 insertions(+), 893 deletions(-) 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 452072a918..ce219dbddf 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; @@ -374,167 +373,148 @@ await SetupAndRunRestApiTest( } /// - /// Tests REST PatchOne which results in incremental update - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. + /// Tests successful execution of PATCH update requests on views + /// when requests try to modify fields belonging to one base table + /// in the view. /// + /// [TestMethod] - public virtual async Task PatchOne_Update_Test() + public virtual async Task PatchOneUpdateViewTest() { + // PATCH update on simple view based on stocks table. string requestBody = @" { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" + ""categoryName"": ""Historical"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", + primaryKeyRoute: "categoryid/2/pieceid/1", queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), + entityNameOrPath: _simple_subset_stocks, + sqlQuery: GetQuery("PatchOneUpdateStocksViewSelected"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, expectedStatusCode: HttpStatusCode.OK ); - - requestBody = @" + } + /// + /// Tests the PatchOne functionality with a REST PATCH request using + /// headers that include as a key "If-Match" with an item that does exist, + /// resulting in an update occuring. Verify update with Find. + /// + [TestMethod] + public virtual async Task PatchOne_Update_IfMatchHeaders_Test() + { + Dictionary headerDictionary = new(); + headerDictionary.Add("If-Match", "*"); + string requestBody = @" { - ""piecesAvailable"": ""10"" + ""title"": ""The Hobbit Returns to The Shire"", + ""publisher_id"": 1234 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", + primaryKeyRoute: "id/1", queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_IfMatchHeaders_Test)), operationType: EntityActionOperation.UpsertIncremental, + headers: new HeaderDictionary(headerDictionary), requestBody: requestBody, expectedStatusCode: HttpStatusCode.OK ); + } - requestBody = @" + /// + /// Test to validate successful execution of PATCH operation which satisfies the database policy for the update operation it resolves into. + /// + [TestMethod] + public virtual async Task PatchOneUpdateWithDatabasePolicy() + { + // PATCH operation resolves to update because we have a record present for given PK. + // Since the database policy for update operation ("@item.pieceid ne 1") is satisfied by the operation, it executes successfully. + string requestBody = @" { - ""categoryName"": """" - + ""piecesAvailable"": 4 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", + primaryKeyRoute: "categoryid/100/pieceid/99", queryString: null, entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + sqlQuery: GetQuery("PatchOneUpdateWithDatabasePolicy"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK + expectedStatusCode: HttpStatusCode.OK, + clientRoleHeader: "database_policy_tester" ); } /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// Test to validate successful execution of PATCH operation which satisfies the database policy for the insert operation it resolves into. /// [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + public virtual async Task PatchOneInsertWithDatabasePolicy() { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. + // PATCH operation resolves to insert because we don't have a record present for given PK. + // Since the database policy for insert operation ("@item.pieceid ne 6 and @item.piecesAvailable gt 0") is satisfied by the operation, it executes successfully. string requestBody = @" { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 + ""piecesAvailable"": 4, + ""categoryName"": ""SciFi"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", + primaryKeyRoute: "categoryid/0/pieceid/7", queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchOneInsertWithDatabasePolicy"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, expectedStatusCode: HttpStatusCode.Created, + clientRoleHeader: "database_policy_tester", expectedLocationHeader: string.Empty ); } /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. - /// - [TestMethod] - 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, this converts to an Insert operation and succeeds - /// with 201 Created. + /// Tests that for a successful PATCH API request, the response returned takes into account that no read action is configured for the role. + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: + /// Status: 200 OK since the PATCH operation results in an update + /// Response Body: Empty because the role policy_tester_noread has no read action configured. /// [TestMethod] - public virtual async Task PatchOne_Insert_KeylessWithAutoGenPK_Test() + public virtual async Task PatchOne_Update_NoReadTest() { string requestBody = @" { - ""title"": ""My New Book"", - ""publisher_id"": 1234 + ""title"": ""Heart of Darkness"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: string.Empty, + primaryKeyRoute: "id/8", queryString: null, entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Insert_KeylessWithAutoGenPK_Test)), + sqlQuery: GetQuery(nameof(PatchOne_Update_NoReadTest)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty + expectedStatusCode: HttpStatusCode.OK, + clientRoleHeader: "test_role_with_noread" ); } /// - /// Tests REST PatchOne which results in incremental update + /// Tests that for a successful PATCH API request, the response returned takes into account the include and exclude fields configured for the read action. /// URI Path: PK of existing record. /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. + /// Expects: + /// Status: 200 OK as PATCH operation results in an update operation. + /// Response Body: Contains only the id, title fields as publisher_id field is excluded in the read configuration. /// [TestMethod] - public virtual async Task PatchOne_Update_Test() + public virtual async Task Patch_Update_WithExcludeFieldsTest() { string requestBody = @" { @@ -545,873 +525,205 @@ await SetupAndRunRestApiTest( primaryKeyRoute: "id/8", queryString: null, entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), + sqlQuery: GetQuery(nameof(Patch_Update_WithExcludeFieldsTest)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK + expectedStatusCode: HttpStatusCode.OK, + clientRoleHeader: "test_role_with_excluded_fields" ); + } - requestBody = @" + /// + /// Tests that for a successful PATCH API request, the response returned takes into account the database policy configured for the read action. + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: + /// Status: 200 OK + /// Response Body: Empty. The read action for the role used in this test has a database policy + /// defined which states that title cannot be equal to Test. Since, this test updates the title + /// to Test the response must be empty. + /// + [TestMethod] + public virtual async Task Patch_Update_WithReadDatabasePolicyTest() + { + string requestBody = @" { - ""categoryName"": """" - + ""title"": ""Test"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", + primaryKeyRoute: "id/8", queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_NoReadTest)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK + expectedStatusCode: HttpStatusCode.OK, + clientRoleHeader: "test_role_with_policy_excluded_fields" ); } /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. + /// Tests that for a successful PATCH API request, the response returned takes into account the database policy configured for the read action. + /// URI Path: PK of existing record. + /// Req Body: Valid Parameter with intended update. + /// Expects: + /// Status: 200 OK + /// Response Body: Non-Empty and does not contain the publisher_id field. The read action for the role used in this test has a database policy + /// defined which states that title cannot be equal to Test. Since, this test updates the title + /// to a different the response must be non-empty. Also, since the role excludes the publisher_id field, the repsonse should not + /// contain publisher_id field. /// [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() + public virtual async Task Patch_Update_WithReadDatabasePolicyUnsatisfiedTest() { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. string requestBody = @" { - ""book_name"": ""New book"", - ""copies_sold"": 50 + ""title"": ""Heart of Darkness"" }"; await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", + primaryKeyRoute: "id/8", queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(Patch_Update_WithExcludeFieldsTest)), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK + expectedStatusCode: HttpStatusCode.OK, + clientRoleHeader: "test_role_with_policy_excluded_fields" ); + } - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" + /// + /// Test to validate that for a PATCH API request that results in a successful insert operation, + /// the response returned takes into account that no read action is configured for the role. + /// URI Path: Contains a Non-existent PK. + /// Req Body: Valid Parameter with intended insert data. + /// Expects: + /// Status: 201 Created since the PATCH results in an insert operation + /// Response Body: Empty because the role policy_tester_noread has no read action configured. + /// + [TestMethod] + public virtual async Task PatchInsert_NoReadTest() + { + string requestBody = @" { - ""book_name"": ""New book"", - ""copies_sold"": 50 + ""piecesAvailable"": 4, + ""categoryName"": ""SciFi"", + ""piecesRequired"": 4 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", + primaryKeyRoute: "categoryid/0/pieceid/7", queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchInsert_NoReadTest"), operationType: EntityActionOperation.UpsertIncremental, requestBody: requestBody, expectedStatusCode: HttpStatusCode.Created, + clientRoleHeader: "test_role_with_noread", expectedLocationHeader: string.Empty ); } /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. + /// Tests that for a PATCH API request that results in a successful insert operation, + /// the response returned takes into account the include and exclude fields configured for the read action. + /// URI Path: Contains a non-existent PK. + /// Req Body: Valid Parameter with intended update. + /// Expects: + /// Status: 201 Created as PATCH results in an insert operation. + /// Response Body: Does not contain the categoryName field as it is excluded in the read configuration. /// [TestMethod] - public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy() + public virtual async Task Patch_Insert_WithExcludeFieldsTest() { - return Task.CompletedTask; + string requestBody = @" + { + ""piecesAvailable"": 4, + ""categoryName"": ""SciFi"", + ""piecesRequired"": 4 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "categoryid/0/pieceid/7", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("Patch_Insert_WithExcludeFieldsTest"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + clientRoleHeader: "test_role_with_excluded_fields", + expectedLocationHeader: string.Empty + ); } /// - /// 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, this converts to an Insert operation and succeeds - /// with 201 Created. + /// Tests that for a PATCH API request that results in a successful insert operation, + /// the response returned takes into account the database policy configured for the read action. + /// URI Path: Contains a non-existent PK. + /// Req Body: Valid Parameter with intended update. + /// Expects: + /// Status: 201 Created as PATCH results in an insert operation. + /// Response Body: Empty. The database policy configured for the read action states that piecesAvailable cannot be 0. + /// Since, the PATCH request inserts a record with piecesAvailable = 0, the response must be empty. /// [TestMethod] - public virtual async Task PatchOne_Insert_KeylessWithAutoGenPK_Test() + public virtual async Task Patch_Insert_WithReadDatabasePolicyTest() { string requestBody = @" { - ""title"": ""My New Book"", - ""publisher_id"": 1234 + ""piecesAvailable"": 0, + ""categoryName"": ""SciFi"", + ""piecesRequired"": 4 }"; 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 - ); + primaryKeyRoute: "categoryid/0/pieceid/7", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("PatchInsert_NoReadTest"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + clientRoleHeader: "test_role_with_policy_excluded_fields", + expectedLocationHeader: string.Empty + ); } /// - /// Tests REST PatchOne which results in incremental update - /// URI Path: PK of existing record. + /// Tests that for a PATCH API request that results in a successful insert operation, + /// the response returned takes into account the database policy and the include/exlude fields + /// configured for the read action. + /// URI Path: Contains a non-existent PK. /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. + /// Expects: + /// Status: 201 Created as PATCH results in an insert operation. + /// Response Body: Non-empty and should not contain the categoryName. The database policy configured for the read action states that piecesAvailable cannot be 0. + /// But, the PATCH request inserts a record with piecesAvailable = 4, so the policy is unsatisfied. Hence, the response should be non-empty. + /// The policy also excludes the categoryName field, so the response should not contain the categoryName field. /// [TestMethod] - public virtual async Task PatchOne_Update_Test() + public virtual async Task Patch_Insert_WithReadDatabasePolicyUnsatisfiedTest() { string requestBody = @" { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" + ""piecesAvailable"": 4, + ""categoryName"": ""SciFi"", + ""piecesRequired"": 4 }"; await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""categoryName"": """" - - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - } - - /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. - /// - [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() - { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - string requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty - ); - } - - /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. - /// - [TestMethod] - 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. - /// - [TestMethod] - public virtual async Task PatchOne_Update_Test() - { - string requestBody = @" - { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""categoryName"": """" - - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - } - - /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. - /// - [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() - { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - string requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty - ); - } - - /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. - /// - [TestMethod] - 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. - /// - [TestMethod] - public virtual async Task PatchOne_Update_Test() - { - string requestBody = @" - { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""categoryName"": """" - - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - } - - /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. - /// - [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() - { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - string requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty - ); - } - - /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. - /// - [TestMethod] - 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. - /// - [TestMethod] - public virtual async Task PatchOne_Update_Test() - { - string requestBody = @" - { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""categoryName"": """" - - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - } - - /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. - /// - [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() - { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - string requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty - ); - } - - /// - /// Tests that the PATCH updates can only update the rows which are accessible after applying the - /// security policy which uses data from session context. - /// - [TestMethod] - 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, this converts to an Insert operation 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 REST PatchOne which results in incremental update - /// URI Path: PK of existing record. - /// Req Body: Valid Parameter with intended update. - /// Expects: 200 OK where sqlQuery validates update. - /// - [TestMethod] - public virtual async Task PatchOne_Update_Test() - { - string requestBody = @" - { - ""title"": ""Heart of Darkness"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/8", - queryString: null, - entityNameOrPath: _integrationEntityName, - sqlQuery: GetQuery(nameof(PatchOne_Update_Test)), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""content"": ""That's a great book"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "id/567/book_id/1", - queryString: null, - entityNameOrPath: _entityWithCompositePrimaryKey, - sqlQuery: GetQuery("PatchOne_Update_Default_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""piecesAvailable"": ""10"" - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_CompositeNonAutoGenPK_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - requestBody = @" - { - ""categoryName"": """" - - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: "categoryid/1/pieceid/1", - queryString: null, - entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, - sqlQuery: GetQuery("PatchOne_Update_Empty_Test"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - } - - /// - /// Test to validate successful execution of a request when a computed field is missing from the request body. - /// - [TestMethod] - public virtual async Task PatchOneWithComputedFieldMissingFromRequestBody() - { - // Validate successful execution of a PATCH update when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - string requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/1", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneUpdateWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.OK - ); - - // Validate successful execution of a PATCH insert when a computed field (here 'last_sold_on_date') - // is missing from the request body. Successful execution of the PATCH request confirms that we did not - // attempt to NULL out the 'last_sold_on_update' field. - requestBody = @" - { - ""book_name"": ""New book"", - ""copies_sold"": 50 - }"; - - await SetupAndRunRestApiTest( - primaryKeyRoute: $"id/2", - queryString: null, - entityNameOrPath: _entityWithReadOnlyFields, - sqlQuery: GetQuery("PatchOneInsertWithComputedFieldMissingFromRequestBody"), - operationType: EntityActionOperation.UpsertIncremental, - requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty - ); + primaryKeyRoute: "categoryid/0/pieceid/7", + queryString: null, + entityNameOrPath: _Composite_NonAutoGenPK_EntityPath, + sqlQuery: GetQuery("Patch_Insert_WithExcludeFieldsTest"), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + clientRoleHeader: "test_role_with_policy_excluded_fields", + expectedLocationHeader: string.Empty + ); } #endregion 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/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 1a6178340f..e5e93a2ce5 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -1026,7 +1026,9 @@ await SetupAndRunRestApiTest( /// /// Tests the Put functionality with a REST PUT request /// without a primary key route. With keyless PUT/PATCH support, - /// this converts to an Insert operation and succeeds with 201 Created. + /// this converts to an Insert operation. Since the entity has a + /// non-auto-generated PK and it's missing from the request body, + /// the Insert validation catches it with a BadRequest. /// [TestMethod] public virtual async Task PutWithNoPrimaryKeyRouteTest() @@ -1044,8 +1046,10 @@ await SetupAndRunRestApiTest( sqlQuery: string.Empty, operationType: EntityActionOperation.Upsert, requestBody: requestBody, - expectedStatusCode: HttpStatusCode.Created, - expectedLocationHeader: string.Empty + exceptionExpected: true, + expectedErrorMessage: "Invalid request body. Missing field in body: id.", + expectedStatusCode: HttpStatusCode.BadRequest, + expectedSubStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest.ToString() ); } From e89741ce859bfc3ccd3ca5ebd4b86cdd29cae6b5 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 15:52:53 -0800 Subject: [PATCH 4/9] fix failing tests --- .../OpenApiDocumentor/DocumentVerbosityTests.cs | 12 ++++++------ .../OpenApiDocumentor/ParameterValidationTests.cs | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) 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. From 37094ec08473e6f041b1ee88d2269694126f2ce3 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 11:56:52 -0800 Subject: [PATCH 5/9] address comments --- src/Core/Services/OpenAPI/OpenApiDocumentor.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 419a10749b..c3ccf7b47f 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -43,9 +43,8 @@ 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_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 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 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 DELETE_DESCRIPTION = "Delete entity."; private const string SP_EXECUTE_DESCRIPTION = "Executes a stored procedure."; @@ -514,7 +513,7 @@ private Dictionary CreateOperations( if (configuredRestOperations[OperationType.Put]) { - OpenApiOperation putKeylessOperation = CreateBaseOperation(description: PUT_KEYLESS_DESCRIPTION, tags: tags); + 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); @@ -522,7 +521,7 @@ private Dictionary CreateOperations( if (configuredRestOperations[OperationType.Patch]) { - OpenApiOperation patchKeylessOperation = CreateBaseOperation(description: PATCH_KEYLESS_DESCRIPTION, tags: tags); + 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); From dc1b95a16a20a1113c5aef05e06f21fce90392e2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 12:01:41 -0800 Subject: [PATCH 6/9] help pipeline to pass --- .../Configuration/Telemetry/AzureLogAnalyticsTests.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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); From c5364bc82c7a8c1a40279838ce19896c2f21c6e5 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 3 Mar 2026 14:37:14 -0800 Subject: [PATCH 7/9] move logic into validator --- src/Core/Services/RequestValidator.cs | 49 ++++++++++++++++--- src/Core/Services/RestService.cs | 37 ++++++++------ .../RestApiTests/Patch/PatchApiTestBase.cs | 45 ++++++++++++++--- .../RestApiTests/Put/PutApiTestBase.cs | 46 ++++++++++++++--- 4 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs index ad9e3894f8..aa7ccefc1b 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 primaryKeyInUrl = 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 (primaryKeyInUrl) + { + 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 f7f7fa934d..d8a272f6c8 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -70,14 +70,11 @@ RequestValidator requestValidator ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName]; - // When a PUT or PATCH request arrives without a primary key in the route, - // convert it to an Insert operation. This supports entities with identity/auto-generated - // keys where the caller doesn't know the key value before creation. - if (string.IsNullOrEmpty(primaryKeyRoute) && - (operationType is EntityActionOperation.Upsert || - operationType is EntityActionOperation.UpsertIncremental)) + // Read the request body early so it can be used for downstream processing. + string requestBody = string.Empty; + using (StreamReader reader = new(GetHttpContext().Request.Body)) { - operationType = EntityActionOperation.Insert; + requestBody = await reader.ReadToEndAsync(); } if (dbObject.SourceType is not EntitySourceType.StoredProcedure) @@ -92,12 +89,6 @@ RequestValidator requestValidator QueryString? query = GetHttpContext().Request.QueryString; string queryString = query is null ? string.Empty : GetHttpContext().Request.QueryString.ToString(); - string requestBody = string.Empty; - using (StreamReader reader = new(GetHttpContext().Request.Body)) - { - requestBody = await reader.ReadToEndAsync(); - } - RestRequestContext context; // If request has resolved to a stored procedure entity, initialize and validate appropriate request context @@ -154,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, @@ -163,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/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs index ce219dbddf..ba5971c02c 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs @@ -348,8 +348,9 @@ public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy() /// /// 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, this converts to an Insert operation and succeeds - /// with 201 Created. + /// 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() @@ -957,10 +958,9 @@ await SetupAndRunRestApiTest( /// /// Tests the Patch functionality with a REST PATCH request - /// without a primary key route. With keyless PUT/PATCH support, - /// this converts to an Insert operation. Since the entity has a - /// non-auto-generated PK and it's missing from the request body, - /// the Insert validation catches it with a BadRequest. + /// 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() @@ -985,6 +985,39 @@ await SetupAndRunRestApiTest( ); } + /// + /// 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). diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs index e5e93a2ce5..503a8388a4 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -230,8 +230,9 @@ public virtual Task PutOneUpdateTestOnTableWithSecurityPolicy() /// /// 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, this converts to an Insert operation and succeeds - /// with 201 Created. + /// 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() @@ -1025,10 +1026,9 @@ await SetupAndRunRestApiTest( /// /// Tests the Put functionality with a REST PUT request - /// without a primary key route. With keyless PUT/PATCH support, - /// this converts to an Insert operation. Since the entity has a - /// non-auto-generated PK and it's missing from the request body, - /// the Insert validation catches it with a BadRequest. + /// 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() @@ -1082,6 +1082,40 @@ await SetupAndRunRestApiTest( 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. From 3bf74a05f9f11de156a80e8d2c643184b1b310b5 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:07:03 -0800 Subject: [PATCH 8/9] undo useless raefactor --- src/Core/Services/RestService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Core/Services/RestService.cs b/src/Core/Services/RestService.cs index d8a272f6c8..18b80a37f8 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -70,6 +70,9 @@ RequestValidator requestValidator ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName]; + 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)) @@ -85,10 +88,7 @@ RequestValidator requestValidator { await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement()); } - - QueryString? query = GetHttpContext().Request.QueryString; - string queryString = query is null ? string.Empty : GetHttpContext().Request.QueryString.ToString(); - + RestRequestContext context; // If request has resolved to a stored procedure entity, initialize and validate appropriate request context From 4b78038d8e69b57d338e516134c69d60c3342b35 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:11:01 -0800 Subject: [PATCH 9/9] naming fix --- src/Core/Services/RequestValidator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs index aa7ccefc1b..aef6ca9ab3 100644 --- a/src/Core/Services/RequestValidator.cs +++ b/src/Core/Services/RequestValidator.cs @@ -353,7 +353,7 @@ public void ValidateInsertRequestContext(InsertRequestContext insertRequestCtx) /// 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, bool primaryKeyInUrl = true) + public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx, bool isPrimaryKeyInUrl = true) { ISqlMetadataProvider sqlMetadataProvider = GetSqlMetadataProvider(upsertRequestCtx.EntityName); IEnumerable fieldsInRequestBody = upsertRequestCtx.FieldValuePairsInBody.Keys; @@ -394,7 +394,7 @@ public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx, // all non-auto-generated PK columns are present in the body to form a complete key. if (sourceDefinition.PrimaryKey.Contains(column.Key)) { - if (primaryKeyInUrl) + if (isPrimaryKeyInUrl) { continue; }