From a6573092cdfd7a902a0455a307861254d3aae6d2 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 29 Apr 2026 17:36:15 +0200 Subject: [PATCH 1/6] [Fusion] Add support for source schema extensions Co-authored-by: Copilot --- .../src/Fusion.Aspire/SchemaComposition.cs | 22 ++- .../SchemaComposerTests.cs | 91 ++++++++++ .../Fusion/FusionCompositionHelpers.cs | 32 +++- .../Fusion/FusionComposeCommandTests.cs | 82 +++++++++ .../composite-schema.graphqls | 163 ++++++++++++++++++ .../source-schema-1-extensions.graphqls | 3 + .../source-schema-1-settings.json | 8 + .../valid-extensions/source-schema-1.graphqls | 11 ++ 8 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs create mode 100644 src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls create mode 100644 src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-extensions.graphqls create mode 100644 src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-settings.json create mode 100644 src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1.graphqls diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs index 8fc4e228186..432122e2baf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs @@ -383,7 +383,27 @@ private List GetReferencedResources( return null; } - return await File.ReadAllTextAsync(schemaFile, cancellationToken); + var schemaText = await File.ReadAllTextAsync(schemaFile, cancellationToken); + + var extensionsFile = IOPath.Combine( + IOPath.GetDirectoryName(schemaFile)!, + IOPath.GetFileNameWithoutExtension(schemaFile) + + "-extensions" + + IOPath.GetExtension(schemaFile)); + + if (File.Exists(extensionsFile)) + { + var extensionsText = await File.ReadAllTextAsync(extensionsFile, cancellationToken); + + if (schemaText.Length > 0 && !schemaText.EndsWith('\n')) + { + schemaText += "\n"; + } + + schemaText += extensionsText; + } + + return schemaText; } catch (Exception ex) { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs new file mode 100644 index 00000000000..bcbc46ed828 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -0,0 +1,91 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion; + +public sealed class SchemaComposerTests +{ + [Fact] + public void Compose_WithExtensions_AppliesExtensions() + { + // arrange + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { + productById1(id: ID!): Product + productById2(id: ID!): Product + lookups: InternalLookups! + } + + extend type Query { + productById1(id: ID!): Product @lookup + productById2(id: ID!): Product @internal + lookups: InternalLookups! @internal + } + + type Product { + id: ID! + hidden: Int + } + + extend type Product { + sku: String! + hidden: Int @inaccessible + } + + type InternalLookups { + productBySku(sku: ID!): Product + } + + extend type InternalLookups @internal { + productBySku(sku: ID!): Product @lookup + } + """) + ], + new SchemaComposerOptions { Merger = { AddFusionDefinitions = false } }, + new CompositionLog()); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsSuccess); + result.Value.MatchInlineSnapshot( + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + productById1(id: ID! @fusion__inputField(schema: A)): Product + @fusion__field(schema: A) + } + + type Product + @fusion__type(schema: A) + @fusion__lookup( + schema: A + key: "id" + field: "productById1(id: ID!): Product" + map: ["id"] + path: null + internal: false + ) + @fusion__lookup( + schema: A + key: "sku" + field: "productBySku(sku: ID!): Product" + map: ["sku"] + path: "lookups" + internal: true + ) { + hidden: Int @fusion__field(schema: A) @fusion__inaccessible + id: ID! @fusion__field(schema: A) + sku: String! @fusion__field(schema: A) + } + """); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs index bab2dcd77bd..d0e439f6c42 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs @@ -17,6 +17,17 @@ public static bool IsSchemaFile(string? fileName) || fileName.EndsWith(".graphqls", StringComparison.OrdinalIgnoreCase); } + public static bool IsExtensionsFile(string? fileName) + { + if (fileName is null) + { + return false; + } + + var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + return nameWithoutExt.EndsWith("-extensions", StringComparison.OrdinalIgnoreCase); + } + public static async Task> ReadSourceSchemasAsync( IFileSystem fileSystem, string? workingDirectory, @@ -59,7 +70,8 @@ public static bool IsSchemaFile(string? fileName) schemaFilePath = fileSystem .GetFiles(sourceSchemaPath, "*.graphql*", SearchOption.AllDirectories) - .FirstOrDefault(f => IsSchemaFile(Path.GetFileName(f))); + .FirstOrDefault(f => IsSchemaFile(Path.GetFileName(f)) + && !IsExtensionsFile(Path.GetFileName(f))); } else if (fileSystem.FileExists(sourceSchemaPath)) { @@ -91,6 +103,24 @@ public static bool IsSchemaFile(string? fileName) var sourceText = await fileSystem.ReadAllTextAsync(schemaFilePath, cancellationToken); + var extensionsFilePath = Path.Combine( + Path.GetDirectoryName(schemaFilePath)!, + Path.GetFileNameWithoutExtension(schemaFilePath) + + "-extensions" + + Path.GetExtension(schemaFilePath)); + + if (fileSystem.FileExists(extensionsFilePath)) + { + var extensionsText = await fileSystem.ReadAllTextAsync(extensionsFilePath, cancellationToken); + + if (sourceText.Length > 0 && !sourceText.EndsWith('\n')) + { + sourceText += "\n"; + } + + sourceText += extensionsText; + } + return (schemaName, new SourceSchemaText(schemaName, sourceText), settings); } } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs index 0524b18539c..126e807891f 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs @@ -465,6 +465,78 @@ public async Task Compose_Valid_ExcludeTag() sourceText.ReplaceLineEndings("\n").MatchInlineSnapshot(s_validExcludeByTagCompositeSchema); } + [Fact] + public async Task Compose_Valid_Extensions() + { + // arrange + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-extensions/source-schema-1.graphqls"); + + // act + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-extensions/source-schema-1.graphqls"), + "--archive", + archiveFileName); + + // assert + Assert.Equal(0, result.ExitCode); + + using var archive = FusionArchive.Open(archiveFileName); + var config = await archive.TryGetGatewayConfigurationAsync(WellKnownVersions.LatestGatewayFormatVersion); + Assert.NotNull(config); + var sourceText = await ReadSchemaAsync(config); + sourceText + .ReplaceLineEndings("\n") + .MatchInlineSnapshot( + await File.ReadAllTextAsync("__resources__/valid-extensions-result/composite-schema.graphqls")); + } + + [Fact] + public async Task Compose_Valid_Extensions_FromDirectory() + { + // arrange + var archiveFileName = CreateTempFile(); + var workDir = Path.Combine(s_resourcesDir, "valid-extensions"); + + // Set up the directory mock to return both the primary and extensions file. + // Discovery must pick the primary schema, not the extensions sidecar. + SetupDirectory(workDir, + Path.Combine(workDir, "source-schema-1.graphqls"), + Path.Combine(workDir, "source-schema-1-extensions.graphqls")); + + var schemaFile = Path.Combine(workDir, "source-schema-1.graphqls"); + var settingsFile = Path.Combine(workDir, "source-schema-1-settings.json"); + var extensionsFile = Path.Combine(workDir, "source-schema-1-extensions.graphqls"); + + SetupFile(schemaFile, (await File.ReadAllTextAsync(schemaFile)).TrimEnd()); + SetupFile(settingsFile, new MemoryStream(await File.ReadAllBytesAsync(settingsFile))); + SetupFile(extensionsFile, new MemoryStream(await File.ReadAllBytesAsync(extensionsFile))); + + // act + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + workDir, + "--archive", + archiveFileName); + + // assert + Assert.Equal(0, result.ExitCode); + + using var archive = FusionArchive.Open(archiveFileName); + var config = await archive.TryGetGatewayConfigurationAsync(WellKnownVersions.LatestGatewayFormatVersion); + Assert.NotNull(config); + var sourceText = await ReadSchemaAsync(config); + sourceText + .ReplaceLineEndings("\n") + .MatchInlineSnapshot( + await File.ReadAllTextAsync("__resources__/valid-extensions-result/composite-schema.graphqls")); + } + [Fact] public async Task Compose_MissingSettingsFile_ReturnsError() { @@ -618,9 +690,19 @@ private void SetupSourceSchemaFromResources(string relativePath) var settingsPath = Path.Combine( Path.GetDirectoryName(fullPath)!, Path.GetFileNameWithoutExtension(fullPath) + "-settings.json"); + var extensionsFilePath = Path.Combine( + Path.GetDirectoryName(fullPath)!, + Path.GetFileNameWithoutExtension(fullPath) + + "-extensions" + + Path.GetExtension(fullPath)); SetupFile(fullPath, File.ReadAllText(fullPath)); SetupFile(settingsPath, new MemoryStream(File.ReadAllBytes(settingsPath))); + + if (File.Exists(extensionsFilePath)) + { + SetupFile(extensionsFilePath, new MemoryStream(File.ReadAllBytes(extensionsFilePath))); + } } /// diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls new file mode 100644 index 00000000000..e3c6d4b30c5 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls @@ -0,0 +1,163 @@ +schema { + query: Query +} + +type Query @fusion__type(schema: SCHEMA1) { + productById(id: ID! @fusion__inputField(schema: SCHEMA1)): Product + @fusion__field(schema: SCHEMA1) +} + +type Product + @fusion__type(schema: SCHEMA1) + @fusion__lookup( + schema: SCHEMA1 + key: "id" + field: "productById(id: ID!): Product" + map: ["id"] + path: null + internal: false + ) { + id: ID! @fusion__field(schema: SCHEMA1) +} + +"The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." +enum fusion__Schema { + SCHEMA1 @fusion__schema_metadata(name: "Schema1") +} + +"The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." +scalar fusion__FieldDefinition + +"The fusion__FieldSelectionMap scalar is used to represent the FieldSelectionMap type specified in the GraphQL Composite Schemas Spec." +scalar fusion__FieldSelectionMap + +"The fusion__FieldSelectionPath scalar is used to represent a path of field names relative to the Query type." +scalar fusion__FieldSelectionPath + +"The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." +scalar fusion__FieldSelectionSet + +"The @fusion__cost directive specifies cost metadata for each source schema." +directive @fusion__cost( + "The name of the source schema that defined the cost metadata." + schema: fusion__Schema! + "The weight defined in the source schema." + weight: String! +) repeatable on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +"The @fusion__enumValue directive specifies which source schema provides an enum value." +directive @fusion__enumValue( + "The name of the source schema that provides the specified enum value." + schema: fusion__Schema! +) repeatable on ENUM_VALUE + +"The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." +directive @fusion__field( + "Indicates that this field is only partially provided and must be combined with `provides`." + partial: Boolean! = false + "A selection set of fields this field provides in the composite schema." + provides: fusion__FieldSelectionSet + "The name of the source schema that originally provided this field." + schema: fusion__Schema! + "The field type in the source schema if it differs in nullability or structure." + sourceType: String +) repeatable on FIELD_DEFINITION + +"The @fusion__implements directive specifies on which source schema an interface is implemented by an object or interface type." +directive @fusion__implements( + "The name of the interface type." + interface: String! + "The name of the source schema on which the annotated type implements the specified interface." + schema: fusion__Schema! +) repeatable on OBJECT | INTERFACE + +"The @fusion__inaccessible directive is used to prevent specific type system members from being accessible through the client-facing composite schema, even if they are accessible in the underlying source schemas." +directive @fusion__inaccessible on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +"The @fusion__inputField directive specifies which source schema provides an input field in a composite input type." +directive @fusion__inputField( + "The name of the source schema that originally provided this input field." + schema: fusion__Schema! + "The field type in the source schema if it differs in nullability or structure." + sourceType: String +) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + +"The @fusion__listSize directive specifies list size metadata for each source schema." +directive @fusion__listSize( + "The assumed size of the list as defined in the source schema." + assumedSize: Int + "The single slicing argument requirement of the list as defined in the source schema." + requireOneSlicingArgument: Boolean + "The name of the source schema that defined the list size metadata." + schema: fusion__Schema! + "The sized fields of the list as defined in the source schema." + sizedFields: [String!] + "The slicing argument default value of the list as defined in the source schema." + slicingArgumentDefaultValue: Int + "The slicing arguments of the list as defined in the source schema." + slicingArguments: [String!] +) repeatable on FIELD_DEFINITION + +"The @fusion__lookup directive specifies how the distributed executor can resolve data for an entity type from a source schema by a stable key." +directive @fusion__lookup( + "The GraphQL field definition in the source schema that can be used to look up the entity." + field: fusion__FieldDefinition! + "Is the lookup meant as an entry point or just to provide more data." + internal: Boolean! = false + "A selection set on the annotated entity type that describes the stable key for the lookup." + key: fusion__FieldSelectionSet! + "The map describes how the key values are resolved from the annotated entity type." + map: [fusion__FieldSelectionMap!]! + "The path to the lookup field relative to the Query type." + path: fusion__FieldSelectionPath + "The name of the source schema where the annotated entity type can be looked up from." + schema: fusion__Schema! +) repeatable on OBJECT | INTERFACE | UNION + +"The @fusion__requires directive specifies if a field has requirements on a source schema." +directive @fusion__requires( + "The GraphQL field definition in the source schema that this field depends on." + field: fusion__FieldDefinition! + "The map describes how the argument values for the source schema are resolved from the arguments of the field exposed in the client-facing composite schema and from required data relative to the current type." + map: [fusion__FieldSelectionMap]! + "A selection set on the annotated field that describes its requirements." + requirements: fusion__FieldSelectionSet! + "The name of the source schema where this field has requirements to data on other source schemas." + schema: fusion__Schema! +) repeatable on FIELD_DEFINITION + +"The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." +directive @fusion__schema_metadata( + "The name of the source schema." + name: String! +) on ENUM_VALUE + +"The @fusion__type directive specifies which source schemas provide parts of a composite type." +directive @fusion__type( + "The name of the source schema that originally provided part of the annotated type." + schema: fusion__Schema! +) repeatable on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"The @fusion__unionMember directive specifies which source schema provides a member type of a union." +directive @fusion__unionMember( + "The name of the member type." + member: String! + "The name of the source schema that provides the specified member type." + schema: fusion__Schema! +) repeatable on UNION diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-extensions.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-extensions.graphqls new file mode 100644 index 00000000000..23c5221bd04 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-extensions.graphqls @@ -0,0 +1,3 @@ +extend type Query { + productById(id: ID!): Product @lookup +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-settings.json b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-settings.json new file mode 100644 index 00000000000..77c748ac2c5 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1-settings.json @@ -0,0 +1,8 @@ +{ + "name": "Schema1", + "transports": { + "http": { + "url": "http://localhost/graphql" + } + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1.graphqls new file mode 100644 index 00000000000..70a9d1ce050 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions/source-schema-1.graphqls @@ -0,0 +1,11 @@ +schema { + query: Query +} + +type Query { + productById(id: ID!): Product +} + +type Product { + id: ID! +} From e2813081f960fe768d18f1407ebcd290f28fb6c9 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 30 Apr 2026 09:59:50 +0200 Subject: [PATCH 2/6] Address Copilot feedback --- .../src/Fusion.Aspire/SchemaComposition.cs | 20 +++++++++-- .../Fusion/FusionCompositionHelpers.cs | 12 +++++-- .../CommandLine/src/CommandLine/Messages.cs | 3 ++ .../Fusion/FusionComposeCommandTests.cs | 34 ++++++++++++++++--- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs index 432122e2baf..17bb5d66a70 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs @@ -269,7 +269,17 @@ private List GetReferencedResources( { var sourceSchemaName = resource.GetGraphQLSourceSchemaName() ?? resource.Name; - var schemaFromFile = await ReadSchemaFromProjectDirectoryAsync(resource, annotation.SchemaPath, cancellationToken); + var schemaPath = annotation.SchemaPath ?? "schema.graphql"; + + if (IsExtensionsSchemaPath(schemaPath)) + { + logger.LogWarning( + "Schema extensions file '{SchemaPath}' cannot be used as a source schema file. Provide the base schema file instead.", + schemaPath); + return null; + } + + var schemaFromFile = await ReadSchemaFromProjectDirectoryAsync(resource, schemaPath, cancellationToken); if (schemaFromFile == null) { return null; @@ -277,8 +287,7 @@ private List GetReferencedResources( // For file schemas, settings file is named after the schema file // e.g., "foo.graphql" -> "foo-settings.json" - var schemaFileName = annotation.SchemaPath ?? "schema.graphql"; - var settingsFileName = $"{IOPath.GetFileNameWithoutExtension(schemaFileName)}-settings.json"; + var settingsFileName = $"{IOPath.GetFileNameWithoutExtension(schemaPath)}-settings.json"; var schemaSettings = await GetSourceSchemaSettingsAsync(resource, settingsFileName, cancellationToken); if (schemaSettings == null) @@ -538,4 +547,9 @@ private async Task ComposeSchemaAsync( return false; } + + private static bool IsExtensionsSchemaPath(string filePath) + => IOPath.GetFileNameWithoutExtension(filePath).EndsWith( + "-extensions", + StringComparison.OrdinalIgnoreCase); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs index d0e439f6c42..91d44212aee 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs @@ -70,11 +70,19 @@ public static bool IsExtensionsFile(string? fileName) schemaFilePath = fileSystem .GetFiles(sourceSchemaPath, "*.graphql*", SearchOption.AllDirectories) - .FirstOrDefault(f => IsSchemaFile(Path.GetFileName(f)) - && !IsExtensionsFile(Path.GetFileName(f))); + .FirstOrDefault(f => + { + var name = Path.GetFileName(f); + return IsSchemaFile(name) && !IsExtensionsFile(name); + }); } else if (fileSystem.FileExists(sourceSchemaPath)) { + if (IsExtensionsFile(Path.GetFileName(sourceSchemaPath))) + { + throw new ExitException(Messages.SchemaExtensionsFileCannotBeUsedAsSchemaFile(sourceSchemaPath)); + } + schemaFilePath = sourceSchemaPath; } diff --git a/src/Nitro/CommandLine/src/CommandLine/Messages.cs b/src/Nitro/CommandLine/src/CommandLine/Messages.cs index 2434c05d96b..c7f2e8abfbd 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Messages.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Messages.cs @@ -21,6 +21,9 @@ public static string UnexpectedMutationError(IError error) public static string SchemaSettingsFileDoesNotExist(string path) => $"Schema settings file '{path}' does not exist."; + public static string SchemaExtensionsFileCannotBeUsedAsSchemaFile(string path) + => $"Schema extensions file '{path}' cannot be used as a source schema file. Provide the base schema file instead."; + public static string ArchiveFileDoesNotExist(string path) => $"Archive file '{path}' does not exist."; public static string LegacyArchiveFileDoesNotExist(string path) => $"Legacy archive file '{path}' does not exist."; diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs index 126e807891f..baa90242c12 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs @@ -20,6 +20,9 @@ public sealed class FusionComposeCommandTests(NitroCommandFixture fixture) private static readonly string s_validExcludeByTagCompositeSchema = File.ReadAllText("__resources__/valid-exclude-by-tag-result/composite-schema.graphqls"); + private static readonly string s_validExtensionsCompositeSchema = + File.ReadAllText("__resources__/valid-extensions-result/composite-schema.graphqls"); + [Fact] public async Task Help_ReturnsSuccess() { @@ -490,8 +493,7 @@ public async Task Compose_Valid_Extensions() var sourceText = await ReadSchemaAsync(config); sourceText .ReplaceLineEndings("\n") - .MatchInlineSnapshot( - await File.ReadAllTextAsync("__resources__/valid-extensions-result/composite-schema.graphqls")); + .MatchInlineSnapshot(s_validExtensionsCompositeSchema); } [Fact] @@ -533,8 +535,32 @@ public async Task Compose_Valid_Extensions_FromDirectory() var sourceText = await ReadSchemaAsync(config); sourceText .ReplaceLineEndings("\n") - .MatchInlineSnapshot( - await File.ReadAllTextAsync("__resources__/valid-extensions-result/composite-schema.graphqls")); + .MatchInlineSnapshot(s_validExtensionsCompositeSchema); + } + + [Fact] + public async Task Compose_Valid_Extensions_PointingToSidecar_ReturnsError() + { + // arrange + var archiveFileName = CreateTempFile(); + var sidecarPath = Path.Combine( + s_resourcesDir, "valid-extensions", "source-schema-1-extensions.graphqls"); + + SetupFile(sidecarPath, new MemoryStream(await File.ReadAllBytesAsync(sidecarPath))); + + // act + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + sidecarPath, + "--archive", + archiveFileName); + + // assert + result.AssertError( + $"Schema extensions file '{sidecarPath}' cannot be used as a source schema file. " + + "Provide the base schema file instead."); } [Fact] From 00ad349baa47fea5112e12a207f92b009985705e Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 7 May 2026 11:10:01 +0200 Subject: [PATCH 3/6] Persist source schema extensions in Fusion archives Store the optional schema extensions sidecar as schema-extensions.graphqls inside both FusionArchive (per source schema) and FusionSourceSchemaArchive, instead of concatenating it into the base schema text. Threads ExtensionsSourceText end-to-end through composition, upload, publish, and the Aspire host, and removes a stale extensions entry when a re-composition supplies no sidecar. --- .../src/Fusion.Aspire/SchemaComposition.cs | 18 ++-- .../Fusion.Composition/CompositionHelper.cs | 25 +++++- .../Logging/LogEntryHelper.cs | 11 ++- .../CompositionResources.Designer.cs | 9 ++ .../Properties/CompositionResources.resx | 3 + .../Fusion.Composition/SourceSchemaParser.cs | 43 +++++++--- .../Fusion.Composition/SourceSchemaText.cs | 3 +- .../src/Fusion.Packaging/ArchiveSession.cs | 52 ++++++++++++ .../Fusion/src/Fusion.Packaging/FileNames.cs | 5 ++ .../src/Fusion.Packaging/FusionArchive.cs | 29 ++++++- .../SourceSchemaConfiguration.cs | 11 +++ .../FileNames.cs | 1 + .../FusionSourceSchemaArchive.cs | 59 +++++++++++++ .../FusionArchiveTests.cs | 83 +++++++++++++++++++ .../FusionSourceSchemaArchiveTests.cs | 45 ++++++++++ .../Fusion/FusionCompositionHelpers.cs | 12 +-- .../Commands/Fusion/FusionPublishCommand.cs | 9 +- .../Commands/Fusion/FusionUploadCommand.cs | 5 ++ .../FusionSourceSchemaArchiveHelper.cs | 7 ++ 19 files changed, 392 insertions(+), 38 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs index 17bb5d66a70..f918a5e7577 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs @@ -280,7 +280,7 @@ private List GetReferencedResources( } var schemaFromFile = await ReadSchemaFromProjectDirectoryAsync(resource, schemaPath, cancellationToken); - if (schemaFromFile == null) + if (schemaFromFile is not { } schemaFiles) { return null; } @@ -300,7 +300,7 @@ private List GetReferencedResources( Name = sourceSchemaName, ResourceName = resource.Name, HttpEndpointUrl = null, // No HTTP endpoint for file-based schemas - Schema = new SourceSchemaText(sourceSchemaName, schemaFromFile), + Schema = new SourceSchemaText(sourceSchemaName, schemaFiles.Schema, schemaFiles.Extensions), SchemaSettings = schemaSettings }; } @@ -368,7 +368,7 @@ private List GetReferencedResources( } } - private async Task ReadSchemaFromProjectDirectoryAsync( + private async Task<(string Schema, string? Extensions)?> ReadSchemaFromProjectDirectoryAsync( IResourceWithEndpoints resource, string? fileName, CancellationToken cancellationToken) @@ -400,19 +400,13 @@ private List GetReferencedResources( + "-extensions" + IOPath.GetExtension(schemaFile)); + string? extensionsText = null; if (File.Exists(extensionsFile)) { - var extensionsText = await File.ReadAllTextAsync(extensionsFile, cancellationToken); - - if (schemaText.Length > 0 && !schemaText.EndsWith('\n')) - { - schemaText += "\n"; - } - - schemaText += extensionsText; + extensionsText = await File.ReadAllTextAsync(extensionsFile, cancellationToken); } - return schemaText; + return (schemaText, extensionsText); } catch (Exception ex) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs index 69bdaea4213..ddc9d6f650a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs @@ -75,8 +75,11 @@ await archive.GetSourceSchemaNamesAsync(cancellationToken), } var sourceText = await ReadSchemaSourceTextAsync(configuration, cancellationToken); + var extensionsSourceText = await TryReadSchemaExtensionsTextAsync(configuration, cancellationToken); - sourceSchemas[schemaName] = (new SourceSchemaText(schemaName, sourceText), configuration.Settings); + sourceSchemas[schemaName] = ( + new SourceSchemaText(schemaName, sourceText, extensionsSourceText), + configuration.Settings); } var existingCompositionSettings = await GetCompositionSettingsAsync(archive, cancellationToken); @@ -134,10 +137,15 @@ await archive.GetSourceSchemaNamesAsync(cancellationToken), foreach (var (schemaName, (schema, settings)) in sourceSchemas) { + var schemaExtensions = schema.ExtensionsSourceText is null + ? default + : Encoding.UTF8.GetBytes(schema.ExtensionsSourceText); + await archive.SetSourceSchemaConfigurationAsync( schemaName, Encoding.UTF8.GetBytes(schema.SourceText), settings, + schemaExtensions, cancellationToken); } @@ -194,4 +202,19 @@ private static async Task ReadSchemaSourceTextAsync( using var reader = new StreamReader(stream, Encoding.UTF8); return await reader.ReadToEndAsync(cancellationToken); } + + private static async Task TryReadSchemaExtensionsTextAsync( + SourceSchemaConfiguration configuration, + CancellationToken cancellationToken) + { + await using var stream = await configuration.TryOpenReadSchemaExtensionsAsync(cancellationToken); + + if (stream is null) + { + return null; + } + + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(cancellationToken); + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs index 2c2392402da..4c3b6246d22 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -536,10 +536,17 @@ public static LogEntry InvalidFieldSharing( .Build(); } - public static LogEntry InvalidGraphQL(string exceptionMessage, MutableSchemaDefinition schema) + public static LogEntry InvalidGraphQL( + string exceptionMessage, + MutableSchemaDefinition schema, + bool inExtensions = false) { return LogEntryBuilder.New() - .SetMessage(LogEntryHelper_InvalidGraphQL, exceptionMessage) + .SetMessage( + inExtensions + ? LogEntryHelper_InvalidGraphQLInExtensions + : LogEntryHelper_InvalidGraphQL, + exceptionMessage) .SetCode(LogEntryCodes.InvalidGraphQL) .SetSeverity(LogSeverity.Error) .SetSchema(schema) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index d34e4766422..5a7a5b5f4e2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -997,6 +997,15 @@ internal static string LogEntryHelper_InvalidGraphQL { } } + /// + /// Looks up a localized string similar to Invalid GraphQL in source schema extensions. Exception message: {0}.. + /// + internal static string LogEntryHelper_InvalidGraphQLInExtensions { + get { + return ResourceManager.GetString("LogEntryHelper_InvalidGraphQLInExtensions", resourceCulture); + } + } + /// /// Looks up a localized string similar to The field '{0}' in schema '{1}' must not be marked as shareable.. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index 206ffcea0d6..20f06dbd096 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -327,6 +327,9 @@ Invalid GraphQL in source schema. Exception message: {0}. + + Invalid GraphQL in source schema extensions. Exception message: {0}. + The field '{0}' in schema '{1}' must not be marked as shareable. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs index bdc2b8fc98a..8ec07cdf7c8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs @@ -24,6 +24,7 @@ public CompositionResult Parse() schema.AddBuiltInFusionTypes(); schema.AddBuiltInFusionDirectives(); + // Parse source schema. try { SchemaParser.Parse( @@ -34,22 +35,42 @@ public CompositionResult Parse() IgnoreExistingTypes = true, IgnoreExistingDirectives = true }); + } + catch (Exception ex) + { + log.Write(LogEntryHelper.InvalidGraphQL(ex.Message, schema)); + } - // Schema validation. - if (_options.EnableSchemaValidation) + // Parse optional source schema extensions. + if (sourceSchemaText.ExtensionsSourceText is not null) + { + try { - var validationLog = new ValidationLog(); - s_schemaValidator.Validate(schema, validationLog); - - if (validationLog.HasErrors) - { - log.WriteValidationLog(validationLog, schema); - } + SchemaParser.Parse( + schema, + sourceSchemaText.ExtensionsSourceText, + new SchemaParserOptions + { + IgnoreExistingTypes = true, + IgnoreExistingDirectives = true + }); + } + catch (Exception ex) + { + log.Write(LogEntryHelper.InvalidGraphQL(ex.Message, schema, inExtensions: true)); } } - catch (Exception ex) + + // Schema validation. + if (_options.EnableSchemaValidation) { - log.Write(LogEntryHelper.InvalidGraphQL(ex.Message, schema)); + var validationLog = new ValidationLog(); + s_schemaValidator.Validate(schema, validationLog); + + if (validationLog.HasErrors) + { + log.WriteValidationLog(validationLog, schema); + } } return log.HasErrors diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaText.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaText.cs index 4efb46bfc69..376bb822b93 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaText.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaText.cs @@ -7,4 +7,5 @@ namespace HotChocolate.Fusion; /// public readonly record struct SourceSchemaText( string Name, - string SourceText); + string SourceText, + string? ExtensionsSourceText = null); diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs index e5fd36ae940..112b4563b8c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs @@ -119,6 +119,50 @@ public Stream OpenWrite(string path) return stream; } + public void Delete(string path) + { + if (_mode is FusionArchiveMode.Read) + { + throw new InvalidOperationException("Cannot delete from a read-only archive."); + } + + if (_files.TryGetValue(path, out var file)) + { + if (file.State is FileState.Deleted) + { + return; + } + + if (file.State is FileState.Created) + { + // File was added in this uncommitted session and never existed + // in the original archive: drop it entirely. + if (File.Exists(file.TempPath)) + { + try + { + File.Delete(file.TempPath); + } + catch + { + // ignore + } + } + + _files.Remove(path); + return; + } + + file.MarkDeleted(); + return; + } + + if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null) + { + _files.Add(path, FileEntry.Deleted(path)); + } + } + public void SetMode(FusionArchiveMode mode) { _mode = mode; @@ -262,6 +306,11 @@ public void MarkMutated() } } + public void MarkDeleted() + { + State = FileState.Deleted; + } + public void MarkRead() { State = FileState.Read; @@ -273,6 +322,9 @@ public static FileEntry Created(string path) public static FileEntry Read(string path) => new(path, GetRandomTempFileName(), FileState.Read); + public static FileEntry Deleted(string path) + => new(path, GetRandomTempFileName(), FileState.Deleted); + private static string GetRandomTempFileName() { var tempDir = System.IO.Path.GetTempPath(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/FileNames.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/FileNames.cs index f9c71ecea88..82f2358f64a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/FileNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/FileNames.cs @@ -5,6 +5,7 @@ internal static class FileNames private const string GatewaySchemaFormat = "gateway/{0}/gateway.graphqls"; private const string GatewaySettingsFormat = "gateway/{0}/gateway-settings.json"; private const string SourceSchemaFormat = "source-schemas/{0}/schema.graphqls"; + private const string SourceSchemaExtensionsFormat = "source-schemas/{0}/schema-extensions.graphqls"; private const string SourceSchemaSettingsFormat = "source-schemas/{0}/schema-settings.json"; public const string ArchiveMetadata = "archive-metadata.json"; @@ -22,6 +23,9 @@ public static string GetGatewaySettingsPath(Version version) public static string GetSourceSchemaPath(string schemaName) => string.Format(SourceSchemaFormat, schemaName); + public static string GetSourceSchemaExtensionsPath(string schemaName) + => string.Format(SourceSchemaExtensionsFormat, schemaName); + public static string GetSourceSchemaSettingsPath(string schemaName) => string.Format(SourceSchemaSettingsFormat, schemaName); @@ -31,6 +35,7 @@ public static FileKind GetFileKind(string fileName) { case "gateway.graphqls": case "schema.graphqls": + case "schema-extensions.graphqls": return FileKind.Schema; case "schema-settings.json": diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs index e8b8459c6d1..31b62f84786 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs @@ -446,6 +446,9 @@ Task OpenReadSchemaAsync(CancellationToken ct) /// The name of the source schema. /// The source schema as UTF-8 encoded bytes. /// The source schema configuration. + /// + /// The source schema extensions as UTF-8 encoded bytes. If empty, no extensions file is written. + /// /// Token to cancel the operation. /// Thrown when schemaName is null, empty, or invalid. /// Thrown when schema is empty. @@ -455,6 +458,7 @@ public async Task SetSourceSchemaConfigurationAsync( string schemaName, ReadOnlyMemory schema, JsonDocument settings, + ReadOnlyMemory schemaExtensions = default, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(schemaName); @@ -488,6 +492,17 @@ public async Task SetSourceSchemaConfigurationAsync( await stream.WriteAsync(schema, cancellationToken); } + if (schemaExtensions.Length > 0) + { + await using var stream = _session.OpenWrite(FileNames.GetSourceSchemaExtensionsPath(schemaName)); + await stream.WriteAsync(schemaExtensions, cancellationToken); + } + else + { + // Ensure no stale extensions linger from a previous composition. + _session.Delete(FileNames.GetSourceSchemaExtensionsPath(schemaName)); + } + await using (var stream = _session.OpenWrite(FileNames.GetSourceSchemaSettingsPath(schemaName))) { await using var jsonWriter = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); @@ -528,10 +543,22 @@ public async Task SetSourceSchemaConfigurationAsync( settings = await JsonDocument.ParseAsync(stream, default, cancellationToken); } - return new SourceSchemaConfiguration(OpenReadSchemaAsync, settings); + return new SourceSchemaConfiguration(OpenReadSchemaAsync, TryOpenReadSchemaExtensionsAsync, settings); Task OpenReadSchemaAsync(CancellationToken ct) => _session.OpenReadAsync(FileNames.GetSourceSchemaPath(schemaName), FileKind.Schema, ct); + + async Task TryOpenReadSchemaExtensionsAsync(CancellationToken ct) + { + var extensionsPath = FileNames.GetSourceSchemaExtensionsPath(schemaName); + + if (!await _session.ExistsAsync(extensionsPath, FileKind.Schema, ct)) + { + return null; + } + + return await _session.OpenReadAsync(extensionsPath, FileKind.Schema, ct); + } } /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/SourceSchemaConfiguration.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/SourceSchemaConfiguration.cs index 2a9dc43c8e8..9eafabcc964 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/SourceSchemaConfiguration.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/SourceSchemaConfiguration.cs @@ -8,16 +8,20 @@ namespace HotChocolate.Fusion.Packaging; public sealed class SourceSchemaConfiguration : IDisposable { private readonly Func> _openReadSchema; + private readonly Func> _tryOpenReadSchemaExtensions; private bool _disposed; internal SourceSchemaConfiguration( Func> openReadSchema, + Func> tryOpenReadSchemaExtensions, JsonDocument settings) { ArgumentNullException.ThrowIfNull(openReadSchema); + ArgumentNullException.ThrowIfNull(tryOpenReadSchemaExtensions); ArgumentNullException.ThrowIfNull(settings); _openReadSchema = openReadSchema; + _tryOpenReadSchemaExtensions = tryOpenReadSchemaExtensions; Settings = settings; } @@ -27,6 +31,13 @@ internal SourceSchemaConfiguration( public Task OpenReadSchemaAsync(CancellationToken cancellationToken = default) => _openReadSchema(cancellationToken); + /// + /// Tries to open the Hot Chocolate Fusion source schema extensions for reading. + /// Returns null if no extensions are stored alongside the source schema. + /// + public Task TryOpenReadSchemaExtensionsAsync(CancellationToken cancellationToken = default) + => _tryOpenReadSchemaExtensions(cancellationToken); + /// /// Gets the settings of the source schema configuration. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FileNames.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FileNames.cs index dd971a69486..f60942326e8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FileNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FileNames.cs @@ -4,5 +4,6 @@ internal static class FileNames { public const string ArchiveMetadata = "archive-metadata.json"; public const string GraphQLSchema = "schema.graphqls"; + public const string GraphQLSchemaExtensions = "schema-extensions.graphqls"; public const string Settings = "schema-settings.json"; } diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs index 8931fdd0699..84325dcdc61 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs @@ -244,6 +244,65 @@ public async Task SetSchemaAsync( } } + /// + /// Sets the GraphQL schema extensions of the source schema. + /// + /// The GraphQL schema extensions to store. + /// Token to cancel the operation. + /// Thrown when the GraphQL schema extensions are empty. + /// Thrown when the archive has been disposed. + /// Thrown when the archive is read-only. + public async Task SetSchemaExtensionsAsync( + ReadOnlyMemory schemaExtensions, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(schemaExtensions.Length, 0); + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureMutable(); + + await using (var stream = _session.OpenWrite(FileNames.GraphQLSchemaExtensions)) + { + await stream.WriteAsync(schemaExtensions, cancellationToken); + } + } + + /// + /// Tries to get the GraphQL schema extensions of the source schema. + /// + /// Token to cancel the operation. + /// The GraphQL schema extensions of the source schema if found, or null if not found. + /// Thrown when the archive has been disposed. + public async Task?> TryGetSchemaExtensionsAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + const string extensionsPath = FileNames.GraphQLSchemaExtensions; + + if (!_session.Exists(extensionsPath)) + { + return null; + } + + var buffer = TryRentBuffer(); + + try + { + await using var extensionsStream = await _session.OpenReadAsync( + extensionsPath, + FileKind.GraphQLSchema, + cancellationToken); + await extensionsStream.CopyToAsync(buffer, cancellationToken); + + return buffer.WrittenMemory.ToArray(); + } + finally + { + TryReturnBuffer(buffer); + } + } + /// /// Sets the settings of the source schema. /// diff --git a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs index 8f699ef9b1c..cff1c9a520a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs @@ -305,6 +305,89 @@ public async Task SetSourceSchema_WithValidSchema_StoresCorrectly() Assert.Equal(Encoding.UTF8.GetString(schemaContent), retrievedSchema); } + [Fact] + public async Task SetSourceSchema_WithSchemaExtensions_StoresAndReturnsExtensions() + { + // Arrange + await using var stream = CreateStream(); + var schemaContent = "type User { id: ID! }"u8.ToArray(); + var extensionsContent = "extend type User { name: String! }"u8.ToArray(); + var settings = CreateSettingsJson(); + const string schemaName = "user-service"; + + // Act + using var archive = FusionArchive.Create(stream, leaveOpen: true); + var metadata = CreateTestMetadata(); + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings, extensionsContent); + + // Assert + var found = await archive.TryGetSourceSchemaConfigurationAsync(schemaName); + Assert.NotNull(found); + + await using var extensionsStream = await found.TryOpenReadSchemaExtensionsAsync(); + Assert.NotNull(extensionsStream); + using var reader = new StreamReader(extensionsStream); + var retrievedExtensions = await reader.ReadToEndAsync(); + Assert.Equal(Encoding.UTF8.GetString(extensionsContent), retrievedExtensions); + } + + [Fact] + public async Task SetSourceSchema_WithoutSchemaExtensions_RemovesPreviouslyStoredExtensions() + { + // Arrange + await using var stream = CreateStream(); + var schemaContent = "type User { id: ID! }"u8.ToArray(); + var extensionsContent = "extend type User { name: String! }"u8.ToArray(); + var settings = CreateSettingsJson(); + const string schemaName = "user-service"; + var metadata = CreateTestMetadata(); + + // Act - initial archive with extensions + using (var archive = FusionArchive.Create(stream, leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings, extensionsContent); + await archive.CommitAsync(); + } + + // Act - re-open in update mode and overwrite the same source schema without extensions + stream.Position = 0; + using (var updateArchive = FusionArchive.Open(stream, FusionArchiveMode.Update, leaveOpen: true)) + { + await updateArchive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings); + await updateArchive.CommitAsync(); + } + + // Assert - extensions are gone + stream.Position = 0; + using var readArchive = FusionArchive.Open(stream, leaveOpen: true); + var found = await readArchive.TryGetSourceSchemaConfigurationAsync(schemaName); + Assert.NotNull(found); + Assert.Null(await found.TryOpenReadSchemaExtensionsAsync()); + } + + [Fact] + public async Task SetSourceSchema_WithoutSchemaExtensions_TryOpenReadSchemaExtensionsReturnsNull() + { + // Arrange + await using var stream = CreateStream(); + var schemaContent = "type User { id: ID! }"u8.ToArray(); + var settings = CreateSettingsJson(); + const string schemaName = "user-service"; + + // Act + using var archive = FusionArchive.Create(stream, leaveOpen: true); + var metadata = CreateTestMetadata(); + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings); + + // Assert + var found = await archive.TryGetSourceSchemaConfigurationAsync(schemaName); + Assert.NotNull(found); + Assert.Null(await found.TryOpenReadSchemaExtensionsAsync()); + } + [Fact] public async Task SetSourceSchema_WithInvalidSchemaName_ThrowsArgumentException() { diff --git a/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs b/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs index fc6a06e905f..7a16cd7a97c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs @@ -116,6 +116,51 @@ public async Task TryGetSchema_WhenNotSet_ReturnsNull() Assert.Null(result); } + [Fact] + public async Task SetSchemaExtensions_WithValidData_StoresCorrectly() + { + // arrange + await using var stream = CreateStream(); + var extensions = "extend type Query { hello: String }"u8.ToArray(); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetSchemaExtensionsAsync(extensions); + + // assert + var retrieved = await archive.TryGetSchemaExtensionsAsync(); + + Assert.NotNull(retrieved); + Assert.Equal(extensions, retrieved.Value.ToArray()); + } + + [Fact] + public async Task SetSchemaExtensions_WithEmptyExtensions_ThrowsArgumentOutOfRangeException() + { + // arrange + await using var stream = CreateStream(); + var extensions = ReadOnlyMemory.Empty; + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await Assert.ThrowsAsync( + () => archive.SetSchemaExtensionsAsync(extensions)); + } + + [Fact] + public async Task TryGetSchemaExtensions_WhenNotSet_ReturnsNull() + { + // arrange + await using var stream = CreateStream(); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream); + var result = await archive.TryGetSchemaExtensionsAsync(); + + // assert + Assert.Null(result); + } + [Fact] public async Task SetSettings_WithValidData_StoresCorrectly() { diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs index 91d44212aee..af0a2add7a4 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionCompositionHelpers.cs @@ -117,18 +117,12 @@ public static bool IsExtensionsFile(string? fileName) + "-extensions" + Path.GetExtension(schemaFilePath)); + string? extensionsSourceText = null; if (fileSystem.FileExists(extensionsFilePath)) { - var extensionsText = await fileSystem.ReadAllTextAsync(extensionsFilePath, cancellationToken); - - if (sourceText.Length > 0 && !sourceText.EndsWith('\n')) - { - sourceText += "\n"; - } - - sourceText += extensionsText; + extensionsSourceText = await fileSystem.ReadAllTextAsync(extensionsFilePath, cancellationToken); } - return (schemaName, new SourceSchemaText(schemaName, sourceText), settings); + return (schemaName, new SourceSchemaText(schemaName, sourceText, extensionsSourceText), settings); } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionPublishCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionPublishCommand.cs index b798603b72c..0618c3c34bb 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionPublishCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionPublishCommand.cs @@ -251,10 +251,17 @@ await client.DownloadSourceSchemaArchiveAsync( $"Archive of source schema '{sourceSchemaVersion.Name}' does not contain a GraphQL schema."); } + var schemaExtensions = await archive.TryGetSchemaExtensionsAsync(cancellationToken); + var schemaName = sourceSchemaVersion.Name; var schemaText = Encoding.UTF8.GetString(schema.Value.Span); + var extensionsText = schemaExtensions.HasValue + ? Encoding.UTF8.GetString(schemaExtensions.Value.Span) + : null; - newSourceSchemas.Add(schemaName, (new SourceSchemaText(schemaName, schemaText), settings)); + newSourceSchemas.Add( + schemaName, + (new SourceSchemaText(schemaName, schemaText, extensionsText), settings)); } downloadSourceSchemaActivity.Success($"Downloaded {sourceSchemaVersions.Length} source schema(s)."); diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionUploadCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionUploadCommand.cs index f9526753b54..d230e2f4242 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionUploadCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionUploadCommand.cs @@ -69,9 +69,14 @@ private static async Task ExecuteAsync( $"Uploading new version '{tag.EscapeMarkup()}' for source schema '{sourceSchemaName.EscapeMarkup()}' of API '{apiId.EscapeMarkup()}'", "Failed to upload a new source schema version."); + var schemaExtensions = sourceText.ExtensionsSourceText is null + ? default + : Encoding.UTF8.GetBytes(sourceText.ExtensionsSourceText); + await using var archiveStream = await FusionSourceSchemaArchiveHelper.CreateArchiveStreamAsync( Encoding.UTF8.GetBytes(sourceText.SourceText), settings, + schemaExtensions, cancellationToken); var result = await fusionConfigurationClient.UploadFusionSubgraphAsync( diff --git a/src/Nitro/CommandLine/src/CommandLine/Helpers/FusionSourceSchemaArchiveHelper.cs b/src/Nitro/CommandLine/src/CommandLine/Helpers/FusionSourceSchemaArchiveHelper.cs index 03ec8f96e19..eb155e7cfa2 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Helpers/FusionSourceSchemaArchiveHelper.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Helpers/FusionSourceSchemaArchiveHelper.cs @@ -8,6 +8,7 @@ internal static class FusionSourceSchemaArchiveHelper public static async Task CreateArchiveStreamAsync( ReadOnlyMemory schema, JsonDocument settings, + ReadOnlyMemory schemaExtensions = default, CancellationToken cancellationToken = default) { var archiveStream = new MemoryStream(); @@ -15,6 +16,12 @@ public static async Task CreateArchiveStreamAsync( await archive.SetArchiveMetadataAsync(new ArchiveMetadata(), cancellationToken); await archive.SetSchemaAsync(schema, cancellationToken); + + if (schemaExtensions.Length > 0) + { + await archive.SetSchemaExtensionsAsync(schemaExtensions, cancellationToken); + } + await archive.SetSettingsAsync(settings, cancellationToken); await archive.CommitAsync(cancellationToken); From a183a83391ad401f2ae65200fef9225d7147b094 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 7 May 2026 11:31:48 +0200 Subject: [PATCH 4/6] Update snapshot --- .../valid-extensions-result/composite-schema.graphqls | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls index e3c6d4b30c5..720bfa0f527 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls @@ -37,6 +37,12 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"The @fusion__connector directive declares which connector kind handles a source schema." +directive @fusion__connector( + "The kind of connector that handles the source schema represented by this enum value." + kind: String! +) on ENUM_VALUE + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." From 9f295498edb6bc17136772ab6e5266c6c45419ec Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 7 May 2026 11:39:38 +0200 Subject: [PATCH 5/6] Address Copilot feedback --- .../src/Fusion.Packaging/ArchiveSession.cs | 39 +++++++++++------ .../FusionArchiveTests.cs | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs index 112b4563b8c..96771aa75bd 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs @@ -36,6 +36,14 @@ public IEnumerable GetFiles() foreach (var entry in _archive.Entries) { + // Skip entries that are explicitly marked Deleted in this session; + // they are still in the underlying ZipArchive but logically gone. + if (_files.TryGetValue(entry.FullName, out var tracked) + && tracked.State is FileState.Deleted) + { + continue; + } + files.Add(entry.FullName); } @@ -137,22 +145,14 @@ public void Delete(string path) { // File was added in this uncommitted session and never existed // in the original archive: drop it entirely. - if (File.Exists(file.TempPath)) - { - try - { - File.Delete(file.TempPath); - } - catch - { - // ignore - } - } - + TryDeleteTempFile(file); _files.Remove(path); return; } + // File was previously read or replaced (extracted to a temp file). + // Clean up the temp file now since Dispose skips Deleted entries. + TryDeleteTempFile(file); file.MarkDeleted(); return; } @@ -163,6 +163,21 @@ public void Delete(string path) } } + private static void TryDeleteTempFile(FileEntry file) + { + if (File.Exists(file.TempPath)) + { + try + { + File.Delete(file.TempPath); + } + catch + { + // ignore + } + } + } + public void SetMode(FusionArchiveMode mode) { _mode = mode; diff --git a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs index cff1c9a520a..afb895574c6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveTests.cs @@ -511,6 +511,48 @@ public async Task VerifySignature_WithValidSignature_ReturnsValid() } } + [Fact] + public async Task SignArchive_AfterRemovingSchemaExtensions_ProducesValidSignature() + { + // Arrange + await using var stream = CreateStream(); + using var cert = CreateTestCertificate(); +#if NET9_0_OR_GREATER + using var publicOnlyCert = X509CertificateLoader.LoadCertificate(cert.Export(X509ContentType.Cert)); +#else + using var publicOnlyCert = new X509Certificate2(cert.Export(X509ContentType.Cert)); +#endif + + var schemaContent = "type User { id: ID! }"u8.ToArray(); + var extensionsContent = "extend type User { name: String! }"u8.ToArray(); + var settings = CreateSettingsJson(); + const string schemaName = "user-service"; + var metadata = CreateTestMetadata(); + + // Act - initial archive with extensions + using (var archive = FusionArchive.Create(stream, leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings, extensionsContent); + await archive.CommitAsync(); + } + + // Act - re-open in update mode, drop extensions, then sign + stream.Position = 0; + using (var updateArchive = FusionArchive.Open(stream, FusionArchiveMode.Update, leaveOpen: true)) + { + await updateArchive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings); + await updateArchive.SignArchiveAsync(cert); + await updateArchive.CommitAsync(); + } + + // Assert - signature is valid against the post-deletion file set + stream.Position = 0; + using var readArchive = FusionArchive.Open(stream, leaveOpen: true); + var result = await readArchive.VerifySignatureAsync(publicOnlyCert); + Assert.Equal(SignatureVerificationResult.Valid, result); + } + [Fact] public async Task VerifySignature_WithUnsignedArchive_ReturnsNotSigned() { From 530b33b096ecc9111c8c40ed029c3012eeffc331 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 7 May 2026 11:51:35 +0200 Subject: [PATCH 6/6] Address more Copilot feedback --- .../SchemaComposerTests.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs index bcbc46ed828..87b3ad49538 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -20,26 +20,27 @@ type Query { lookups: InternalLookups! } + type Product { + id: ID! + hidden: Int + } + + type InternalLookups { + productBySku(sku: ID!): Product + } + """, + """ extend type Query { productById1(id: ID!): Product @lookup productById2(id: ID!): Product @internal lookups: InternalLookups! @internal } - type Product { - id: ID! - hidden: Int - } - extend type Product { sku: String! hidden: Int @inaccessible } - type InternalLookups { - productBySku(sku: ID!): Product - } - extend type InternalLookups @internal { productBySku(sku: ID!): Product @lookup }