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:
master
2026-03-10 17:13:58 +02:00
parent 8578065675
commit 8a1fb9bd9b
28 changed files with 349 additions and 94 deletions

View File

@@ -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
}
};
}

View File

@@ -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; }
}

View File

@@ -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)