OpenAPI query param discovery and header cleanup completion
Backend: ExtractParameters() now discovers query params from [AsParameters] records and [FromQuery] attributes via handler method reflection. Gateway OpenApiDocumentGenerator emits parameters arrays in the aggregated spec. QueryParameterInfo added to EndpointSchemaInfo for HELLO payload transport. Frontend: Remaining spec files and straggler services updated to canonical X-Stella-Ops-* header names. Sprint 026 archived (tasks 01-06 DONE, 07-09 TODO for backend service rename pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,10 +353,12 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider :
|
||||
private IReadOnlyList<ParameterDescriptor> ExtractParameters(RouteEndpoint endpoint)
|
||||
{
|
||||
var parameters = new List<ParameterDescriptor>();
|
||||
var routeParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Extract route parameters from pattern
|
||||
foreach (var param in endpoint.RoutePattern.Parameters)
|
||||
{
|
||||
routeParamNames.Add(param.Name);
|
||||
parameters.Add(new ParameterDescriptor
|
||||
{
|
||||
Name = param.Name,
|
||||
@@ -382,9 +384,158 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider :
|
||||
});
|
||||
}
|
||||
|
||||
// Extract query parameters from handler method signature.
|
||||
// Inspects [AsParameters] records and [FromQuery] attributes on delegate parameters.
|
||||
ExtractQueryParameters(endpoint, routeParamNames, parameters);
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private void ExtractQueryParameters(
|
||||
RouteEndpoint endpoint,
|
||||
HashSet<string> routeParamNames,
|
||||
List<ParameterDescriptor> parameters)
|
||||
{
|
||||
// Resolve the handler delegate's MethodInfo from endpoint metadata.
|
||||
var methodInfo = endpoint.Metadata.GetMetadata<MethodInfo>();
|
||||
if (methodInfo is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var param in methodInfo.GetParameters())
|
||||
{
|
||||
// Skip well-known service types injected by DI.
|
||||
if (IsServiceType(param.ParameterType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip CancellationToken.
|
||||
if (param.ParameterType == typeof(CancellationToken))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip route params (already handled).
|
||||
if (routeParamNames.Contains(param.Name ?? ""))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [AsParameters] — expand the record's public properties as query params.
|
||||
var asParametersAttr = param.GetCustomAttributes()
|
||||
.FirstOrDefault(a => a.GetType().Name is "AsParametersAttribute");
|
||||
if (asParametersAttr is not null)
|
||||
{
|
||||
ExpandAsParametersRecord(param.ParameterType, routeParamNames, parameters);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for explicit [FromQuery].
|
||||
var fromQueryAttr = param.GetCustomAttributes()
|
||||
.FirstOrDefault(a => a.GetType().Name is "FromQueryAttribute");
|
||||
if (fromQueryAttr is not null)
|
||||
{
|
||||
var queryName = GetFromQueryName(fromQueryAttr) ?? ToCamelCase(param.Name ?? "");
|
||||
if (!string.IsNullOrEmpty(queryName) && !routeParamNames.Contains(queryName))
|
||||
{
|
||||
var isNullable = Nullable.GetUnderlyingType(param.ParameterType) is not null ||
|
||||
!param.ParameterType.IsValueType ||
|
||||
param.HasDefaultValue;
|
||||
parameters.Add(new ParameterDescriptor
|
||||
{
|
||||
Name = queryName,
|
||||
Source = ParameterSource.Query,
|
||||
Type = param.ParameterType,
|
||||
IsRequired = !isNullable && !param.HasDefaultValue,
|
||||
DefaultValue = param.HasDefaultValue ? param.DefaultValue : null,
|
||||
JsonSchemaType = GetJsonSchemaType(param.ParameterType)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExpandAsParametersRecord(
|
||||
Type recordType,
|
||||
HashSet<string> routeParamNames,
|
||||
List<ParameterDescriptor> parameters)
|
||||
{
|
||||
var props = recordType
|
||||
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.Where(p => p.GetMethod is { IsPublic: true } && p.GetIndexParameters().Length == 0);
|
||||
|
||||
foreach (var prop in props)
|
||||
{
|
||||
// Skip well-known service types.
|
||||
if (IsServiceType(prop.PropertyType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var queryName = ToCamelCase(prop.Name);
|
||||
|
||||
// Check for explicit [FromQuery] on the property with a custom Name.
|
||||
var fromQueryAttr = prop.GetCustomAttributes()
|
||||
.FirstOrDefault(a => a.GetType().Name is "FromQueryAttribute");
|
||||
if (fromQueryAttr is not null)
|
||||
{
|
||||
queryName = GetFromQueryName(fromQueryAttr) ?? queryName;
|
||||
}
|
||||
|
||||
// Skip if this is a route param.
|
||||
if (routeParamNames.Contains(queryName) || routeParamNames.Contains(prop.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null ||
|
||||
!prop.PropertyType.IsValueType;
|
||||
|
||||
parameters.Add(new ParameterDescriptor
|
||||
{
|
||||
Name = queryName,
|
||||
Source = ParameterSource.Query,
|
||||
Type = prop.PropertyType,
|
||||
IsRequired = !isNullable,
|
||||
JsonSchemaType = GetJsonSchemaType(prop.PropertyType)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetFromQueryName(Attribute attr)
|
||||
{
|
||||
// FromQueryAttribute.Name property.
|
||||
var nameProp = attr.GetType().GetProperty("Name");
|
||||
return nameProp?.GetValue(attr) as string;
|
||||
}
|
||||
|
||||
private static bool IsServiceType(Type type)
|
||||
{
|
||||
// Filter out DI-injected services, HttpContext, ClaimsPrincipal, etc.
|
||||
if (type.Namespace is not null &&
|
||||
(type.Namespace.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal) ||
|
||||
type.Namespace.StartsWith("Microsoft.Extensions", StringComparison.Ordinal) ||
|
||||
type.Namespace.StartsWith("System.Security", StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type == typeof(CancellationToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Interfaces are usually DI services.
|
||||
if (type.IsInterface)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private IReadOnlyList<ResponseDescriptor> ExtractResponses(RouteEndpoint endpoint)
|
||||
{
|
||||
var responses = new List<ResponseDescriptor>();
|
||||
@@ -502,12 +653,24 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider :
|
||||
: null;
|
||||
var responseStatusCode = GetSuccessResponseStatusCode(descriptor);
|
||||
|
||||
// Collect query parameters for OpenAPI.
|
||||
var queryParams = descriptor.Parameters
|
||||
.Where(p => p.Source == ParameterSource.Query)
|
||||
.Select(p => new QueryParameterInfo
|
||||
{
|
||||
Name = p.Name,
|
||||
Type = p.JsonSchemaType ?? "string",
|
||||
Required = p.IsRequired,
|
||||
Description = p.Description
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var hasOpenApiMetadata =
|
||||
!string.IsNullOrWhiteSpace(descriptor.Summary) ||
|
||||
!string.IsNullOrWhiteSpace(descriptor.Description) ||
|
||||
descriptor.Tags.Count > 0;
|
||||
|
||||
if (requestSchemaId is null && responseSchemaId is null && !hasOpenApiMetadata)
|
||||
if (requestSchemaId is null && responseSchemaId is null && !hasOpenApiMetadata && queryParams.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -521,7 +684,8 @@ public sealed partial class AspNetCoreEndpointDiscoveryProvider :
|
||||
ResponseStatusCode = responseStatusCode,
|
||||
Summary = descriptor.Summary,
|
||||
Description = descriptor.Description,
|
||||
Tags = descriptor.Tags
|
||||
Tags = descriptor.Tags,
|
||||
QueryParameters = queryParams
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,4 +41,29 @@ public sealed record EndpointSchemaInfo
|
||||
/// Gets a value indicating whether this endpoint is deprecated.
|
||||
/// </summary>
|
||||
public bool Deprecated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the query parameter descriptors for this endpoint.
|
||||
/// Each entry contains the parameter name, JSON Schema type, and whether it is required.
|
||||
/// Propagated via HELLO and consumed by the gateway OpenAPI generator.
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueryParameterInfo> QueryParameters { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight query parameter descriptor that can be serialized in the HELLO payload.
|
||||
/// </summary>
|
||||
public sealed record QueryParameterInfo
|
||||
{
|
||||
/// <summary>Parameter name as it appears in the query string.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>JSON Schema type (string, integer, boolean, array, etc.).</summary>
|
||||
public string Type { get; init; } = "string";
|
||||
|
||||
/// <summary>Whether the parameter is required.</summary>
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>Optional description for OpenAPI documentation.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
@@ -212,6 +212,49 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
|
||||
timeoutExtension["effectiveSeconds"]?.GetValue<int>() ??
|
||||
Math.Max(1, (int)Math.Ceiling(endpoint.DefaultTimeout.TotalSeconds));
|
||||
|
||||
// Add query parameters
|
||||
if (endpoint.SchemaInfo?.QueryParameters is { Count: > 0 } queryParams)
|
||||
{
|
||||
var parametersArray = new JsonArray();
|
||||
foreach (var qp in queryParams)
|
||||
{
|
||||
var paramObj = new JsonObject
|
||||
{
|
||||
["name"] = qp.Name,
|
||||
["in"] = "query",
|
||||
["required"] = qp.Required,
|
||||
["schema"] = new JsonObject { ["type"] = qp.Type }
|
||||
};
|
||||
if (qp.Description is not null)
|
||||
{
|
||||
paramObj["description"] = qp.Description;
|
||||
}
|
||||
parametersArray.Add(paramObj);
|
||||
}
|
||||
operation["parameters"] = parametersArray;
|
||||
}
|
||||
|
||||
// Add path parameters from route template
|
||||
var pathParams = ExtractPathParametersFromTemplate(gatewayPath);
|
||||
if (pathParams.Count > 0)
|
||||
{
|
||||
var parametersArray = operation["parameters"] as JsonArray ?? new JsonArray();
|
||||
foreach (var pp in pathParams)
|
||||
{
|
||||
parametersArray.Add(new JsonObject
|
||||
{
|
||||
["name"] = pp,
|
||||
["in"] = "path",
|
||||
["required"] = true,
|
||||
["schema"] = new JsonObject { ["type"] = "string" }
|
||||
});
|
||||
}
|
||||
if (operation["parameters"] is null)
|
||||
{
|
||||
operation["parameters"] = parametersArray;
|
||||
}
|
||||
}
|
||||
|
||||
// Add request body if schema exists
|
||||
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
|
||||
{
|
||||
@@ -685,6 +728,25 @@ public sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
|
||||
endpoint.Path);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractPathParametersFromTemplate(string path)
|
||||
{
|
||||
var parameters = new List<string>();
|
||||
var start = -1;
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
if (path[i] == '{')
|
||||
{
|
||||
start = i + 1;
|
||||
}
|
||||
else if (path[i] == '}' && start >= 0)
|
||||
{
|
||||
parameters.Add(path[start..i]);
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static bool AreClaimSetsEquivalent(
|
||||
IReadOnlyList<ClaimRequirement> left,
|
||||
IReadOnlyList<ClaimRequirement> right)
|
||||
|
||||
Reference in New Issue
Block a user