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
49 changes: 42 additions & 7 deletions src/Core/Services/RequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,12 @@ public void ValidateInsertRequestContext(InsertRequestContext insertRequestCtx)
/// and vice versa.
/// </summary>
/// <param name="upsertRequestCtx">Upsert Request context containing the request body.</param>
/// <param name="primaryKeyInUrl">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.</param>
/// <exception cref="DataApiBuilderException"></exception>
public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx)
public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx, bool isPrimaryKeyInUrl = true)
{
ISqlMetadataProvider sqlMetadataProvider = GetSqlMetadataProvider(upsertRequestCtx.EntityName);
IEnumerable<string> fieldsInRequestBody = upsertRequestCtx.FieldValuePairsInBody.Keys;
Expand Down Expand Up @@ -385,13 +389,45 @@ public void ValidateUpsertRequestContext(UpsertRequestContext upsertRequestCtx)
unValidatedFields.Remove(exposedName!);
}

// Primary Key(s) should not be present in the request body. We do not fail a request
// if a PK is autogenerated here, because an UPSERT request may only need to update a
// record. If an insert occurs on a table with autogenerated primary key,
// a database error will be returned.
// When the primary key is provided in the URL route, skip PK columns in body validation.
// When the primary key is NOT in the URL (body-based PK), we need to validate that
// all non-auto-generated PK columns are present in the body to form a complete key.
if (sourceDefinition.PrimaryKey.Contains(column.Key))
{
continue;
if (isPrimaryKeyInUrl)
{
continue;
}
else
{
// Body-based PK: non-auto-generated PK columns MUST be present.
// Auto-generated PK columns are skipped — they cannot be supplied by the caller.
if (column.Value.IsAutoGenerated)
{
continue;
}

if (!fieldsInRequestBody.Contains(exposedName))
{
throw new DataApiBuilderException(
message: $"Invalid request body. Missing field in body: {exposedName}.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}

// PK value must not be null for non-nullable PK columns.
if (!column.Value.IsNullable &&
upsertRequestCtx.FieldValuePairsInBody[exposedName!] is null)
{
throw new DataApiBuilderException(
message: $"Invalid value for field {exposedName} in request body.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}

unValidatedFields.Remove(exposedName!);
continue;
}
}

// Request body must have value defined for included non-nullable columns
Expand Down Expand Up @@ -488,7 +524,6 @@ public void ValidateEntity(string entityName)
/// Tries to get the table definition for the given entity from the Metadata provider.
/// </summary>
/// <param name="entityName">Target entity name.</param>
/// enables referencing DB schema.</param>
/// <exception cref="DataApiBuilderException"></exception>

private static SourceDefinition TryGetSourceDefinition(string entityName, ISqlMetadataProvider sqlMetadataProvider)
Expand Down
41 changes: 29 additions & 12 deletions src/Core/Services/RestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,24 +70,25 @@ RequestValidator requestValidator
ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName);
DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName];

if (dbObject.SourceType is not EntitySourceType.StoredProcedure)
{
await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement());
}
else
{
await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement());
}

QueryString? query = GetHttpContext().Request.QueryString;
string queryString = query is null ? string.Empty : GetHttpContext().Request.QueryString.ToString();


// Read the request body early so it can be used for downstream processing.
string requestBody = string.Empty;
using (StreamReader reader = new(GetHttpContext().Request.Body))
{
requestBody = await reader.ReadToEndAsync();
}

if (dbObject.SourceType is not EntitySourceType.StoredProcedure)
{
await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement());
}
else
{
await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement());
}

RestRequestContext context;

// If request has resolved to a stored procedure entity, initialize and validate appropriate request context
Expand Down Expand Up @@ -144,7 +145,21 @@ RequestValidator requestValidator
case EntityActionOperation.UpdateIncremental:
case EntityActionOperation.Upsert:
case EntityActionOperation.UpsertIncremental:
RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute);
// For Upsert/UpsertIncremental, a keyless URL is allowed. When the
// primary key route is absent, ValidateUpsertRequestContext checks that
// the body contains all non-auto-generated PK columns so the mutation
// engine can resolve the target row (or insert a new one).
// Update/UpdateIncremental always require the PK in the URL.
if (!string.IsNullOrEmpty(primaryKeyRoute))
{
RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute);
}
else if (operationType is not EntityActionOperation.Upsert and
not EntityActionOperation.UpsertIncremental)
{
RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(operationType, primaryKeyRoute);
}

JsonElement upsertPayloadRoot = RequestValidator.ValidateAndParseRequestBody(requestBody);
context = new UpsertRequestContext(
entityName,
Expand All @@ -153,7 +168,9 @@ RequestValidator requestValidator
operationType);
if (context.DatabaseObject.SourceType is EntitySourceType.Table)
{
_requestValidator.ValidateUpsertRequestContext((UpsertRequestContext)context);
_requestValidator.ValidateUpsertRequestContext(
(UpsertRequestContext)context,
primaryKeyInUrl: !string.IsNullOrEmpty(primaryKeyRoute));
}

break;
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;
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
Loading
Loading