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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public class OpenApiDocumentor : IOpenApiDocumentor
private const string GETONE_DESCRIPTION = "Returns an entity.";
private const string POST_DESCRIPTION = "Create entity.";
private const string PUT_DESCRIPTION = "Replace or create entity.";
private const string PUT_PATCH_KEYLESS_DESCRIPTION = "Create entity (keyless). For entities with auto-generated primary keys, creates a new record without requiring the key in the URL.";
private const string PATCH_DESCRIPTION = "Update or create entity.";
private const string DELETE_DESCRIPTION = "Delete entity.";
private const string SP_EXECUTE_DESCRIPTION = "Executes a stored procedure.";
Expand Down Expand Up @@ -502,6 +503,31 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(
openApiPathItemOperations.Add(OperationType.Post, postOperation);
}

// For entities with auto-generated primary keys, add keyless PUT and PATCH operations.
// These routes allow creating records without specifying the primary key in the URL,
// which is useful for entities with identity/auto-generated keys.
if (DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition))
{
string keylessBodySchemaReferenceId = $"{entityName}_NoAutoPK";
bool keylessRequestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true);

if (configuredRestOperations[OperationType.Put])
{
OpenApiOperation putKeylessOperation = CreateBaseOperation(description: PUT_PATCH_KEYLESS_DESCRIPTION, tags: tags);
putKeylessOperation.RequestBody = CreateOpenApiRequestBodyPayload(keylessBodySchemaReferenceId, keylessRequestBodyRequired);
putKeylessOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName));
openApiPathItemOperations.Add(OperationType.Put, putKeylessOperation);
}

if (configuredRestOperations[OperationType.Patch])
{
OpenApiOperation patchKeylessOperation = CreateBaseOperation(description: PUT_PATCH_KEYLESS_DESCRIPTION, tags: tags);
patchKeylessOperation.RequestBody = CreateOpenApiRequestBodyPayload(keylessBodySchemaReferenceId, keylessRequestBodyRequired);
patchKeylessOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName));
openApiPathItemOperations.Add(OperationType.Patch, patchKeylessOperation);
}
}

return openApiPathItemOperations;
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/Core/Services/RestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conversion to Insert is not gated by “auto-generated PK”. Should it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, this is OK because our RequestValidator will handle the validation side of things and we keep a clean separation of responsibility.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed a good question. @aaronburtle, I checked with Copilot, and it says "So: autogenerated PKs do not bypass the URL [primaryKeyRoute] requirement for update/upsert/delete. For insert, a URL PK is disallowed regardless, in [RequestValidator.cs:224-232].

public void ValidatePrimaryKey(RestRequestContext context) see this function.

You need to fix the exception that we throw there, in cases of Patch/Put to not check for existence of primaryKey in the route if they are autogenerated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed a good question. @aaronburtle, I checked with Copilot, and it says "So: autogenerated PKs do not bypass the URL [primaryKeyRoute] requirement for update/upsert/delete. For insert, a URL PK is disallowed regardless, in [RequestValidator.cs:224-232].

public void ValidatePrimaryKey(RestRequestContext context) see this function.

You need to fix the exception that we throw there, in cases of Patch/Put to not check for existence of primaryKey in the route if they are autogenerated.

We should be OK, because we are changing the Patch/Put to an insert operation already in RestService

if (string.IsNullOrEmpty(primaryKeyRoute) && (operationType is EntityActionOperation.Upsert || operationType is EntityActionOperation.UpsertIncremental)) { operationType = EntityActionOperation.Insert; }

And then when we do the validation on lines 224-232 of RequestValidator we expect there to be no Primary Key, so keyless Put Patch should be fine, since we've converted to insert and then have the right behavior

case EntityActionOperation.Insert: if (!isPrimaryKeyRouteEmpty) { throw new DataApiBuilderException( message: PRIMARY_KEY_INVALID_USAGE_ERR_MESSAGE, statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like the conversion to Insert operation happens before we do the Request Validation. In that case, we should be ok with validation. But, then we should be doing the conversion to Insert only when the PKs are autogenerated. Otherwise, we should NOT convert into Insert and continue to throw the exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want this behavior because they could include the PKs in the body of the request, in which case we should still be able to do the insert. The logic would be to convert to Insert even for non auto-gen, then the Insert validation logic applies, and if it is non-auto-gen it must include the keys in the body, and only if those keys are missing from the body as well, we would throw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is effectively going beyond the scope of this underlying issue, however, we get this additional functionality for free just by letting the request validator handle the insert op, so seems worth it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So after speaking offline we have identified the correct behavior we want, will provide it here for clarity.

In a truly keyless PUT/PATCH, we will convert to insert op, that means no key in URL, no key in body. The Insert validation will just work in this case. It will succeed if we are auto-gen, and fail if we are not. This will require identifying when we have keys in the body when the URL is missing keys.

For PUT/PATCH where the URL is keyless, but we have the keys in the body, we want to use Upsert semantics, so we will not convert to an insert. This will require new validation logic.

operationType = EntityActionOperation.Insert;
}

if (dbObject.SourceType is not EntitySourceType.StoredProcedure)
{
await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why start with such a low pollIntervalMs if the original delay was 2000ms? Perhaps start with that.

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);
Expand Down
12 changes: 6 additions & 6 deletions src/Service.Tests/OpenApiDocumentor/DocumentVerbosityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class DocumentVerbosityTests
private const string UNEXPECTED_CONTENTS_ERROR = "Unexpected number of response objects to validate.";

/// <summary>
/// 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:
Expand All @@ -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)
/// </summary>
[TestMethod]
public async Task ResponseObjectSchemaIncludesTypeProperty()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ public class MsSqlPatchApiTests : PatchApiTestBase
{
private static Dictionary<string, string> _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 } " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ public class MySqlPatchApiTests : PatchApiTestBase
{
protected static Dictionary<string, string> _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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -346,6 +345,33 @@ public virtual Task PatchOneUpdateTestOnTableWithSecurityPolicy()
return Task.CompletedTask;
}

/// <summary>
/// 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.
/// </summary>
[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
);
}

/// <summary>
/// Tests successful execution of PATCH update requests on views
/// when requests try to modify fields belonging to one base table
Expand Down Expand Up @@ -931,8 +957,10 @@ await SetupAndRunRestApiTest(

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public virtual async Task PatchWithNoPrimaryKeyRouteTest()
Expand All @@ -951,8 +979,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()
);
}

Expand Down Expand Up @@ -988,7 +1017,7 @@ await SetupAndRunRestApiTest(
}

/// <summary>
/// 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).
/// </summary>
[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ public class PostgreSqlPatchApiTests : PatchApiTestBase
{
protected static Dictionary<string, string> _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",
@"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ public class MsSqlPutApiTests : PutApiTestBase
{
private static Dictionary<string, string> _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 } " +
Expand Down
12 changes: 12 additions & 0 deletions src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ public class MySqlPutApiTests : PutApiTestBase
{
protected static Dictionary<string, string> _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",
@"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ public class PostgreSqlPutApiTests : PutApiTestBase
{
protected static Dictionary<string, string> _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",
@"
Expand Down
Loading
Loading