diff --git a/src/Core/Parsers/IntrospectionInterceptor.cs b/src/Core/Parsers/IntrospectionInterceptor.cs index 98c458e6a9..53b6711cf9 100644 --- a/src/Core/Parsers/IntrospectionInterceptor.cs +++ b/src/Core/Parsers/IntrospectionInterceptor.cs @@ -5,6 +5,7 @@ using HotChocolate.AspNetCore; using HotChocolate.Execution; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Core.Parsers { @@ -14,17 +15,18 @@ namespace Azure.DataApiBuilder.Core.Parsers /// public class IntrospectionInterceptor : DefaultHttpRequestInterceptor { - private RuntimeConfigProvider _runtimeConfigProvider; - /// - /// Constructor injects RuntimeConfigProvider to allow - /// HotChocolate to attempt to retrieve the runtime config - /// when evaluating GraphQL requests. + /// Parameterless constructor. /// - /// - public IntrospectionInterceptor(RuntimeConfigProvider runtimeConfigProvider) + /// + /// Hot Chocolate v16 isolates schema services from the application's request services. + /// Resolving constructor-injected app singletons (e.g. ) + /// against the schema service provider therefore fails at executor session creation. + /// We instead resolve dependencies from in + /// , where the application's request scope is in effect. + /// + public IntrospectionInterceptor() { - _runtimeConfigProvider = runtimeConfigProvider; } /// @@ -51,7 +53,10 @@ public override ValueTask OnCreateAsync( OperationRequestBuilder requestBuilder, CancellationToken cancellationToken) { - if (_runtimeConfigProvider.GetConfig().AllowIntrospection) + RuntimeConfigProvider runtimeConfigProvider = + context.RequestServices.GetRequiredService(); + + if (runtimeConfigProvider.GetConfig().AllowIntrospection) { requestBuilder.AllowIntrospection(); } diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 68d83557c0..29c435d955 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -117,14 +117,15 @@ private static IEnumerable GenerateQueryColumns(SelectionSetNode [MemberNotNull(nameof(OrderByColumns))] private void Init(IDictionary queryParams) { - ISelection selection = _context.Selection; + Selection selection = _context.Selection; ObjectType underlyingType = selection.Field.Type.NamedType(); IsPaginated = QueryBuilder.IsPaginationType(underlyingType); OrderByColumns = new(); + FieldNode selectionFieldNode = selection.RequireFieldNode(); if (IsPaginated) { - FieldNode? fieldNode = ExtractQueryField(selection.SyntaxNode); + FieldNode? fieldNode = ExtractQueryField(selectionFieldNode); if (fieldNode is not null) { @@ -139,7 +140,7 @@ private void Init(IDictionary queryParams) } else { - Columns.AddRange(GenerateQueryColumns(selection.SyntaxNode.SelectionSet!, _context.Operation.Document, SourceAlias)); + Columns.AddRange(GenerateQueryColumns(selectionFieldNode.SelectionSet!, _context.Operation.Document, SourceAlias)); string typeName = GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingType.Directives, out string? modelName) ? modelName : underlyingType.Name; diff --git a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 0370b44b83..210cb5fef5 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -126,7 +126,7 @@ public SqlQueryStructure( sqlMetadataProvider, authorizationResolver, ctx.Selection.Field, - ctx.Selection.SyntaxNode, + ctx.Selection.RequireFieldNode(), // The outermost query is where we start, so this can define // create the IncrementingInteger that will be shared between // all subqueries in this query. @@ -173,7 +173,7 @@ public SqlQueryStructure( IsMultipleCreateOperation = isMultipleCreateOperation; ObjectField schemaField = _ctx.Selection.Field; - FieldNode? queryField = _ctx.Selection.SyntaxNode; + FieldNode? queryField = _ctx.Selection.RequireFieldNode(); IOutputType outputType = schemaField.Type; _underlyingFieldType = outputType.NamedType(); diff --git a/src/Core/Services/DetermineStatusCodeMiddleware.cs b/src/Core/Services/DetermineStatusCodeMiddleware.cs index dcddc62971..a0a9a88490 100644 --- a/src/Core/Services/DetermineStatusCodeMiddleware.cs +++ b/src/Core/Services/DetermineStatusCodeMiddleware.cs @@ -35,7 +35,7 @@ public async ValueTask InvokeAsync(RequestContext context) } contextData[ExecutionContextData.HttpStatusCode] = HttpStatusCode.BadRequest; - context.Result = singleResult.WithContextData(contextData.ToImmutable()); + singleResult.ContextData = contextData.ToImmutable(); } } } diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index 79bdc6af9c..c91ac6c9aa 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Xml; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; @@ -18,7 +19,6 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Execution; -using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Resolvers; using NodaTime.Text; @@ -201,7 +201,7 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) return namedType switch { StringType => fieldValue.GetString(), // spec - ByteType => fieldValue.GetByte(), + UnsignedByteType => fieldValue.GetByte(), ShortType => fieldValue.GetInt16(), IntType => fieldValue.GetInt32(), // spec LongType => fieldValue.GetInt64(), @@ -211,11 +211,21 @@ fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) DateTimeType => DateTimeOffset.TryParse(fieldValue.GetString()!, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out DateTimeOffset date) ? date : null, // for DW when datetime is null it will be in "" (double quotes) due to stringagg parsing and hence we need to ensure parsing is correct. DateType => DateTimeOffset.TryParse(fieldValue.GetString()!, out DateTimeOffset date) ? date : null, HotChocolate.Types.NodaTime.LocalTimeType => fieldValue.GetString()!.Equals("null", StringComparison.OrdinalIgnoreCase) ? null : LocalTimePattern.ExtendedIso.Parse(fieldValue.GetString()!).Value, - ByteArrayType => fieldValue.GetBytesFromBase64(), + // HC v16 ships both ByteArrayType (legacy, GraphQL name "ByteArray", runtime byte[]) + // and Base64StringType (new, GraphQL name "Base64String", runtime byte[]). DAB's + // generated schemas still use the GraphQL name "ByteArray", so HC binds entity + // fields to ByteArrayType. We accept either type here so DAB also tolerates + // schemas that bind to Base64StringType (e.g. via DefaultValueType). + // CS0618: ByteArrayType is [Obsolete] in HC v16 in favor of Base64StringType, + // but we still need to pattern-match it because it remains the type bound to + // the GraphQL name "ByteArray" that DAB-generated schemas continue to use. +#pragma warning disable CS0618 + Base64StringType or ByteArrayType => fieldValue.GetBytesFromBase64(), +#pragma warning restore CS0618 BooleanType => fieldValue.GetBoolean(), // spec UrlType => new Uri(fieldValue.GetString()!), UuidType => fieldValue.GetGuid(), - TimeSpanType => TimeSpan.Parse(fieldValue.GetString()!), + DurationType => XmlConvert.ToTimeSpan(fieldValue.GetString()!), AnyType => fieldValue.ToString(), _ => fieldValue.GetString() }; @@ -508,7 +518,7 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti { return GetParametersFromSchemaAndQueryFields( context.Selection.Field, - context.Selection.SyntaxNode, + context.Selection.RequireFieldNode(), context.Variables); } diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 90e918c833..b635e5128d 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -105,6 +105,15 @@ private ISchemaBuilder Parse( // Generate the Query and the Mutation Node. (DocumentNode queryNode, DocumentNode mutationNode) = GenerateQueryAndMutationNodes(root, inputTypes); + // Hot Chocolate v16 validates schemas eagerly during host startup + // (RequestExecutorWarmupService) and rejects an empty Query type with + // "The object type `Query` has to at least define one field in order to be valid." + // This can occur in valid runtime configurations: GraphQL globally disabled, + // every entity opting out of GraphQL via `graphql.enabled = false`, or no entities + // configured. Inject a hidden placeholder field with a no-op resolver so the schema + // is structurally valid; HC v16 also rejects fields without resolvers. + queryNode = EnsureQueryHasAtLeastOneField(queryNode, sb); + return sb .AddDocument(root) .AddAuthorizeDirectiveType() @@ -122,12 +131,70 @@ private ISchemaBuilder Parse( .AddDocument(queryNode) // Generate the GraphQL mutations from the provided objects .AddDocument(mutationNode) - // Enable the OneOf directive (https://github.com/graphql/graphql-spec/pull/825) to support the DefaultValue type - .ModifyOptions(o => o.EnableOneOf = true) // Adds our type interceptor that will create the resolvers. .TryAddTypeInterceptor(new ResolverTypeInterceptor(new ExecutionHelper(_queryEngineFactory, _mutationEngineFactory, _runtimeConfigProvider))); } + /// + /// Name of the hidden placeholder field added to Query when no entity contributes + /// a query field, used to keep the schema valid for HC v16's eager validation. + /// + private const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab"; + + /// + /// If the generated Query object type has no fields, append a hidden placeholder + /// field and register a null-returning resolver for it. The placeholder is shadowed in + /// any configuration that produces real query fields, so it is only visible in + /// otherwise-empty schemas (GraphQL globally disabled, all entities opting out, + /// no entities configured). + /// + private static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode, ISchemaBuilder sb) + { + ImmutableArray.Builder rewritten = ImmutableArray.CreateBuilder(queryNode.Definitions.Count); + bool placeholderInjected = false; + + foreach (IDefinitionNode definition in queryNode.Definitions) + { + if (definition is ObjectTypeDefinitionNode objectType + && objectType.Name.Value == "Query" + && objectType.Fields.Count == 0) + { + FieldDefinitionNode placeholderField = new( + location: null, + new NameNode(EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME), + new StringValueNode( + "Internal placeholder; only present when no entity contributes a query field. " + + "Always returns null and is never reachable in normal operation."), + arguments: new List(), + type: new NamedTypeNode(new NameNode("String")), + directives: new List()); + + rewritten.Add(new ObjectTypeDefinitionNode( + objectType.Location, + objectType.Name, + objectType.Description, + objectType.Directives, + objectType.Interfaces, + new List { placeholderField })); + placeholderInjected = true; + } + else + { + rewritten.Add(definition); + } + } + + if (placeholderInjected) + { + // HC v16 requires every field to have a resolver; bind a no-op that always + // returns null. The field is unreachable in normal operation because callers + // for empty-Query configurations never issue GraphQL requests. + sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null); + } + + return new DocumentNode(rewritten.ToImmutable()); + } + /// /// Generate the GraphQL schema query and mutation nodes from the provided database. /// diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index dfd605cc8f..8195e57d78 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,15 +13,12 @@ - - - - - - - - - + + + + + + diff --git a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs index e7d4be690a..8d85ccb39f 100644 --- a/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs +++ b/src/Service.GraphQLBuilder/CustomScalars/SingleType.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using HotChocolate.Language; +using HotChocolate.Text.Json; using HotChocolate.Types; namespace Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars @@ -48,10 +50,16 @@ public SingleType( Description = description; } - protected override float ParseLiteral(IFloatValueLiteral valueSyntax) => - valueSyntax.ToSingle(); + protected override float OnCoerceInputLiteral(IFloatValueLiteral valueLiteral) + => valueLiteral.ToSingle(); - protected override FloatValueNode ParseValue(float runtimeValue) => - new(runtimeValue); + protected override float OnCoerceInputValue(JsonElement inputValue) + => inputValue.GetSingle(); + + protected override void OnCoerceOutputValue(float runtimeValue, ResultElement resultValue) + => resultValue.SetNumberValue(runtimeValue); + + protected override IValueNode OnValueToLiteral(float runtimeValue) + => new FloatValueNode(runtimeValue); } } diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs index 8aca57421c..92d0298f10 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -156,14 +156,14 @@ private static Tuple ConvertValueToGraphQLType(string defaul { Tuple valueNode = paramValueType switch { - UUID_TYPE => new(UUID_TYPE, new UuidType().ParseValue(Guid.Parse(defaultValueFromConfig))), - BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig))), - SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig))), - INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig))), - LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig))), - SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ParseValue(float.Parse(defaultValueFromConfig))), - FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))), - DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig))), + UUID_TYPE => new(UUID_TYPE, new UuidType().ValueToLiteral(Guid.Parse(defaultValueFromConfig))), + BYTE_TYPE => new(BYTE_TYPE, new IntValueNode(byte.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + SHORT_TYPE => new(SHORT_TYPE, new IntValueNode(short.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + INT_TYPE => new(INT_TYPE, new IntValueNode(int.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + LONG_TYPE => new(LONG_TYPE, new IntValueNode(long.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ValueToLiteral(float.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), + DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig, CultureInfo.InvariantCulture))), STRING_TYPE => new(STRING_TYPE, new StringValueNode(defaultValueFromConfig)), BOOLEAN_TYPE => new(BOOLEAN_TYPE, new BooleanValueNode( defaultValueFromConfig switch @@ -174,10 +174,10 @@ var s when s.Equals("true", StringComparison.OrdinalIgnoreCase) => true, var s when s.Equals("false", StringComparison.OrdinalIgnoreCase) => false, _ => throw new FormatException($"String '{defaultValueFromConfig}' was not recognized as a valid Boolean.") })), - DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ParseResult( - DateTime.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))), - BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(Convert.FromBase64String(defaultValueFromConfig))), - LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)), + DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ValueToLiteral( + DateTimeOffset.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))), + BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(Convert.FromBase64String(defaultValueFromConfig))), + LOCALTIME_TYPE => new(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)), _ => throw new NotSupportedException(message: $"The {defaultValueFromConfig} parameter's value type [{paramValueType}] is not supported.") }; diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs index a8b058fa6a..7f10d1c290 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs @@ -13,7 +13,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor) { descriptor.Name("DefaultValue"); descriptor.OneOf(); - descriptor.Field(BYTE_TYPE).Type(); + descriptor.Field(BYTE_TYPE).Type(); descriptor.Field(SHORT_TYPE).Type(); descriptor.Field(INT_TYPE).Type(); descriptor.Field(LONG_TYPE).Type(); @@ -23,7 +23,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor) descriptor.Field(FLOAT_TYPE).Type(); descriptor.Field(DECIMAL_TYPE).Type(); descriptor.Field(DATETIME_TYPE).Type(); - descriptor.Field(BYTEARRAY_TYPE).Type(); + descriptor.Field(BYTEARRAY_TYPE).Type(); descriptor.Field(LOCALTIME_TYPE).Type(); } } diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs index 88d29a6e3f..242bc7080e 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs @@ -11,7 +11,13 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes public static class SupportedHotChocolateTypes { public const string UUID_TYPE = "UUID"; - public const string BYTE_TYPE = "Byte"; + // HC v16 split the legacy Byte scalar into: + // - ByteType (runtime: sbyte, range -128..127) + // - UnsignedByteType (runtime: byte, range 0..255) + // SQL Server's tinyint maps to .NET byte (0..255), so DAB targets UnsignedByte. + // The GraphQL type name visible in the generated schema therefore changed from + // "Byte" to "UnsignedByte" with the HC v16 upgrade. + public const string BYTE_TYPE = "UnsignedByte"; public const string SHORT_TYPE = "Short"; public const string INT_TYPE = "Int"; public const string LONG_TYPE = "Long"; diff --git a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs index aa6423d55d..a0b3023a38 100644 --- a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs +++ b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs @@ -11,7 +11,11 @@ public sealed class StandardQueryInputs { private static readonly ITypeNode _id = new NamedTypeNode(ScalarNames.ID); private static readonly ITypeNode _boolean = new NamedTypeNode(ScalarNames.Boolean); - private static readonly ITypeNode _byte = new NamedTypeNode(ScalarNames.Byte); + // BYTE_TYPE is "UnsignedByte" after the HC v16 upgrade (HC v16 split ByteType + // into a signed-byte ByteType and a new UnsignedByteType for byte 0..255). DAB's + // SQL tinyint columns map to UnsignedByte, so the filter input must reference the + // same scalar that schema fields are typed as. + private static readonly ITypeNode _byte = new NamedTypeNode(BYTE_TYPE); private static readonly ITypeNode _short = new NamedTypeNode(ScalarNames.Short); private static readonly ITypeNode _int = new NamedTypeNode(ScalarNames.Int); private static readonly ITypeNode _long = new NamedTypeNode(ScalarNames.Long); @@ -55,7 +59,9 @@ private static InputObjectTypeDefinitionNode BooleanInputType() => CreateSimpleEqualsFilter("BooleanFilterInput", "Input type for adding Boolean filters", _boolean); private static InputObjectTypeDefinitionNode ByteInputType() => - CreateComparableFilter("ByteFilterInput", "Input type for adding Byte filters", _byte); + // Filter input type name follows the $"{TypeName}FilterInput" convention used by + // GetCommonFilterInputType, so this is "UnsignedByteFilterInput" with HC v16. + CreateComparableFilter($"{BYTE_TYPE}FilterInput", $"Input type for adding {BYTE_TYPE} filters", _byte); private static InputObjectTypeDefinitionNode ShortInputType() => CreateComparableFilter("ShortFilterInput", "Input type for adding Short filters", _short); @@ -189,7 +195,9 @@ private StandardQueryInputs() { AddInputType(ScalarNames.ID, IdInputType()); AddInputType(ScalarNames.UUID, UuidInputType()); - AddInputType(ScalarNames.Byte, ByteInputType()); + // Register under BYTE_TYPE ("UnsignedByte" with HC v16) so InputTypeBuilder's + // lookup by the field's GraphQL type name resolves correctly. + AddInputType(BYTE_TYPE, ByteInputType()); AddInputType(ScalarNames.Short, ShortInputType()); AddInputType(ScalarNames.Int, IntInputType()); AddInputType(ScalarNames.Long, LongInputType()); diff --git a/src/Service.GraphQLBuilder/SelectionExtensions.cs b/src/Service.GraphQLBuilder/SelectionExtensions.cs new file mode 100644 index 0000000000..b246a567f5 --- /dev/null +++ b/src/Service.GraphQLBuilder/SelectionExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Execution.Processing; +using HotChocolate.Language; + +namespace Azure.DataApiBuilder.Service.GraphQLBuilder +{ + /// + /// Extension methods over Hot Chocolate's type. + /// + public static class SelectionExtensions + { + /// + /// Returns the first backing the given , + /// failing fast with a targeted when no syntax node + /// is available. + /// + /// + /// Hot Chocolate v16 introduced Selection.SyntaxNodes (a span) to support field-merging + /// across multiple selection-set occurrences of the same field. Indexing directly with + /// SyntaxNodes[0] would surface as an at request + /// time if the span were ever empty. In practice an executable selection always has at least + /// one syntax node, so an empty span is an invariant violation rather than a legitimate + /// "no field" signal — surface it as a clear DAB error. + /// + public static FieldNode RequireFieldNode(this Selection selection) + { + // SyntaxNodes is a ReadOnlySpan, so LINQ helpers (e.g. FirstOrDefault) + // are not available; check IsEmpty before indexing. + ReadOnlySpan syntaxNodes = selection.SyntaxNodes; + if (syntaxNodes.IsEmpty) + { + throw new DataApiBuilderException( + message: $"GraphQL selection '{selection.ResponseName}' has no syntax node available.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); + } + + return syntaxNodes[0].Node; + } + } +} diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 76057a76dc..9a65d3461d 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -582,16 +582,17 @@ public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValu short value => new ObjectValueNode(new ObjectFieldNode(SHORT_TYPE, new IntValueNode(value))), int value => new ObjectValueNode(new ObjectFieldNode(INT_TYPE, value)), long value => new ObjectValueNode(new ObjectFieldNode(LONG_TYPE, new IntValueNode(value))), - Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ParseValue(value))), + Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ValueToLiteral(value))), string value => new ObjectValueNode(new ObjectFieldNode(STRING_TYPE, value)), bool value => new ObjectValueNode(new ObjectFieldNode(BOOLEAN_TYPE, value)), - float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ParseValue(value))), + float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ValueToLiteral(value))), double value => new ObjectValueNode(new ObjectFieldNode(FLOAT_TYPE, value)), decimal value => new ObjectValueNode(new ObjectFieldNode(DECIMAL_TYPE, new FloatValueNode(value))), - DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseValue(value))), - DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseResult(value))), - byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(value))), - TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ParseResult(value))), + DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(value))), + DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral( + value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value)))), + byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(value))), + TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(value))), _ => throw new DataApiBuilderException( message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value", statusCode: HttpStatusCode.InternalServerError, diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index be2dcca84f..148344dad5 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -741,6 +741,13 @@ public void CleanupAfterEachTest() /// But if invalid config is provided during startup, ApplicationException is thrown /// and application exits. /// + /// + /// As of Hot Chocolate 16, the GraphQL middleware resolves WithOptions per request via an + /// Action<GraphQLServerOptions>, so the "no runtime config" condition surfaces as a + /// with bubbling + /// out of the request pipeline rather than as a synchronous 503 response. The assertions below treat + /// that as semantically equivalent to the original 503 / contract. + /// [DataTestMethod] [DataRow(new string[] { }, true, DisplayName = "No config returns 503 - config file flag absent")] [DataRow(new string[] { "--ConfigFileName=" }, true, DisplayName = "No config returns 503 - empty config file option")] @@ -767,13 +774,24 @@ public async Task TestNoConfigReturnsServiceUnavailable( HttpResponseMessage result = await httpClient.GetAsync("/graphql"); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, result.StatusCode); } - catch (Exception e) + catch (DataApiBuilderException dabException) { - Assert.IsFalse(isUpdateableRuntimeConfig); - Assert.AreEqual(typeof(ApplicationException), e.GetType()); + // Hot Chocolate 16+: the absence of a runtime config bubbles out of the GraphQL pipeline + // as DataApiBuilderException(ServiceUnavailable). This is semantically equivalent to the + // pre-HC16 503 response (hosting case) or ApplicationException (CLI case). + Assert.AreEqual( + HttpStatusCode.ServiceUnavailable, + dabException.StatusCode, + $"Expected ServiceUnavailable status when runtime config is missing, got: {dabException.Message}"); + } + catch (ApplicationException appException) + { + Assert.IsFalse( + isUpdateableRuntimeConfig, + "ApplicationException should only be thrown in the non-updateable (CLI startup) scenario."); Assert.AreEqual( $"Could not initialize the engine with the runtime config file: {DEFAULT_CONFIG_FILE_NAME}", - e.Message); + appException.Message); } finally { diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 3ff9e58531..6c1e76f715 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable annotations + using System; using System.Collections.Generic; using System.IO; diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index 745d5eade3..bb07362a7f 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -58,7 +58,7 @@ public async Task InsertMutation(string dbQuery) /// "default_string_with_paranthesis": "()", /// "default_function_string_with_paranthesis": "NOW()", /// "default_integer": 100, - /// "default_date_string": "1999-01-08T10:23:54.000Z" + /// "default_date_string": "1999-01-08T10:23:54Z" /// } /// public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQuery) @@ -95,7 +95,9 @@ public virtual async Task InsertMutationWithDefaultBuiltInFunctions(string dbQue Assert.AreEqual("()", result.GetProperty("default_string_with_parenthesis").GetString()); Assert.AreEqual("NOW()", result.GetProperty("default_function_string_with_parenthesis").GetString()); Assert.AreEqual(100, result.GetProperty("default_integer").GetInt32()); - Assert.AreEqual("1999-01-08T10:23:54.000Z", result.GetProperty("default_date_string").GetString()); + // HC v16 DateTime scalar elides trailing zero fractional seconds in ISO-8601 output + // ("1999-01-08T10:23:54.000Z" → "1999-01-08T10:23:54Z"). + Assert.AreEqual("1999-01-08T10:23:54Z", result.GetProperty("default_date_string").GetString()); } /// diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs index 6f1b498f1e..2d2ee75e0d 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs @@ -95,7 +95,7 @@ public async Task QueryTypeColumn(string type, int id) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "supportedType_by_pk"; string gqlQuery = "{ supportedType_by_pk(typeid: " + id + ") { typeid, " + field + " } }"; @@ -159,7 +159,7 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "supportedTypes"; string gqlQuery = @"{ supportedTypes(first: 100 orderBy: { typeid: ASC } filter: { " + field + ": {" + filterOperator + ": " + gqlValue + @"} }) { @@ -403,7 +403,7 @@ public async Task InsertIntoTypeColumn(string type, string value) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLMutationName = "createSupportedType"; string gqlMutation = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }"; @@ -434,7 +434,7 @@ public async Task InsertInvalidTimeIntoTimeTypeColumn(string type, string value) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "createSupportedType"; string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }"; @@ -471,7 +471,7 @@ public async Task InsertIntoTypeColumnWithArgument(string type, object value) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "createSupportedType"; string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ createSupportedType (item: {" + field + ": $param }){ typeid, " + field + " } }"; @@ -537,7 +537,7 @@ public async Task UpdateTypeColumn(string type, string value) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "updateSupportedType"; string gqlQuery = "mutation{ updateSupportedType (typeid: 1, item: {" + field + ": " + value + " }){ typeid " + field + " } }"; @@ -577,7 +577,7 @@ public async Task UpdateTypeColumnWithArgument(string type, object value) Assert.Inconclusive("Type not supported"); } - string field = $"{type.ToLowerInvariant()}_types"; + string field = GetTestFieldName(type); string graphQLQueryName = "updateSupportedType"; string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ updateSupportedType (typeid: 1, item: {" + field + ": $param }){ typeid, " + field + " } }"; @@ -660,7 +660,7 @@ private static void CompareUuidResults(string actual, string expected) /// private static void CompareFloatResults(string floatType, string actual, string expected) { - string fieldName = $"{floatType.ToLowerInvariant()}_types"; + string fieldName = GetTestFieldName(floatType); using JsonDocument actualJsonDoc = JsonDocument.Parse(actual); using JsonDocument expectedJsonDoc = JsonDocument.Parse(expected); @@ -871,6 +871,24 @@ private static string TypeNameToGraphQLType(string typeName) }; } + /// + /// Maps a DAB GraphQL scalar type name to the column-name suffix used in the + /// supported-types test table (and corresponding GraphQL field). The convention is + /// {lower(typeName)}_types, except for which is + /// "UnsignedByte" after the HC v16 upgrade. SQL Server's tinyint column and + /// the corresponding GraphQL field are still named byte_types, so map + /// UnsignedByte back to byte for the column-name lookup. + /// + protected static string GetTestFieldName(string typeName) + { + if (typeName == BYTE_TYPE) + { + return "byte_types"; + } + + return $"{typeName.ToLowerInvariant()}_types"; + } + protected abstract string MakeQueryOnTypeTable( List queryFields, string filterValue = "1", diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs index 49b062c55e..e4d304fed8 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs @@ -55,19 +55,21 @@ public static async Task SetupAsync(TestContext context) /// Unescaped string used as value for GraphQL input field datetime_types /// Expected result the HotChocolate returns from resolving database response. // Date and time - [DataRow("1000-01-01 00:00:00", "1000-01-01T00:00:00.000Z", DisplayName = "Datetime value separated by space.")] - [DataRow("9999-12-31T23:59:59", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value separated by T.")] - [DataRow("9999-12-31 23:59:59Z", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset Z as resolved by HotChocolate.")] - [DataRow("9999-12-31 23:59:59+00:00", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset with no datetime change when stored in db.")] - [DataRow("9999-12-31 23:59:59+03:00", "9999-12-31T20:59:59.000Z", DisplayName = "Timezone offset UTC+03:00 accepted by MySQL because UTC value is in supported datetime range.")] - [DataRow("9999-12-31 20:59:59-03:00", "9999-12-31T23:59:59.000Z", DisplayName = "Timezone offset UTC-03:00 accepted by MySQL because UTC value is in supported datetime range.")] + // Note: HC v16's DateTime scalar elides trailing zero fractional seconds in ISO-8601 + // output (e.g. "1000-01-01T00:00:00.000Z" → "1000-01-01T00:00:00Z"). + [DataRow("1000-01-01 00:00:00", "1000-01-01T00:00:00Z", DisplayName = "Datetime value separated by space.")] + [DataRow("9999-12-31T23:59:59", "9999-12-31T23:59:59Z", DisplayName = "Datetime value separated by T.")] + [DataRow("9999-12-31 23:59:59Z", "9999-12-31T23:59:59Z", DisplayName = "Datetime value specified with UTC offset Z as resolved by HotChocolate.")] + [DataRow("9999-12-31 23:59:59+00:00", "9999-12-31T23:59:59Z", DisplayName = "Datetime value specified with UTC offset with no datetime change when stored in db.")] + [DataRow("9999-12-31 23:59:59+03:00", "9999-12-31T20:59:59Z", DisplayName = "Timezone offset UTC+03:00 accepted by MySQL because UTC value is in supported datetime range.")] + [DataRow("9999-12-31 20:59:59-03:00", "9999-12-31T23:59:59Z", DisplayName = "Timezone offset UTC-03:00 accepted by MySQL because UTC value is in supported datetime range.")] // Fractional seconds rounded up/down when mysql column datetime doesn't specify fractional seconds // e.g. column not defined as datetime({1-6}) - [DataRow("9999-12-31 23:59:59.499999", "9999-12-31T23:59:59.000Z", DisplayName = "Fractional seconds rounded down because fractional seconds are passed to column with datatype datetime(0).")] - [DataRow("2024-12-31 23:59:59.999999", "2025-01-01T00:00:00.000Z", DisplayName = "Fractional seconds rounded up because fractional seconds are passed to column with datatype datetime(0).")] + [DataRow("9999-12-31 23:59:59.499999", "9999-12-31T23:59:59Z", DisplayName = "Fractional seconds rounded down because fractional seconds are passed to column with datatype datetime(0).")] + [DataRow("2024-12-31 23:59:59.999999", "2025-01-01T00:00:00Z", DisplayName = "Fractional seconds rounded up because fractional seconds are passed to column with datatype datetime(0).")] // Only date - [DataRow("9999-12-31", "9999-12-31T00:00:00.000Z", DisplayName = "Max date for datetime column stored with zeroed out time.")] - [DataRow("1000-01-01", "1000-01-01T00:00:00.000Z", DisplayName = "Min date for datetime column stored with zeroed out time.")] + [DataRow("9999-12-31", "9999-12-31T00:00:00Z", DisplayName = "Max date for datetime column stored with zeroed out time.")] + [DataRow("1000-01-01", "1000-01-01T00:00:00Z", DisplayName = "Min date for datetime column stored with zeroed out time.")] [DataTestMethod] public async Task InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult) { @@ -98,9 +100,11 @@ public async Task InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedVa /// /// Unescaped string used as value for GraphQL input field datetime_types /// Expected result the HotChocolate returns from resolving database response. - [DataRow("23:59:59.499999", "23:59:59.000Z", DisplayName = "hh:mm::ss.ffffff for datetime column stored with zeroed out date and rounded down fractional seconds.")] - [DataRow("23:59:59", "23:59:59.000Z", DisplayName = "hh:mm:ss for datetime column stored with zeroed out date.")] - [DataRow("23:59", "23:59:00.000Z", DisplayName = "hh:mm for datetime column stored with zeroed out date and seconds.")] + // Note: HC v16's DateTime scalar elides trailing zero fractional seconds in ISO-8601 + // output (e.g. "23:59:59.000Z" → "23:59:59Z"). + [DataRow("23:59:59.499999", "23:59:59Z", DisplayName = "hh:mm::ss.ffffff for datetime column stored with zeroed out date and rounded down fractional seconds.")] + [DataRow("23:59:59", "23:59:59Z", DisplayName = "hh:mm:ss for datetime column stored with zeroed out date.")] + [DataRow("23:59", "23:59:00Z", DisplayName = "hh:mm for datetime column stored with zeroed out date and seconds.")] [DataTestMethod] public async Task InsertMutationInput_DateTimeTypes_TimeOnly_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult) { diff --git a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs index e06e140328..7c2ee2cecd 100644 --- a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs +++ b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs @@ -26,6 +26,7 @@ using HotChocolate; using HotChocolate.Execution; using HotChocolate.Resolvers; +using HotChocolate.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -118,18 +119,18 @@ public async Task TestMultiSourceQuery() Assert.AreEqual(1, cosmosQueryEngine.Invocations.Count, "Cosmos query engine should be invoked for multi-source query as an entity belongs to cosmos db."); OperationResult singleResult = result.ExpectOperationResult(); - Assert.IsNull(singleResult.Errors, "There should be no errors in processing of multisource query."); + Assert.IsTrue(singleResult.Errors.IsEmpty, "There should be no errors in processing of multisource query."); Assert.IsNotNull(singleResult.Data, "Data should be returned for multisource query."); - IReadOnlyDictionary data = singleResult.Data; - Assert.IsTrue(data.TryGetValue(QUERY_NAME_1, out object queryNode1), $"Query node for {QUERY_NAME_1} should have data populated."); - Assert.IsTrue(data.TryGetValue(QUERY_NAME_2, out object queryNode2), $"Query node for {QUERY_NAME_2} should have data populated."); + ResultDocument document = (ResultDocument)singleResult.Data.Value.Value; + Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_1, out ResultElement queryNode1), $"Query node for {QUERY_NAME_1} should have data populated."); + Assert.IsTrue(document.Data.TryGetProperty(QUERY_NAME_2, out ResultElement queryNode2), $"Query node for {QUERY_NAME_2} should have data populated."); - KeyValuePair firstEntryMap1 = ((IReadOnlyDictionary)queryNode1).FirstOrDefault(); - KeyValuePair firstEntryMap2 = ((IReadOnlyDictionary)queryNode2).FirstOrDefault(); + ResultProperty firstEntryMap1 = queryNode1.EnumerateObject().FirstOrDefault(); + ResultProperty firstEntryMap2 = queryNode2.EnumerateObject().FirstOrDefault(); // validate that the data returned for the queries we did matches the moq data we set up for the respective query engines. - Assert.AreEqual("db1", firstEntryMap1.Value, $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query"); - Assert.AreEqual("db2", firstEntryMap2.Value, $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query"); + Assert.AreEqual("db1", firstEntryMap1.Value.GetString(), $"Data returned for {QUERY_NAME_1} is incorrect for multi-source query"); + Assert.AreEqual("db2", firstEntryMap2.Value.GetString(), $"Data returned for {QUERY_NAME_2} is incorrect for multi-source query"); } /// diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 4da3266271..65bc1778e6 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable annotations + using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index fa2e0e33f6..f30c9f56f5 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable annotations + using System; using System.Collections.Generic; using System.Data; diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 70c162a078..6c32fe00ab 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -615,16 +615,66 @@ private void ConfigureResponseCompression(IServiceCollection services, RuntimeCo private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOptions? graphQLRuntimeOptions) { IRequestExecutorBuilder server = services.AddGraphQLServer() + // Hot Chocolate v16 builds the GraphQL schema eagerly during host startup. DAB + // supports a "hosted" scenario where the runtime config is supplied after the + // host starts (POST /configuration), so the schema cannot exist at startup. Eager + // initialization plus our placeholder-schema fallback also creates a race on + // late-config hydration: HC keeps serving the warm placeholder executor in the + // background while the new schema's warmup runs, so the first GraphQL request + // after hydration validates against the placeholder and returns BadRequest. + // Opt into lazy initialization (the v15 default) so the schema is constructed on + // first request, by which time the runtime config is loaded and the metadata + // provider has been initialized in PerformOnConfigChangeAsync. + .ModifyOptions(options => options.LazyInitialization = true) .AddInstrumentation() - .AddType(new DateTimeType(disableFormatCheck: graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true)) + .AddType(new DateTimeType(new DateTimeOptions { ValidateInputFormat = !(graphQLRuntimeOptions?.EnableLegacyDateTimeScalar ?? true) })) .AddHttpRequestInterceptor() .ConfigureSchema((serviceProvider, schemaBuilder) => { - // The GraphQLSchemaCreator is an application service that is not available on - // the schema specific service provider, this means we have to get it with - // the GetRootServiceProvider helper. - GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService(); - graphQLService.InitializeSchemaAndResolvers(schemaBuilder); + // With LazyInitialization, ConfigureSchema runs on the first GraphQL request, + // not at host startup. By that point the runtime config is loaded for both + // the file-based and POST /configuration scenarios. The placeholder fallback + // remains as a safety net for two edge cases: + // 1. GraphQL globally disabled - requests are short-circuited to 404 by + // PathRewriteMiddleware, but if a request slips through, emit a minimal + // schema so HC validation does not fail on entity metadata that is + // intentionally never exposed (e.g. column names colliding with + // reserved GraphQL identifiers like the leading double-underscore). + // 2. Schema construction failure (e.g. config validation error preventing + // metadata-provider initialization). + RuntimeConfigProvider configProvider = serviceProvider.GetRootServiceProvider() + .GetRequiredService(); + if (!configProvider.TryGetConfig(out RuntimeConfig? loadedConfig)) + { + EmitPlaceholderSchema(schemaBuilder); + return; + } + + if (!loadedConfig.IsGraphQLEnabled) + { + EmitPlaceholderSchema(schemaBuilder); + return; + } + + try + { + // The GraphQLSchemaCreator is an application service that is not available on + // the schema specific service provider, this means we have to get it with + // the GetRootServiceProvider helper. + GraphQLSchemaCreator graphQLService = serviceProvider.GetRootServiceProvider().GetRequiredService(); + graphQLService.InitializeSchemaAndResolvers(schemaBuilder); + } + catch (Exception ex) + { + // Schema construction failed (e.g. metadata provider was not initialized + // due to a config validation error). Fall back to the placeholder schema + // so the host can still start; the underlying error will have already + // been surfaced by Startup.PerformOnConfigChangeAsync. + _logger.LogWarning( + exception: ex, + message: "Failed to build GraphQL schema; emitting placeholder schema. The error will surface on first GraphQL request and is typically caused by a runtime config that failed validation."); + EmitPlaceholderSchema(schemaBuilder); + } }) .AddHttpRequestInterceptor() .AddAuthorizationHandler() @@ -847,23 +897,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC endpoints .MapGraphQL() - .WithOptions(new GraphQLServerOptions + .WithOptions(options => { - Tool = { - // Determines if accessing the endpoint from a browser - // will load the GraphQL Banana Cake Pop IDE. - Enable = IsUIEnabled(runtimeConfig, env) - } - }); - - // In development mode, Nitro is enabled at /graphql endpoint by default. - // Need to disable mapping Nitro explicitly as well to avoid ability to query - // at an additional endpoint: /graphql/ui. - endpoints - .MapNitroApp() - .WithOptions(new GraphQLToolOptions - { - Enable = false + // Determines if accessing the endpoint from a browser + // will load the GraphQL Banana Cake Pop / Nitro IDE. + options.Tool.Enable = IsUIEnabled(runtimeConfig, env); }); endpoints.MapHealthChecks("/", new HealthCheckOptions @@ -882,6 +920,21 @@ private static void EvictGraphQLSchema(IRequestExecutorManager requestExecutorRe requestExecutorResolver.EvictExecutor(); } + /// + /// Registers a minimal valid GraphQL schema (a single placeholder field with a no-op + /// resolver) on the supplied . Used to satisfy Hot + /// Chocolate v16's eager schema validation when the real schema cannot be constructed + /// at startup (no runtime config loaded yet, or a config validation failure prevented + /// metadata provider initialization). The placeholder is unreachable in normal + /// operation: it is shadowed once a real config is hot-reloaded via the + /// GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED event. + /// + private static void EmitPlaceholderSchema(ISchemaBuilder schemaBuilder) + { + schemaBuilder.AddDocumentFromString("type Query { _dab: String }"); + schemaBuilder.AddResolver("Query", "_dab", _ => null); + } + /// /// If LogLevel is NOT overridden by CLI, attempts to find the /// minimum log level based on host.mode in the runtime config if available.