diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Command/ExportCommand.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Command/ExportCommand.cs index d1007f6dc77..e4760fafe41 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Command/ExportCommand.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Command/ExportCommand.cs @@ -20,6 +20,7 @@ public ExportCommand(IHost host) : base("export") Options.Add(Opt.Instance); Options.Add(Opt.Instance); + Options.Add(Opt.Instance); SetAction( (parseResult, cancellationToken) => @@ -27,8 +28,9 @@ public ExportCommand(IHost host) : base("export") var output = parseResult.InvocationConfiguration.Output; var outputFile = parseResult.GetValue(Opt.Instance); var schemaName = parseResult.GetValue(Opt.Instance); + var semanticNonNull = parseResult.GetValue(Opt.Instance); - return ExecuteAsync(output, host, outputFile, schemaName, cancellationToken); + return ExecuteAsync(output, host, outputFile, schemaName, semanticNonNull, cancellationToken); }); } @@ -37,6 +39,7 @@ private static async Task ExecuteAsync( IHost host, FileInfo? outputFile, string? schemaName, + bool semanticNonNull, CancellationToken cancellationToken) { var provider = host.Services.GetRequiredService(); @@ -58,7 +61,11 @@ private static async Task ExecuteAsync( var executor = await provider.GetExecutorAsync(schemaName, cancellationToken); outputFile ??= new FileInfo(System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls")); - var result = await SchemaFileExporter.Export(outputFile.FullName, executor, cancellationToken); + var result = await SchemaFileExporter.Export( + outputFile.FullName, + executor, + semanticNonNull, + cancellationToken); await output.WriteLineAsync("Exported Files:"); await output.WriteLineAsync($"- {result.SchemaFileName}"); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Options/SemanticNonNullOption.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Options/SemanticNonNullOption.cs new file mode 100644 index 00000000000..95af37c2083 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.CommandLine/Options/SemanticNonNullOption.cs @@ -0,0 +1,16 @@ +using System.CommandLine; + +namespace HotChocolate.AspNetCore.CommandLine; + +/// +/// An option for the schema command. The option is used to rewrite non-null +/// output fields to use the @semanticNonNull directive in the exported schema. +/// +internal sealed class SemanticNonNullOption : Option +{ + public SemanticNonNullOption() : base("--semantic-non-null") + { + Description = "Rewrite the exported schema to strip non-null wrappers from output " + + "fields and apply the @semanticNonNull directive instead."; + } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs index 534b3faee93..45eeb350476 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs @@ -914,14 +914,14 @@ private sealed class CachedSemanticNonNullSchemaOutput public CachedSemanticNonNullSchemaOutput(ISchemaDefinition schema, ulong version, DateTimeOffset lastModifiedTime) { - var document = SchemaFormatter.FormatAsDocument( - schema, - new SchemaFormatterOptions - { - IncludeInternalDirectives = false - }); - var rewritten = SemanticNonNullSchemaRewriter.Rewrite(document); - _schema = Encoding.UTF8.GetBytes(rewritten.ToString(indented: true)); + _schema = Encoding.UTF8.GetBytes( + SchemaFormatter.FormatAsString( + schema, + new SchemaFormatterOptions + { + IncludeInternalDirectives = false, + RewriteToSemanticNonNull = true + })); FileName = GetSchemaFileName(schema); ETag = CreateETag(_schema, version); LastModified = lastModifiedTime.ToString("R"); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index bd446462397..96f164d7982 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -371,6 +371,10 @@ public static IRequestExecutorBuilder AddWarmupTask< /// /// The file name of the schema file. /// + /// + /// If true, non-null wrappers on output fields are stripped and + /// replaced with the @semanticNonNull directive in the exported schema. + /// /// /// If true, the schema file will not be exported. /// @@ -383,6 +387,7 @@ public static IRequestExecutorBuilder AddWarmupTask< public static IRequestExecutorBuilder ExportSchemaOnStartup( this IRequestExecutorBuilder builder, string? schemaFileName = null, + bool semanticNonNull = false, bool skipIf = false) { ArgumentNullException.ThrowIfNull(builder); @@ -390,7 +395,7 @@ public static IRequestExecutorBuilder ExportSchemaOnStartup( if (!skipIf) { schemaFileName ??= System.IO.Path.Combine(Environment.CurrentDirectory, "schema.graphqls"); - builder.AddWarmupTask(new SchemaFileExporterWarmupTask(schemaFileName)); + builder.AddWarmupTask(new SchemaFileExporterWarmupTask(schemaFileName, semanticNonNull)); } return builder; diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs index 011e83bb813..06bcd1b9b80 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/SchemaFileExporterWarmupTask.cs @@ -2,12 +2,18 @@ namespace HotChocolate.AspNetCore.Warmup; -internal sealed class SchemaFileExporterWarmupTask(string schemaFileName) : IRequestExecutorWarmupTask +internal sealed class SchemaFileExporterWarmupTask( + string schemaFileName, + bool rewriteToSemanticNonNull) : IRequestExecutorWarmupTask { public bool ApplyOnlyOnStartup => false; public async Task WarmupAsync(IRequestExecutor executor, CancellationToken cancellationToken) { - await SchemaFileExporter.Export(schemaFileName, executor, cancellationToken); + await SchemaFileExporter.Export( + schemaFileName, + executor, + rewriteToSemanticNonNull, + cancellationToken); } } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/SchemaExportCommandTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/SchemaExportCommandTests.cs index d71fe3c8589..a045cf87979 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/SchemaExportCommandTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/SchemaExportCommandTests.cs @@ -91,6 +91,34 @@ public async Task App_Should_WriteSchemaToFile_When_OutputOptionIsSpecified() await snapshot.MatchMarkdownAsync(); } + [Fact] + public async Task App_Should_WriteSemanticNonNullSchemaToFile_When_SemanticNonNullOptionIsSpecified() + { + // arrange + var snapshot = new Snapshot(); + var services = new ServiceCollection(); + services.AddGraphQL() + .AddQueryType(x => x.Name("Query").Field("foo").Type>().Resolve("bar")); + + var hostMock = new Mock(); + hostMock + .Setup(x => x.Services) + .Returns(services.BuildServiceProvider()); + + var host = hostMock.Object; + var output = new StringWriter(); + var app = new App(host); + var tempFile = CreateSchemaFileName(); + + // act + await app.InvokeAsync($"schema export --output {tempFile} --semantic-non-null", output); + + // assert + snapshot.Add(await File.ReadAllTextAsync(tempFile + ".graphqls"), "Schema", markdownLanguage: "graphql"); + snapshot.Add(await File.ReadAllTextAsync(tempFile + "-settings.json"), "Settings", markdownLanguage: "json"); + await snapshot.MatchMarkdownAsync(); + } + [Fact] public async Task App_Should_WriteNamedSchemaToOutput_When_SchemaNameIsSpecified() { diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_OutputCorrectHelpTest_When_HelpIsRequested.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_OutputCorrectHelpTest_When_HelpIsRequested.snap index baf8899f0cd..a651927535c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_OutputCorrectHelpTest_When_HelpIsRequested.snap +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_OutputCorrectHelpTest_When_HelpIsRequested.snap @@ -7,5 +7,6 @@ Usage: Options: --output The path to the file where the schema should be exported to. If no output path is specified the schema will be printed to the console. --schema-name The name of the schema that should be exported. If no schema name is specified the default schema will be exported. + --semantic-non-null Rewrite the exported schema to strip non-null wrappers from output fields and apply the @semanticNonNull directive instead. -?, -h, --help Show help and usage information diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_WriteSemanticNonNullSchemaToFile_When_SemanticNonNullOptionIsSpecified.md b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_WriteSemanticNonNullSchemaToFile_When_SemanticNonNullOptionIsSpecified.md new file mode 100644 index 00000000000..c4059f312ea --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.CommandLine.Tests/__snapshots__/SchemaExportCommandTests.App_Should_WriteSemanticNonNullSchemaToFile_When_SemanticNonNullOptionIsSpecified.md @@ -0,0 +1,32 @@ +# App_Should_WriteSemanticNonNullSchemaToFile_When_SemanticNonNullOptionIsSpecified + +## Schema + +```graphql +schema { + query: Query +} + +type Query { + foo: String @semanticNonNull +} + +directive @semanticNonNull(levels: [Int!] = [ + 0 +]) on FIELD_DEFINITION + +``` + +## Settings + +```json +{ + "name": "_Default", + "transports": { + "http": { + "url": "http://localhost:5000/graphql" + } + } +} + +``` diff --git a/src/HotChocolate/Core/src/Types.Abstractions/HotChocolate.Types.Abstractions.csproj b/src/HotChocolate/Core/src/Types.Abstractions/HotChocolate.Types.Abstractions.csproj index a470cd866a5..02c2fd1c9e4 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/HotChocolate.Types.Abstractions.csproj +++ b/src/HotChocolate/Core/src/Types.Abstractions/HotChocolate.Types.Abstractions.csproj @@ -13,6 +13,7 @@ + diff --git a/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatter.cs b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatter.cs index 2f4fbe9783a..ef1e5f0bcb8 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatter.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatter.cs @@ -62,6 +62,11 @@ public static DocumentNode FormatAsDocument( document = postProcessor.Format(schema, document); } + if (options.RewriteToSemanticNonNull) + { + document = SemanticNonNullSchemaRewriter.Rewrite(document); + } + return document; } diff --git a/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatterOptions.cs b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatterOptions.cs index 49303318bbc..102ec1262d0 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatterOptions.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SchemaFormatterOptions.cs @@ -45,4 +45,12 @@ public sealed class SchemaFormatterOptions /// Default: true. /// public bool IncludeInternalDirectives { get; set; } = true; + + /// + /// Controls whether the formatted document is post-processed to strip + /// non-null wrappers from output fields and apply the @semanticNonNull + /// directive instead. + /// Default: false. + /// + public bool RewriteToSemanticNonNull { get; set; } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/SemanticNonNullSchemaRewriter.cs b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SemanticNonNullSchemaRewriter.cs similarity index 99% rename from src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/SemanticNonNullSchemaRewriter.cs rename to src/HotChocolate/Core/src/Types.Abstractions/Serialization/SemanticNonNullSchemaRewriter.cs index 6441d3c215e..f53f6a02969 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/SemanticNonNullSchemaRewriter.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/Serialization/SemanticNonNullSchemaRewriter.cs @@ -1,7 +1,7 @@ using HotChocolate.Language; using HotChocolate.Types; -namespace HotChocolate.AspNetCore.Formatters; +namespace HotChocolate.Serialization; /// /// Rewrites a GraphQL schema document by stripping non-null wrappers from diff --git a/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs b/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs index 1aff3c652b1..b90d90d77a6 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using HotChocolate.Serialization; namespace HotChocolate.Execution.Internal; @@ -9,9 +10,12 @@ internal static class SchemaFileExporter public static async Task Export( string schemaFileName, IRequestExecutor executor, + bool rewriteToSemanticNonNull, CancellationToken cancellationToken) { - var sdl = executor.Schema.ToString(); + var sdl = SchemaFormatter.FormatAsString( + executor.Schema, + new SchemaFormatterOptions { RewriteToSemanticNonNull = rewriteToSemanticNonNull }); if (Directory.Exists(schemaFileName)) { diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/SemanticNonNullSchemaRewriterTests.cs b/src/HotChocolate/Core/test/Types.Abstractions.Tests/Serialization/SemanticNonNullSchemaRewriterTests.cs similarity index 99% rename from src/HotChocolate/AspNetCore/test/AspNetCore.Tests/SemanticNonNullSchemaRewriterTests.cs rename to src/HotChocolate/Core/test/Types.Abstractions.Tests/Serialization/SemanticNonNullSchemaRewriterTests.cs index e70671156b1..b0aede153d6 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/SemanticNonNullSchemaRewriterTests.cs +++ b/src/HotChocolate/Core/test/Types.Abstractions.Tests/Serialization/SemanticNonNullSchemaRewriterTests.cs @@ -1,7 +1,6 @@ -using HotChocolate.AspNetCore.Formatters; using HotChocolate.Language; -namespace HotChocolate.AspNetCore; +namespace HotChocolate.Serialization; public class SemanticNonNullSchemaRewriterTests { diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index c4f5ce0bfa2..19ec320da42 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -1012,6 +1012,72 @@ There is no 1:1 mapping for all old methods. In most cases: Also note that `SubscriptionTransportError(...)` is no longer exposed separately in the fusion diagnostics API; use `SourceSchemaTransportError(...)`. --> +## Experimental @semanticNonNull support removed + +Hot Chocolate v15 included experimental support for the `@semanticNonNull` directive, which let you mark fields as semantically non-null while still returning `null` (rather than propagating to the parent) when a resolver errored. We've removed this feature in v16 in favor of the [`onError` proposal](https://github.com/graphql/graphql-spec/pull/1163). + +If you previously opted in to this feature, remove the option: + +```diff +builder.AddGraphQL() + .ModifyOptions(o => + { +- o.EnableSemanticNonNull = true; + }); +``` + +If you still need to keep the behavior of not propagating nulls for errors on non-null fields, set the `DefaultErrorHandlingMode` to `ErrorHandlingMode.Null`: + +```csharp +builder + .AddGraphQL() + .ModifyOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null); +``` + +### Clients that still need a schema with @semanticNonNull annotations + +If you have a client that still relies on the schema being annotated with `@semanticNonNull`, you have a few options to obtain such a schema. + +**Schema snapshot tests** + +If you're producing a schema string for snapshot tests like this: + +```csharp +ISchemaDefinition schema = await new ServiceCollection() + .AddGraphQL() + // ... + .BuildSchemaAsync(); + +string schemaStr = schema.ToString(); + +// assert schemaStr ... +``` + +Switch to `SchemaFormatter` with `RewriteToSemanticNonNull` enabled: + +```csharp +string schemaStr = SchemaFormatter.FormatAsString( + schema, + new SchemaFormatterOptions { RewriteToSemanticNonNull = true }); +``` + +**Downloading the schema from the server** + +If you're using `MapGraphQLSchema()` to expose the schema at `/graphql/schema`, you can additionally call `MapGraphQLSemanticNonNullSchema()` to expose a variant annotated with `@semanticNonNull` at `/graphql/semantic-non-null-schema.graphql`: + +```csharp +app.MapGraphQLSchema(); +app.MapGraphQLSemanticNonNullSchema(); +``` + +**Exporting the schema via the CLI** + +If you're using the schema export command, add the `--semantic-non-null` flag to emit the schema with `@semanticNonNull` annotations: + +```bash +dotnet run -- schema export --output schema.graphql --semantic-non-null +``` + # Deprecations Things that will continue to function this release, but we encourage you to move away from. diff --git a/website/src/docs/hotchocolate/v16/server/command-line.md b/website/src/docs/hotchocolate/v16/server/command-line.md index ea924cffc21..649a292e592 100644 --- a/website/src/docs/hotchocolate/v16/server/command-line.md +++ b/website/src/docs/hotchocolate/v16/server/command-line.md @@ -36,6 +36,7 @@ dotnet run -- schema export --output schema.graphql - `--output`: The path to the file where the schema is exported. If no output path is specified, the schema prints to the console. - `--schema-name`: The name of the schema to export. If no schema name is specified, the default schema is exported. +- `--semantic-non-null`: Rewrites the exported schema to strip non-null wrappers from output fields and apply the `@semanticNonNull` directive instead. Useful for clients that still rely on `@semanticNonNull` annotations after the experimental support was removed in v16. # Next Steps