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 3852f7b89db..e0967858f36 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/Execution/DynamicEndpointMiddleware.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO.Pipelines; using System.Text.Json; using Microsoft.AspNetCore.Http; @@ -418,7 +419,7 @@ private static IValueNode ParseValueNode(object? value, ITypeDefinition type) return new IntValueNode(i); } - if (value is string s && int.TryParse(s, out var intValue)) + if (value is string s && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { return new IntValueNode(intValue); } @@ -449,7 +450,14 @@ private static IValueNode ParseValueNode(object? value, ITypeDefinition type) return new FloatValueNode(d); } - if (value is string s && double.TryParse(s, out var doubleValue)) + if (value is string s + && double.TryParse( + s, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var doubleValue) + && !double.IsNaN(doubleValue) + && !double.IsInfinity(doubleValue)) { return new FloatValueNode(doubleValue); } 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 076aa0cba1a..aa38ea945e9 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/HttpEndpointIntegrationTestBase.cs @@ -159,6 +159,48 @@ query GetUser($userName: String!) @http(method: GET, route: "/users-details", qu response.MatchSnapshot(); } + [Fact] + public async Task Http_Get_With_Query_Parameter_Float_Value() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query SearchProducts($text: String, $minPrice: Float) + @http(method: GET, route: "/search", queryParameters: ["text", "minPrice"]) { + searchProducts(text: $text, minPrice: $minPrice) + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync("/search?text=Bed&minPrice=500"); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task Http_Get_With_Query_Parameter_Float_Value_With_Decimals() + { + // arrange + var storage = new TestOpenApiDefinitionStorage( + """ + query SearchProducts($text: String, $minPrice: Float) + @http(method: GET, route: "/search", queryParameters: ["text", "minPrice"]) { + searchProducts(text: $text, minPrice: $minPrice) + } + """); + var server = CreateTestServer(storage); + var client = server.CreateClient(); + + // act + var response = await client.GetAsync("/search?text=Bed&minPrice=500.99"); + + // assert + response.MatchSnapshot(); + } + [Fact] public async Task Http_Get_Invalid_Type_In_Route_Parameter() { diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value.snap new file mode 100644 index 00000000000..3311324837c --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/json +--------------------------> +Status Code: OK +--------------------------> +"Searched for: Bed, minPrice: 500" diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value_With_Decimals.snap b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value_With_Decimals.snap new file mode 100644 index 00000000000..54806dd5187 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/Endpoints/__snapshots__/HttpEndpointIntegrationTestBase.Http_Get_With_Query_Parameter_Float_Value_With_Decimals.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/json +--------------------------> +Status Code: OK +--------------------------> +"Searched for: Bed, minPrice: 500.99" diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs index 9738149dc7d..b744b14f67f 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/TestSchema.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using HotChocolate.Authorization; using HotChocolate.Features; @@ -87,6 +88,12 @@ public ComplexObject GetComplexObject(ComplexObjectInput input) input.Url, input.Uuid); } + + public string SearchProducts(string? text, float? minPrice) + { + var formattedMinPrice = minPrice?.ToString(CultureInfo.InvariantCulture); + return $"Searched for: {text ?? "all"}, minPrice: {formattedMinPrice}"; + } } public class Mutation diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs index b0ebae7bdcb..e700530c150 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs @@ -172,7 +172,7 @@ private FusionScalarTypeDefinition CreateSpecScalar(string name) default, FusionDirectiveCollection.Empty, specifiedBy: null, - serializationType: ScalarSerializationType.String, + serializationType: GetSpecScalarSerializationType(name), pattern: null)); _typeDefinitionNodeLookup = _typeDefinitionNodeLookup.SetItem(name, typeDef); @@ -181,6 +181,19 @@ private FusionScalarTypeDefinition CreateSpecScalar(string name) return type; } + private static ScalarSerializationType GetSpecScalarSerializationType(string name) + => name switch + { + SpecScalarNames.String.Name => ScalarSerializationType.String, + SpecScalarNames.Int.Name => ScalarSerializationType.Int, + SpecScalarNames.Float.Name => ScalarSerializationType.Float, + SpecScalarNames.Boolean.Name => ScalarSerializationType.Boolean, + SpecScalarNames.ID.Name => ScalarSerializationType.String | ScalarSerializationType.Int, + _ => throw new ArgumentOutOfRangeException( + nameof(name), + $"The specified name `{name}` is not a valid spec scalar name.") + }; + private static IType CreateType(ITypeNode typeNode, ITypeDefinition compositeNamedType) { if (typeNode is NonNullTypeNode nonNullType) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs index 287e23ba318..5c7c7042d3f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs @@ -182,7 +182,9 @@ public bool IsValueCompatible(IValueNode valueLiteral) SyntaxKind.NullValue => true, SyntaxKind.EnumValue => false, SyntaxKind.StringValue => ValueKind.HasFlag(ScalarValueKind.String), - SyntaxKind.IntValue => ValueKind.HasFlag(ScalarValueKind.Integer), + SyntaxKind.IntValue => + ValueKind.HasFlag(ScalarValueKind.Integer) + || ValueKind.HasFlag(ScalarValueKind.Float), SyntaxKind.FloatValue => ValueKind.HasFlag(ScalarValueKind.Float), SyntaxKind.BooleanValue => ValueKind.HasFlag(ScalarValueKind.Boolean), SyntaxKind.ListValue => ValueKind.HasFlag(ScalarValueKind.List), diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableCoercionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableCoercionTests.cs index 3e6397888df..85c4fec277a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableCoercionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableCoercionTests.cs @@ -41,6 +41,117 @@ query testQuery($input: String!) { await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Float_Input_Accepts_Whole_Number() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + r => r.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + var request = new OperationRequest( + """ + query testQuery($input: Float!) { + field(input: $input) + } + """, + variables: new Dictionary + { + ["input"] = 500 + }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + using var response = await result.ReadAsResultAsync(); + + // assert + Assert.Equal(500, response.Data.GetProperty("field").GetDouble()); + } + + [Fact] + public async Task Float_Input_Accepts_Zero_Decimal() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + r => r.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + var request = new OperationRequest( + """ + query testQuery($input: Float!) { + field(input: $input) + } + """, + variables: new Dictionary + { + ["input"] = 500.0 + }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + using var response = await result.ReadAsResultAsync(); + + // assert + Assert.Equal(500, response.Data.GetProperty("field").GetDouble()); + } + + [Fact] + public async Task Float_Input_Accepts_Fractional_Decimal() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + r => r.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery($input: Float!) { + field(input: $input) + } + """, + variables: new Dictionary + { + ["input"] = 500.99 + }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + using var response = await result.ReadAsResultAsync(); + + // assert + Assert.Equal(500.99, response.Data.GetProperty("field").GetDouble(), precision: 2); + } + [Fact] public async Task InputObject_Invalid_Field() { @@ -462,4 +573,12 @@ public class Query public string GetField(string input) => input; } } + + public static class SourceSchema2 + { + public class Query + { + public double GetField(double input) => input; + } + } }