From 25a622c144a12b320e814a0fc53f54aace63b248 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:49:20 +0200 Subject: [PATCH 1/2] Improve missing query parameter error --- .../Execution/DynamicEndpointMiddleware.cs | 12 +++++++++++ .../HttpEndpointIntegrationTestBase.cs | 21 +++++++++++++++++++ ...With_Missing_Required_Query_Parameter.snap | 6 ++++++ .../test/Adapters.OpenApi.Tests/TestSchema.cs | 3 +++ 4 files changed, 42 insertions(+) create mode 100644 src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Missing_Required_Query_Parameter.snap diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs index e0967858f36..04c14af338c 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs @@ -333,6 +333,12 @@ private static bool TryGetValueForParameter( return false; } + if (leaf.Type.IsNonNullType()) + { + throw new BadRequestException( + $"Required route parameter '{leaf.ParameterKey}' is missing"); + } + parameterValue = s_nullValueNode; return true; } @@ -357,6 +363,12 @@ private static bool TryGetValueForParameter( return false; } + if (leaf.Type.IsNonNullType()) + { + throw new BadRequestException( + $"Required query parameter '{leaf.ParameterKey}' is missing"); + } + parameterValue = s_nullValueNode; return true; } diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs index aa38ea945e9..6bd8cc23175 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs @@ -84,6 +84,27 @@ public async Task Http_Get_With_Query_Parameter() response.MatchSnapshot(); } + [Fact] + public async Task Http_Get_With_Missing_Required_Query_Parameter() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query SearchProducts($text: String, $first: Int!) + @http(method: GET, route: "/products/search", queryParameters: ["text", "first"]) { + searchProductsPaginated(text: $text, first: $first) + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync("/products/search?text=Chair"); + + // assert + response.MatchSnapshot(); + } + [Fact] public async Task Http_Get_Without_Query_Parameter_That_Has_Default_Value() { diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Missing_Required_Query_Parameter.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Missing_Required_Query_Parameter.snap new file mode 100644 index 00000000000..b88cd2fb173 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Missing_Required_Query_Parameter.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/problem+json +--------------------------> +Status Code: BadRequest +--------------------------> +{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Required query parameter 'first' is missing"} diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs index b744b14f67f..ecfe4e89305 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs @@ -94,6 +94,9 @@ public string SearchProducts(string? text, float? minPrice) var formattedMinPrice = minPrice?.ToString(CultureInfo.InvariantCulture); return $"Searched for: {text ?? "all"}, minPrice: {formattedMinPrice}"; } + + public string SearchProductsPaginated(string? text, int first) + => $"Searched for: {text ?? "all"}, first: {first}"; } public class Mutation From 8da578a47809cfab4c887659a8630d30069027d6 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:03:19 +0200 Subject: [PATCH 2/2] Fix --- .../Execution/DynamicEndpointMiddleware.cs | 16 +++++--- .../Execution/OpenApiEndpointDescriptor.cs | 5 ++- .../Execution/OpenApiEndpointFactory.cs | 39 ++++--------------- 3 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs index 04c14af338c..e1ebd084460 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs @@ -333,7 +333,7 @@ private static bool TryGetValueForParameter( return false; } - if (leaf.Type.IsNonNullType()) + if (leaf.IsNonNullType) { throw new BadRequestException( $"Required route parameter '{leaf.ParameterKey}' is missing"); @@ -345,7 +345,7 @@ private static bool TryGetValueForParameter( try { - parameterValue = ParseValueNode(value, leaf.Type); + parameterValue = ParseValueNode(value, leaf.NamedType); return true; } catch (InvalidFormatException) @@ -356,14 +356,14 @@ private static bool TryGetValueForParameter( if (leaf.ParameterType is OpenApiEndpointParameterType.Query) { - if (!query.TryGetValue(leaf.ParameterKey, out var values) || values is not [{ } value]) + if (!query.TryGetValue(leaf.ParameterKey, out var values)) { if (leaf.HasDefaultValue) { return false; } - if (leaf.Type.IsNonNullType()) + if (leaf.IsNonNullType) { throw new BadRequestException( $"Required query parameter '{leaf.ParameterKey}' is missing"); @@ -373,9 +373,15 @@ private static bool TryGetValueForParameter( return true; } + if (values is not [{ } value]) + { + throw new BadRequestException( + $"Query parameter '{leaf.ParameterKey}' can only be specified once."); + } + try { - parameterValue = ParseValueNode(value, leaf.Type); + parameterValue = ParseValueNode(value, leaf.NamedType); return true; } catch (InvalidFormatException) diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs index 3e93575a74f..10e7891a2db 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointDescriptor.cs @@ -21,9 +21,10 @@ internal sealed class VariableValueInsertionTrie internal sealed record VariableValueInsertionTrieLeaf( string ParameterKey, - ITypeDefinition Type, + ITypeDefinition NamedType, OpenApiEndpointParameterType ParameterType, - bool HasDefaultValue) : IVariableValueInsertionTrieSegment; + bool HasDefaultValue, + bool IsNonNullType) : IVariableValueInsertionTrieSegment; internal enum OpenApiEndpointParameterType { diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs index 3f3785c0826..fbc299b5345 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/OpenApiEndpointFactory.cs @@ -96,7 +96,7 @@ private static OpenApiEndpointDescriptor CreateEndpointDescriptor( var responseNameToExtract = rootField.Alias?.Value ?? rootField.Name.Value; - var route = CreateRoutePattern(endpointDefinition.Route); + var route = RoutePatternFactory.Parse(endpointDefinition.Route); var parameterTrie = new VariableValueInsertionTrie(); @@ -123,7 +123,7 @@ void InsertParametersIntoTrie( { foreach (var parameter in parameters) { - var (inputType, hasDefaultValue) = GetParameterDetails( + var (inputType, hasDefaultValue, isNonNullType) = GetParameterDetails( parameter, endpointDefinition.OperationDefinition, schema); @@ -132,7 +132,8 @@ void InsertParametersIntoTrie( parameter.Key, inputType, parameterType, - hasDefaultValue); + hasDefaultValue, + isNonNullType); var inputObjectPath = parameter.InputObjectPath; @@ -185,7 +186,7 @@ void InsertParametersIntoTrie( } } - private static (ITypeDefinition Type, bool HasDefaultValue) GetParameterDetails( + private static (ITypeDefinition Type, bool HasDefaultValue, bool IsNonNullType) GetParameterDetails( OpenApiEndpointDefinitionParameter parameter, OperationDefinitionNode operation, ISchemaDefinition schema) @@ -195,6 +196,7 @@ private static (ITypeDefinition Type, bool HasDefaultValue) GetParameterDetails( var currentType = schema.Types[variable.Type.NamedType().Name.Value]; var hasDefaultValue = variable.DefaultValue is not null; + var isNonNullType = variable.Type.IsNonNullType(); if (parameter.InputObjectPath is { Length: > 0 }) { @@ -209,35 +211,10 @@ private static (ITypeDefinition Type, bool HasDefaultValue) GetParameterDetails( currentType = field.Type.NamedType(); hasDefaultValue = field.DefaultValue is not null; + isNonNullType = field.Type.IsNonNullType(); } } - return (currentType, hasDefaultValue); - } - - private static RoutePattern CreateRoutePattern(string route) - { - return RoutePatternFactory.Parse(route); - // var segments = new List(); - // - // foreach (var segment in route.Segments) - // { - // if (segment is OpenApiRouteSegmentLiteral stringSegment) - // { - // segments.Add( - // RoutePatternFactory.Segment( - // RoutePatternFactory.LiteralPart(stringSegment.Value))); - // } - // else if (segment is OpenApiRouteSegmentParameter mapSegment) - // { - // // We do not apply route constraints here, as they are not meant for validation but to disambiguate routes: - // // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints - // segments.Add( - // RoutePatternFactory.Segment( - // RoutePatternFactory.ParameterPart(mapSegment.Key))); - // } - // } - // - // return RoutePatternFactory.Pattern(segments); + return (currentType, hasDefaultValue, isNonNullType); } }