Skip to content

Commit 08aa656

Browse files
CopilotJerryNixon
andcommitted
Refactor OpenAPI document generation and optimize schema creation
Architectural improvements per @JerryNixon feedback: 1. **Extract shared document building logic**: - New `BuildOpenApiDocument` method contains shared logic for both superset and role-specific documents - Eliminates duplication between `CreateDocument` and `GenerateDocumentForRole` - Future changes to document structure only need to be made in one place 2. **Validate role parameter doesn't contain path separators**: - Added check to reject roles containing '/' (e.g., /openapi/foo/bar) - Ensures role parameter is a single segment, not a multi-part path 3. **Only generate request body schemas when mutation operations exist**: - Check `GetConfiguredRestOperations` before creating _NoAutoPK and _NoPK schemas - For tables/views: only generate if POST, PUT, or PATCH operations are available - For stored procedures: only generate _sp_request if operations that use it exist - Reduces document size and eliminates unused schemas for read-only entities These changes improve code maintainability, prevent route confusion, and optimize document size. Co-authored-by: JerryNixon <[email protected]>
1 parent d0cd41b commit 08aa656

2 files changed

Lines changed: 69 additions & 77 deletions

File tree

src/Core/Services/OpenAPI/OpenApiDocumentor.cs

Lines changed: 59 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? d
176176
/// Generates an OpenAPI document filtered for a specific role.
177177
/// </summary>
178178
private OpenApiDocument? GenerateDocumentForRole(RuntimeConfig runtimeConfig, string role)
179+
{
180+
string title = $"{DOCUMENTOR_UI_TITLE} - {role}";
181+
return BuildOpenApiDocument(runtimeConfig, role, title);
182+
}
183+
184+
/// <summary>
185+
/// Builds an OpenAPI document with optional role-based filtering.
186+
/// Shared logic for both superset and role-specific document generation.
187+
/// </summary>
188+
/// <param name="runtimeConfig">Runtime configuration.</param>
189+
/// <param name="role">Optional role to filter permissions. If null, returns superset of all roles.</param>
190+
/// <param name="title">Document title.</param>
191+
/// <returns>OpenAPI document.</returns>
192+
private OpenApiDocument BuildOpenApiDocument(RuntimeConfig runtimeConfig, string? role, string title)
179193
{
180194
string restEndpointPath = runtimeConfig.RestPath;
181195
string? runtimeBaseRoute = runtimeConfig.Runtime?.BaseRoute;
@@ -208,8 +222,7 @@ public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? d
208222
Info = new OpenApiInfo
209223
{
210224
Version = ProductInfo.GetProductVersion(),
211-
// Use the role name directly since it was already validated to exist in permissions
212-
Title = $"{DOCUMENTOR_UI_TITLE} - {role}"
225+
Title = title
213226
},
214227
Servers = new List<OpenApiServer>
215228
{
@@ -250,48 +263,7 @@ public void CreateDocument(bool doOverrideExistingDocument = false)
250263

251264
try
252265
{
253-
string restEndpointPath = runtimeConfig.RestPath;
254-
string? runtimeBaseRoute = runtimeConfig.Runtime?.BaseRoute;
255-
string url = string.IsNullOrEmpty(runtimeBaseRoute) ? restEndpointPath : runtimeBaseRoute + "/" + restEndpointPath;
256-
OpenApiComponents components = new()
257-
{
258-
Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role: null, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict)
259-
};
260-
261-
// Collect all entity tags and their descriptions for the top-level tags array
262-
// Only include entities that have REST enabled and at least one available operation
263-
List<OpenApiTag> globalTags = new();
264-
foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities)
265-
{
266-
Entity entity = kvp.Value;
267-
if (!entity.Rest.Enabled || !HasAnyAvailableOperations(entity))
268-
{
269-
continue;
270-
}
271-
272-
string restPath = entity.Rest?.Path ?? kvp.Key;
273-
globalTags.Add(new OpenApiTag
274-
{
275-
Name = restPath,
276-
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
277-
});
278-
}
279-
280-
OpenApiDocument doc = new()
281-
{
282-
Info = new OpenApiInfo
283-
{
284-
Version = ProductInfo.GetProductVersion(),
285-
Title = DOCUMENTOR_UI_TITLE
286-
},
287-
Servers = new List<OpenApiServer>
288-
{
289-
new() { Url = url }
290-
},
291-
Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName),
292-
Components = components,
293-
Tags = globalTags
294-
};
266+
OpenApiDocument doc = BuildOpenApiDocument(runtimeConfig, role: null, title: DOCUMENTOR_UI_TITLE);
295267
_openApiDocument = doc;
296268
}
297269
catch (Exception ex)
@@ -1314,13 +1286,21 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
13141286
// Filter fields based on the superset of permissions across all roles (or specific role)
13151287
exposedColumnNames = FilterFieldsByPermissions(entity, exposedColumnNames, role);
13161288

1289+
// Get configured operations to determine which schemas to generate
1290+
Dictionary<OperationType, bool> configuredOps = GetConfiguredRestOperations(entity, dbObject, role);
1291+
bool hasPostOperation = configuredOps.GetValueOrDefault(OperationType.Post);
1292+
bool hasPutPatchOperation = configuredOps.GetValueOrDefault(OperationType.Put) || configuredOps.GetValueOrDefault(OperationType.Patch);
1293+
13171294
HashSet<string> nonAutoGeneratedPKColumnNames = new();
13181295

13191296
if (dbObject.SourceType is EntitySourceType.StoredProcedure)
13201297
{
1321-
// Request body schema whose properties map to stored procedure parameters
1322-
DatabaseStoredProcedure spObject = (DatabaseStoredProcedure)dbObject;
1323-
schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters, isRequestBodyStrict: isRequestBodyStrict));
1298+
// Only generate request body schema if SP has operations that use it
1299+
if (hasPostOperation || hasPutPatchOperation)
1300+
{
1301+
DatabaseStoredProcedure spObject = (DatabaseStoredProcedure)dbObject;
1302+
schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters, isRequestBodyStrict: isRequestBodyStrict));
1303+
}
13241304

13251305
// Response body schema whose properties map to the stored procedure's first result set columns
13261306
// as described by sys.dm_exec_describe_first_result_set.
@@ -1334,43 +1314,47 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
13341314
// Response schemas don't need additionalProperties restriction
13351315
schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: false));
13361316

1337-
// Create an entity's request body component schema excluding autogenerated primary keys.
1338-
// A POST request requires any non-autogenerated primary key references to be in the request body.
1339-
foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey)
1317+
// Only generate request body schemas if mutation operations are available
1318+
if (hasPostOperation || hasPutPatchOperation)
13401319
{
1341-
// Non-Autogenerated primary key(s) should appear in the request body.
1342-
if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated)
1320+
// Create an entity's request body component schema excluding autogenerated primary keys.
1321+
// A POST request requires any non-autogenerated primary key references to be in the request body.
1322+
foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey)
13431323
{
1344-
nonAutoGeneratedPKColumnNames.Add(primaryKeyColumn);
1345-
continue;
1346-
}
1324+
// Non-Autogenerated primary key(s) should appear in the request body.
1325+
if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated)
1326+
{
1327+
nonAutoGeneratedPKColumnNames.Add(primaryKeyColumn);
1328+
continue;
1329+
}
13471330

1348-
if (metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName)
1349-
&& exposedColumnName is not null)
1350-
{
1351-
exposedColumnNames.Remove(exposedColumnName);
1331+
if (metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName)
1332+
&& exposedColumnName is not null)
1333+
{
1334+
exposedColumnNames.Remove(exposedColumnName);
1335+
}
13521336
}
1353-
}
13541337

1355-
// Request body schema for POST - apply additionalProperties based on strict mode
1356-
schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict));
1338+
// Request body schema for POST - apply additionalProperties based on strict mode
1339+
schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict));
13571340

1358-
// Create an entity's request body component schema excluding all primary keys
1359-
// by removing the tracked non-autogenerated primary key column names and removing them from
1360-
// the exposedColumnNames collection.
1361-
// The schema component without primary keys is used for PUT and PATCH operation request bodies because
1362-
// those operations require all primary key references to be in the URI path, not the request body.
1363-
foreach (string primaryKeyColumn in nonAutoGeneratedPKColumnNames)
1364-
{
1365-
if (metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName)
1366-
&& exposedColumnName is not null)
1341+
// Create an entity's request body component schema excluding all primary keys
1342+
// by removing the tracked non-autogenerated primary key column names and removing them from
1343+
// the exposedColumnNames collection.
1344+
// The schema component without primary keys is used for PUT and PATCH operation request bodies because
1345+
// those operations require all primary key references to be in the URI path, not the request body.
1346+
foreach (string primaryKeyColumn in nonAutoGeneratedPKColumnNames)
13671347
{
1368-
exposedColumnNames.Remove(exposedColumnName);
1348+
if (metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName)
1349+
&& exposedColumnName is not null)
1350+
{
1351+
exposedColumnNames.Remove(exposedColumnName);
1352+
}
13691353
}
1370-
}
13711354

1372-
// Request body schema for PUT/PATCH - apply additionalProperties based on strict mode
1373-
schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict));
1355+
// Request body schema for PUT/PATCH - apply additionalProperties based on strict mode
1356+
schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict));
1357+
}
13741358
}
13751359
}
13761360

src/Service/Controllers/RestController.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,16 @@ private async Task<IActionResult> HandleOperation(
243243
return NotFound();
244244
}
245245

246-
string role = Uri.UnescapeDataString(routeAfterPathBase.Substring(OpenApiDocumentor.OPENAPI_ROUTE.Length + 1));
247-
if (!string.IsNullOrEmpty(role) && _openApiDocumentor.TryGetDocumentForRole(role, out string? roleDocument))
246+
string role = Uri.UnescapeDataString(
247+
routeAfterPathBase.Substring(OpenApiDocumentor.OPENAPI_ROUTE.Length + 1));
248+
249+
// Validate role doesn't contain path separators (reject /openapi/foo/bar)
250+
if (string.IsNullOrEmpty(role) || role.Contains('/'))
251+
{
252+
return NotFound();
253+
}
254+
255+
if (_openApiDocumentor.TryGetDocumentForRole(role, out string? roleDocument))
248256
{
249257
return Content(roleDocument, MediaTypeNames.Application.Json);
250258
}

0 commit comments

Comments
 (0)