diff --git a/src/Simplic.OxS.Server/Extensions/GraphQLExtension.cs b/src/Simplic.OxS.Server/Extensions/GraphQLExtension.cs index 705f6f1..6b2155d 100644 --- a/src/Simplic.OxS.Server/Extensions/GraphQLExtension.cs +++ b/src/Simplic.OxS.Server/Extensions/GraphQLExtension.cs @@ -1,5 +1,6 @@ using HotChocolate.Execution.Configuration; using Microsoft.Extensions.DependencyInjection; +using Simplic.OxS.Server.GraphQL; using Simplic.OxS.Server.Middleware; namespace Simplic.OxS.Server.Extensions @@ -7,11 +8,21 @@ namespace Simplic.OxS.Server.Extensions public static class GraphQLExtension { /// - /// Enable the use of GraphQL within the simplic eco system + /// Enable the use of GraphQL within the simplic eco system. /// - /// - /// - public static IServiceCollection UseGraphQL(this IServiceCollection services, Action builder = null) where TQuery : class + /// DI service collection. + /// Optional builder hook for service-specific extensions. + /// + /// When true (default), every output field on user-defined object types is + /// made nullable in the generated schema. This prevents NonNull spec + /// violations when a resolver returns null for a property that wasn't + /// stored on legacy documents. Set to false for strict, spec-conformant + /// non-null behavior (clients then must handle the propagated null). + /// + public static IServiceCollection UseGraphQL( + this IServiceCollection services, + Action builder = null, + bool tolerateMissingFieldValues = true) where TQuery : class { var req = services.AddGraphQLServer().ModifyOptions(o => { @@ -22,6 +33,9 @@ public static IServiceCollection UseGraphQL(this IServiceCollection serv .AddAuthorization() .AddQueryType(); + if (tolerateMissingFieldValues) + req.TryAddTypeInterceptor(); + // Set TimeSpan representation to d.hh:mm:ss req.AddType(new TimeSpanType(TimeSpanFormat.DotNet)); diff --git a/src/Simplic.OxS.Server/GraphQL/MakeFieldsNullableTypeInterceptor.cs b/src/Simplic.OxS.Server/GraphQL/MakeFieldsNullableTypeInterceptor.cs new file mode 100644 index 0000000..17aaa94 --- /dev/null +++ b/src/Simplic.OxS.Server/GraphQL/MakeFieldsNullableTypeInterceptor.cs @@ -0,0 +1,56 @@ +using HotChocolate.Configuration; +using HotChocolate.Types.Descriptors; +using HotChocolate.Types.Descriptors.Configurations; + +namespace Simplic.OxS.Server.GraphQL +{ + /// + /// HotChocolate type interceptor that rewrites every output field of every + /// user-defined object type so its outermost type becomes nullable. + /// + /// Purpose: tolerate items that are missing values for fields the schema + /// would otherwise mark as NonNull. Without this interceptor, a + /// resolver returning null for a !-field causes the parent + /// selection-set to be replaced by null (per GraphQL spec). + /// + /// + /// HotChocolate built-in types (introspection, paging connections, etc.) + /// are skipped because clients depend on their non-null guarantees. + /// + /// + internal sealed class MakeFieldsNullableTypeInterceptor : TypeInterceptor + { + public override void OnBeforeCompleteName( + ITypeCompletionContext completionContext, + TypeSystemConfiguration configuration) + { + if (completionContext.IsIntrospectionType) + return; + + if (configuration is not ObjectTypeConfiguration objectConfig) + return; + + // Skip HotChocolate's own types (Connection, Edge, PageInfo, ...). + var runtimeType = objectConfig.RuntimeType; + if (runtimeType?.Namespace is { } ns && + ns.StartsWith("HotChocolate", System.StringComparison.Ordinal)) + { + return; + } + + foreach (var field in objectConfig.Fields) + { + if (field.IsIntrospectionField) + continue; + + if (field.Type is not ExtendedTypeReference extRef) + continue; + + var nullableType = completionContext.TypeInspector + .ChangeNullability(extRef.Type, true); + + field.Type = extRef.WithType(nullableType); + } + } + } +}