Skip to content
37 changes: 22 additions & 15 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string
{
new() { Url = url }
},
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role),
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, globalTagsDict, role, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict),
Components = components,
Tags = globalTagsDict.Values.ToList()
};
Expand Down Expand Up @@ -300,7 +300,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
/// <param name="globalTags">Dictionary of global tags keyed by normalized REST path for reuse.</param>
/// <param name="role">Optional role to filter permissions. If null, returns superset of all roles.</param>
/// <returns>All possible paths in the DAB engine's REST API endpoint.</returns>
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary<string, OpenApiTag> globalTags, string? role = null)
private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, Dictionary<string, OpenApiTag> globalTags, string? role = null, bool isRequestBodyStrict = true)
{
OpenApiPaths pathsCollection = new();

Expand Down Expand Up @@ -377,7 +377,8 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour
sourceDefinition: sourceDefinition,
includePrimaryKeyPathComponent: true,
configuredRestOperations: configuredRestOperations,
tags: tags);
tags: tags,
isRequestBodyStrict: isRequestBodyStrict);

if (pkOperations.Count > 0)
{
Expand All @@ -400,7 +401,8 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour
sourceDefinition: sourceDefinition,
includePrimaryKeyPathComponent: false,
configuredRestOperations: configuredRestOperations,
tags: tags);
tags: tags,
isRequestBodyStrict: isRequestBodyStrict);

if (operations.Count > 0)
{
Expand Down Expand Up @@ -435,7 +437,8 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(
SourceDefinition sourceDefinition,
bool includePrimaryKeyPathComponent,
Dictionary<OperationType, bool> configuredRestOperations,
List<OpenApiTag> tags)
List<OpenApiTag> tags,
bool isRequestBodyStrict = true)
{
Dictionary<OperationType, OpenApiOperation> openApiPathItemOperations = new();

Expand All @@ -457,7 +460,8 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(
if (configuredRestOperations[OperationType.Put])
{
OpenApiOperation putOperation = CreateBaseOperation(description: PUT_DESCRIPTION, tags: tags);
putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired);
string putPatchSchemaRef = isRequestBodyStrict ? $"{entityName}_NoPK" : entityName;
putOperation.RequestBody = CreateOpenApiRequestBodyPayload(putPatchSchemaRef, requestBodyRequired);
putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName));
putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName));
openApiPathItemOperations.Add(OperationType.Put, putOperation);
Expand All @@ -466,7 +470,8 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(
if (configuredRestOperations[OperationType.Patch])
{
OpenApiOperation patchOperation = CreateBaseOperation(description: PATCH_DESCRIPTION, tags: tags);
patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired);
string patchSchemaRef = isRequestBodyStrict ? $"{entityName}_NoPK" : entityName;
Comment thread
aaronburtle marked this conversation as resolved.
patchOperation.RequestBody = CreateOpenApiRequestBodyPayload(patchSchemaRef, requestBodyRequired);
patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName));
patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName));
openApiPathItemOperations.Add(OperationType.Patch, patchOperation);
Expand Down Expand Up @@ -496,7 +501,7 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(

if (configuredRestOperations[OperationType.Post])
{
string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}";
string postBodySchemaReferenceId = isRequestBodyStrict && DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}";
OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags);
postOperation.RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true));
postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName));
Expand All @@ -509,7 +514,7 @@ private Dictionary<OperationType, OpenApiOperation> CreateOperations(
// which is useful for entities with identity/auto-generated keys.
if (DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition))
{
string keylessBodySchemaReferenceId = $"{entityName}_NoAutoPK";
string keylessBodySchemaReferenceId = isRequestBodyStrict ? $"{entityName}_NoAutoPK" : entityName;
bool keylessRequestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true);

if (configuredRestOperations[OperationType.Put])
Expand Down Expand Up @@ -1276,14 +1281,16 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch

/// <summary>
/// Builds the schema objects for all entities present in the runtime configuration.
/// Two schemas per entity are created:
/// 1) {EntityName} -> Primary keys present in schema, used for request bodies (excluding GET) and all response bodies.
/// 2) {EntityName}_NoAutoPK -> No auto-generated primary keys present in schema, used for POST requests where PK is not autogenerated and GET (all).
/// 3) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all).
/// When isRequestBodyStrict is true, up to three schemas per entity are created:
/// 1) {EntityName} -> All columns including primary keys. Used for response bodies and as request body when strict mode is off.
/// 2) {EntityName}_NoAutoPK -> Excludes auto-generated primary keys. Used for POST request bodies (strict mode only).
/// 3) {EntityName}_NoPK -> Excludes all primary keys. Used for PUT/PATCH request bodies (strict mode only).
/// When isRequestBodyStrict is false, only the base {EntityName} schema is created and all
/// request body operations reference it directly, since extra properties are allowed.
/// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity.
/// </summary>
/// <param name="role">Optional role to filter permissions. If null, returns superset of all roles.</param>
/// <param name="isRequestBodyStrict">When true, request body schemas disallow extra fields.</param>
/// <param name="isRequestBodyStrict">When true, generates separate request body schemas that disallow extra fields.</param>
/// <returns>Collection of schemas for entities defined in the runtime configuration.</returns>
private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName, string? role = null, bool isRequestBodyStrict = true)
{
Expand Down Expand Up @@ -1342,7 +1349,7 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: false));

// Only generate request body schemas if mutation operations are available
if (hasPostOperation || hasPutPatchOperation)
if (isRequestBodyStrict && (hasPostOperation || hasPutPatchOperation))
{
// Create an entity's request body component schema excluding autogenerated primary keys.
Comment thread
aaronburtle marked this conversation as resolved.
// A POST request requires any non-autogenerated primary key references to be in the request body.
Expand Down
55 changes: 47 additions & 8 deletions src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,61 @@ public async Task RequestBodyStrict_True_DisallowsExtraFields()
}

/// <summary>
/// Validates that when request-body-strict is false, request body schemas
/// have additionalProperties set to true.
/// Validates that when request-body-strict is false, the redundant _NoAutoPK and _NoPK
/// schemas are not generated. Operations reference the base entity schema instead.
/// </summary>
[TestMethod]
public async Task RequestBodyStrict_False_AllowsExtraFields()
public async Task RequestBodyStrict_False_OmitsRedundantSchemas()
{
OpenApiDocument doc = await GenerateDocumentWithPermissions(
OpenApiTestBootstrap.CreateBasicPermissions(),
requestBodyStrict: false);

// Request body schemas should have additionalProperties = true
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should exist");
Assert.IsTrue(doc.Components.Schemas["book_NoAutoPK"].AdditionalPropertiesAllowed, "POST request body should allow extra fields in non-strict mode");
// _NoAutoPK and _NoPK schemas should not be generated when strict mode is off
Assert.IsFalse(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should not exist in non-strict mode");
Assert.IsFalse(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should not exist in non-strict mode");

Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should exist");
Assert.IsTrue(doc.Components.Schemas["book_NoPK"].AdditionalPropertiesAllowed, "PUT/PATCH request body should allow extra fields in non-strict mode");
// Base entity schema should still exist
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Base entity schema should exist");
Comment thread
aaronburtle marked this conversation as resolved.

// Operations (POST/PUT/PATCH) should reference the base 'book' schema for their request bodies
bool foundRequestBodyForWritableOperation = false;
foreach (OpenApiPathItem pathItem in doc.Paths.Values)
{
foreach (KeyValuePair<OperationType, OpenApiOperation> operationKvp in pathItem.Operations)
{
OperationType operationType = operationKvp.Key;
OpenApiOperation operation = operationKvp.Value;

if (operationType != OperationType.Post
&& operationType != OperationType.Put
&& operationType != OperationType.Patch)
{
continue;
}

if (operation.RequestBody is null)
{
continue;
}

if (!operation.RequestBody.Content.TryGetValue("application/json", out OpenApiMediaType mediaType)
|| mediaType.Schema is null)
{
continue;
}

foundRequestBodyForWritableOperation = true;
OpenApiSchema schema = mediaType.Schema;

Assert.IsNotNull(schema.Reference, "Request body schema should reference a component schema when request-body-strict is false.");
Assert.AreEqual("book", schema.Reference.Id, "Request body should reference the base 'book' schema when request-body-strict is false.");
Assert.AreNotEqual("book_NoAutoPK", schema.Reference.Id, "Request body should not reference the 'book_NoAutoPK' schema when request-body-strict is false.");
Assert.AreNotEqual("book_NoPK", schema.Reference.Id, "Request body should not reference the 'book_NoPK' schema when request-body-strict is false.");
}
}

Assert.IsTrue(foundRequestBodyForWritableOperation, "Expected at least one POST/PUT/PATCH operation with a JSON request body.");
}

private static async Task<OpenApiDocument> GenerateDocumentWithPermissions(EntityPermission[] permissions, bool? requestBodyStrict = null)
Expand Down