Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,34 @@ public static IResourceBuilder<T> WithGraphQLSchemaFile<T>(
/// Marks a resource as needing GraphQL schema composition from its referenced subgraphs.
/// </summary>
/// <param name="builder">The resource builder</param>
/// <param name="outputFileName">The output schema file name (defaults to "gateway.fgp")</param>
/// <param name="settings">The composition settings.</param>
/// <param name="disableValidation">
/// A value indicating whether Nitro schema validation shall be disabled.
/// </param>
/// <param name="outputFileName">The output archive file name.</param>
/// <returns>The resource builder for chaining</returns>
public static IResourceBuilder<T> WithGraphQLSchemaComposition<T>(
this IResourceBuilder<T> builder,
string outputFileName = "gateway.far",
GraphQLCompositionSettings settings = default)
bool disableValidation = false,
string outputFileName = "gateway.far")
where T : IResourceWithEndpoints
Comment on lines 86 to +90
=> builder.WithGraphQLSchemaComposition(
new GraphQLCompositionSettings
{
DisableSchemaValidation = disableValidation
},
outputFileName);

/// <summary>
/// Marks a resource as needing GraphQL schema composition from its referenced subgraphs.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="settings">The composition settings.</param>
/// <param name="outputFileName">The output archive file name.</param>
/// <returns>The resource builder for chaining.</returns>
public static IResourceBuilder<T> WithGraphQLSchemaComposition<T>(
this IResourceBuilder<T> builder,
GraphQLCompositionSettings settings,
string outputFileName = "gateway.far")
where T : IResourceWithEndpoints
{
builder.WithAnnotation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<RootNamespace>HotChocolate.Fusion.Aspire</RootNamespace>
<TargetFrameworks>net11.0; net10.0; net9.0</TargetFrameworks>
<IsAotCompatible>false</IsAotCompatible>
<NoWarn>$(NoWarn);ASPIREINTERACTION001</NoWarn>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
Expand Down Expand Up @@ -37,11 +38,13 @@
<None Remove="Nitro\Operations\PollNitroSchemaValidation.graphql" />
<None Remove="Nitro\Operations\GetNitroStageVersion.graphql" />
<None Remove="Nitro\Operations\WatchNitroStage.graphql" />
<None Remove="Nitro\Operations\GetNitroCompositionSettings.graphql" />
<EmbeddedResource Include="Nitro\Operations\ResolveNitroApiName.graphql" />
<EmbeddedResource Include="Nitro\Operations\ValidateNitroSchema.graphql" />
<EmbeddedResource Include="Nitro\Operations\PollNitroSchemaValidation.graphql" />
<EmbeddedResource Include="Nitro\Operations\GetNitroStageVersion.graphql" />
<EmbeddedResource Include="Nitro\Operations\WatchNitroStage.graphql" />
<EmbeddedResource Include="Nitro\Operations\GetNitroCompositionSettings.graphql" />
</ItemGroup>

<ItemGroup Condition="'$(Configuration)' == 'Release'">
Expand All @@ -50,11 +53,13 @@
<None Remove="Nitro\Operations\PollNitroSchemaValidation.graphql.sha256" />
<None Remove="Nitro\Operations\GetNitroStageVersion.graphql.sha256" />
<None Remove="Nitro\Operations\WatchNitroStage.graphql.sha256" />
<None Remove="Nitro\Operations\GetNitroCompositionSettings.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\ResolveNitroApiName.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\ValidateNitroSchema.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\PollNitroSchemaValidation.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\GetNitroStageVersion.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\WatchNitroStage.graphql.sha256" />
<EmbeddedResource Include="Nitro\Operations\GetNitroCompositionSettings.graphql.sha256" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace HotChocolate.Fusion.Aspire.Nitro;

internal interface INitroCompositionSettingsClient
{
Task<CompositionSettings?> GetAsync(
NitroConnection connection,
string apiId,
string stage,
CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.Text.Json;
using HotChocolate.Fusion.Options;
using HotChocolate.Transport;
using HotChocolate.Transport.Http;

namespace HotChocolate.Fusion.Aspire.Nitro;

internal sealed class NitroCompositionSettingsClient(GraphQLHttpClient client)
: INitroCompositionSettingsClient
{
public async Task<CompositionSettings?> GetAsync(
NitroConnection connection,
string apiId,
string stage,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(apiId);
ArgumentException.ThrowIfNullOrWhiteSpace(stage);
var variables = new Dictionary<string, object?>
{
["apiId"] = apiId,
["stageName"] = stage
};
var request = new GraphQLHttpRequest(
#if NITRO_PERSISTED_OPERATIONS
new OperationRequest(
id: NitroOperationDocuments.GetCompositionSettingsOperationId(),
operationName: NitroOperationDocuments.GetCompositionSettingsOperationName,
variables: variables),
#else
new OperationRequest(
NitroOperationDocuments.GetCompositionSettingsDocument(),
operationName: NitroOperationDocuments.GetCompositionSettingsOperationName,
variables: variables),
#endif
connection.GraphQLEndpoint)
{
OnMessageCreated = (_, requestMessage, _) =>
NitroRequestHeaders.Apply(requestMessage, connection.Credential)
};

using var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();

using var result = await response.ReadAsResultAsync(cancellationToken);
if (result.Errors.ValueKind is JsonValueKind.Array
&& result.Errors.GetArrayLength() > 0)
{
throw new InvalidDataException(
"Nitro returned GraphQL errors for the composition settings operation.");
}

if (result.Data.ValueKind is not JsonValueKind.Object
|| !result.Data.TryGetProperty("node", out var node))
{
throw new InvalidDataException(
"Nitro returned malformed composition settings.");
}

if (node.ValueKind is JsonValueKind.Null)
{
throw new InvalidOperationException(
$"Nitro knows no api with the id '{apiId}'. Check the api id that is passed to "
+ "WithNitroApiId.");
}

if (node.ValueKind is not JsonValueKind.Object
|| !node.TryGetProperty("stage", out var stageElement)
|| stageElement.ValueKind is JsonValueKind.Null)
{
throw new InvalidOperationException(
$"The api with the id '{apiId}' has no stage named '{stage}' in Nitro.");
}

if (stageElement.ValueKind is not JsonValueKind.Object
|| !stageElement.TryGetProperty("compositionSettings", out var settings)
|| settings.ValueKind is JsonValueKind.Null)
{
return null;
}

if (settings.ValueKind is not JsonValueKind.Object)
{
throw new InvalidDataException(
"Nitro returned malformed composition settings.");
}

return new CompositionSettings
{
Preprocessor = new CompositionSettings.PreprocessorSettings
{
ExcludeByTag = ReadStringSet(settings, "excludeByTag")
},
Merger = new CompositionSettings.MergerSettings
{
CacheControlMergeBehavior = ReadDirectiveMergeBehavior(
settings,
"cacheControlMergeBehavior"),
EnableGlobalObjectIdentification = ReadBoolean(
settings,
"enableGlobalObjectIdentification"),
NodeResolution = ReadNodeResolution(settings, "nodeResolution"),
RemoveUnreferencedDefinitions = ReadBoolean(
settings,
"removeUnreferencedDefinitions"),
TagMergeBehavior = ReadDirectiveMergeBehavior(settings, "tagMergeBehavior")
}
};
}

private static bool? ReadBoolean(JsonElement parent, string propertyName)
{
if (!parent.TryGetProperty(propertyName, out var value)
|| value.ValueKind is JsonValueKind.Null)
{
return null;
}

return value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.")
};
}

private static HashSet<string>? ReadStringSet(JsonElement parent, string propertyName)
{
if (!parent.TryGetProperty(propertyName, out var value)
|| value.ValueKind is JsonValueKind.Null)
{
return null;
}

if (value.ValueKind is not JsonValueKind.Array)
{
throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.");
}

var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind is not JsonValueKind.String)
{
throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.");
}

values.Add(item.GetString()!);
}

return values;
}

private static DirectiveMergeBehavior? ReadDirectiveMergeBehavior(
JsonElement parent,
string propertyName)
{
var value = ReadEnum(parent, propertyName);
return value switch
{
null => null,
"IGNORE" => DirectiveMergeBehavior.Ignore,
"INCLUDE" => DirectiveMergeBehavior.Include,
"INCLUDE_PRIVATE" => DirectiveMergeBehavior.IncludePrivate,
_ => throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.")
};
}

private static NodeResolution? ReadNodeResolution(JsonElement parent, string propertyName)
{
var value = ReadEnum(parent, propertyName);
return value switch
{
null => null,
"GATEWAY" => NodeResolution.Gateway,
"SOURCE_SCHEMA" => NodeResolution.SourceSchema,
_ => throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.")
};
}

private static string? ReadEnum(JsonElement parent, string propertyName)
{
if (!parent.TryGetProperty(propertyName, out var value)
|| value.ValueKind is JsonValueKind.Null)
{
return null;
}

return value.ValueKind is JsonValueKind.String
? value.GetString()
: throw new InvalidDataException(
$"Nitro returned an invalid value for '{propertyName}'.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,29 @@ internal static class NitroOperationDocuments
private const string PollSchemaValidationHashFile = "PollNitroSchemaValidation.graphql.sha256";
private const string GetStageVersionHashFile = "GetNitroStageVersion.graphql.sha256";
private const string WatchStageHashFile = "WatchNitroStage.graphql.sha256";
private const string GetCompositionSettingsHashFile =
"GetNitroCompositionSettings.graphql.sha256";

private static string? s_resolveApiNameHash;
private static string? s_validateSchemaHash;
private static string? s_pollSchemaValidationHash;
private static string? s_getStageVersionHash;
private static string? s_watchStageHash;
private static string? s_getCompositionSettingsHash;
#else
private const string ResolveApiNameFile = "ResolveNitroApiName.graphql";
private const string ValidateSchemaFile = "ValidateNitroSchema.graphql";
private const string PollSchemaValidationFile = "PollNitroSchemaValidation.graphql";
private const string GetStageVersionFile = "GetNitroStageVersion.graphql";
private const string WatchStageFile = "WatchNitroStage.graphql";
private const string GetCompositionSettingsFile = "GetNitroCompositionSettings.graphql";

private static string? s_resolveApiName;
private static string? s_validateSchema;
private static string? s_pollSchemaValidation;
private static string? s_getStageVersion;
private static string? s_watchStage;
private static string? s_getCompositionSettings;
#endif

/// <summary>
Expand All @@ -44,6 +49,7 @@ internal static class NitroOperationDocuments
public const string PollSchemaValidationOperationName = "PollNitroSchemaValidation";
public const string GetStageVersionOperationName = "GetNitroStageVersion";
public const string WatchStageOperationName = "WatchNitroStage";
public const string GetCompositionSettingsOperationName = "GetNitroCompositionSettings";

#if NITRO_PERSISTED_OPERATIONS
/// <summary>
Expand All @@ -63,6 +69,10 @@ public static string GetStageVersionOperationId()

public static string GetWatchStageOperationId()
=> s_watchStageHash ??= ReadDocument(WatchStageHashFile).Trim();

public static string GetCompositionSettingsOperationId()
=> s_getCompositionSettingsHash ??=
ReadDocument(GetCompositionSettingsHashFile).Trim();
#else
/// <summary>
/// Gets the document that resolves the name of an api by its id.
Expand All @@ -81,6 +91,9 @@ public static string GetStageVersionDocument()

public static string GetWatchStageDocument()
=> s_watchStage ??= ReadDocument(WatchStageFile);

public static string GetCompositionSettingsDocument()
=> s_getCompositionSettings ??= ReadDocument(GetCompositionSettingsFile);
#endif

private static string ReadDocument(string fileName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,27 +371,24 @@ private void ApplyReportSynchronized(

if (transition is not NotificationTransition.None)
{
Notify(transition, report);
Notify(transition);
}
}

private void Notify(
NotificationTransition transition,
NitroSchemaValidationReport report)
private void Notify(NotificationTransition transition)
{
var message = transition is NotificationTransition.Restored
? $"Client contracts restored (gateway '{_gatewayName}', stage '{_stage}')."
: $"Client contracts broken: {report.ClientCount} clients, "
+ $"{report.OperationCount} operations affected (gateway '{_gatewayName}', "
+ $"stage '{_stage}').";
? $"Schema validation for '{_gatewayName}' against stage '{_stage}' passed again."
: $"Detected breaking schema changes in '{_gatewayName}' against stage '{_stage}'; "
+ "check the logs for details.";

if (transition is NotificationTransition.Restored)
{
_notifier.NotifyRestored(message);
}
else
{
_notifier.NotifyViolations(message);
_notifier.NotifyViolations(_gatewayName, message);
}
}

Expand Down
Loading
Loading