Skip to content

Commit c6e23bc

Browse files
authored
Add Fusion Aspire Nitro integration (#9719)
1 parent 3ca2040 commit c6e23bc

8 files changed

Lines changed: 370 additions & 5 deletions

File tree

src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceBuilderExtensions.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,37 @@ public static IResourceBuilder<T> WithGraphQLSchemaComposition<T>(
8181
return builder;
8282
}
8383

84+
/// <summary>
85+
/// Configures the gateway resource to download a Nitro fusion archive as a seed
86+
/// for schema composition. External subgraphs from the Nitro platform are merged
87+
/// with any locally referenced Aspire subgraphs during composition.
88+
/// </summary>
89+
/// <param name="builder">The resource builder.</param>
90+
/// <param name="apiId">The Nitro API identifier.</param>
91+
/// <param name="stage">The deployment stage to download the configuration for.</param>
92+
/// <param name="alwaysDownload">
93+
/// When <c>true</c>, bypasses the local cache and downloads a fresh archive on every
94+
/// composition. Defaults to <c>false</c>, which uses a cached archive when available.
95+
/// </param>
96+
/// <returns>The resource builder for chaining.</returns>
97+
public static IResourceBuilder<T> WithNitroConfiguration<T>(
98+
this IResourceBuilder<T> builder,
99+
string apiId,
100+
string stage,
101+
bool alwaysDownload = false)
102+
where T : IResourceWithEndpoints
103+
{
104+
builder.WithAnnotation(
105+
new NitroConfigurationAnnotation
106+
{
107+
ApiId = apiId,
108+
Stage = stage,
109+
AlwaysDownload = alwaysDownload
110+
});
111+
112+
return builder;
113+
}
114+
84115
internal static string? GetGraphQLSourceSchemaName(this IResource resource)
85116
{
86117
var annotation = resource.Annotations.OfType<GraphQLSourceSchemaAnnotation>().FirstOrDefault();

src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<RootNamespace>HotChocolate.Fusion.Aspire</RootNamespace>
66
<TargetFrameworks>net10.0; net9.0</TargetFrameworks>
77
<IsAotCompatible>false</IsAotCompatible>
8+
<DefineConstants>$(DefineConstants);FUSION_ASPIRE</DefineConstants>
89
</PropertyGroup>
910

1011
<ItemGroup>
@@ -17,6 +18,18 @@
1718
<ProjectReference Include="..\Fusion.Packaging\HotChocolate.Fusion.Packaging.csproj" />
1819
</ItemGroup>
1920

21+
<ItemGroup>
22+
<Compile Include="..\..\..\..\Nitro\CommandLine\src\CommandLine\Services\Sessions\Session.cs">
23+
<Link>Nitro\Session.cs</Link>
24+
</Compile>
25+
<Compile Include="..\..\..\..\Nitro\CommandLine\src\CommandLine\Services\Sessions\Tokens.cs">
26+
<Link>Nitro\Tokens.cs</Link>
27+
</Compile>
28+
<Compile Include="..\..\..\..\Nitro\CommandLine\src\CommandLine\Services\Sessions\Workspace.cs">
29+
<Link>Nitro\Workspace.cs</Link>
30+
</Compile>
31+
</ItemGroup>
32+
2033
<ItemGroup>
2134
<None Include="$(MSBuildThisFileDirectory)..\MSBuild\HotChocolate.Fusion.Aspire.props" Pack="true" PackagePath="build/HotChocolate.Fusion.Aspire.props" Visible="false" />
2235
<None Include="$(MSBuildThisFileDirectory)..\MSBuild\HotChocolate.Fusion.Aspire.targets" Pack="true" PackagePath="build/HotChocolate.Fusion.Aspire.targets" Visible="false" />
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using System.Net;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using System.Text.Json;
5+
using Microsoft.Extensions.Logging;
6+
using IOPath = System.IO.Path;
7+
8+
namespace HotChocolate.Fusion.Aspire;
9+
10+
internal static class NitroArchiveDownloader
11+
{
12+
private static readonly TimeSpan s_cacheTtl = TimeSpan.FromMinutes(30);
13+
private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNameCaseInsensitive = true };
14+
15+
public static async Task<string?> DownloadOrGetCachedAsync(
16+
NitroConfigurationAnnotation config,
17+
ILogger logger,
18+
CancellationToken cancellationToken)
19+
{
20+
var cachePath = GetCachePath(config);
21+
22+
if (!config.AlwaysDownload && IsCacheFresh(cachePath))
23+
{
24+
logger.LogInformation("📦 Using cached Nitro archive for API '{ApiId}' stage '{Stage}'.",
25+
config.ApiId, config.Stage);
26+
return cachePath;
27+
}
28+
29+
var session = ReadSession(logger);
30+
if (session?.Tokens is null)
31+
{
32+
if (File.Exists(cachePath))
33+
{
34+
logger.LogWarning(
35+
"Nitro session not found. Using cached archive. "
36+
+ "Run 'nitro login' to authenticate.");
37+
return cachePath;
38+
}
39+
40+
logger.LogError(
41+
"Nitro session not found and no cached archive available. "
42+
+ "Run 'nitro login' to authenticate.");
43+
return null;
44+
}
45+
46+
if (session.Tokens.ExpiresAt <= DateTimeOffset.UtcNow)
47+
{
48+
if (File.Exists(cachePath))
49+
{
50+
logger.LogWarning(
51+
"Nitro session token expired. Using cached archive. "
52+
+ "Run 'nitro login' to re-authenticate.");
53+
return cachePath;
54+
}
55+
56+
logger.LogError(
57+
"Nitro session token expired and no cached archive available. "
58+
+ "Run 'nitro login' to re-authenticate.");
59+
return null;
60+
}
61+
62+
try
63+
{
64+
using var httpClient = new HttpClient();
65+
httpClient.Timeout = TimeSpan.FromSeconds(30);
66+
httpClient.DefaultRequestHeaders.Add(
67+
"Authorization", $"Bearer {session.Tokens.AccessToken}");
68+
69+
var url = $"{session.ApiUrl.TrimEnd('/')}/api/v1/apis/"
70+
+ $"{Uri.EscapeDataString(config.ApiId)}"
71+
+ "/fusion/configurations/latest/download"
72+
+ $"?stage={Uri.EscapeDataString(config.Stage)}"
73+
+ $"&format={Uri.EscapeDataString("far")}"
74+
+ $"&fusionVersion={Uri.EscapeDataString(WellKnownVersions.LatestGatewayFormatVersion.ToString())}";
75+
76+
using var response = await httpClient.GetAsync(url, cancellationToken);
77+
78+
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
79+
{
80+
if (File.Exists(cachePath))
81+
{
82+
logger.LogWarning(
83+
"Nitro authentication failed (HTTP {Status}). Using cached archive. "
84+
+ "Run 'nitro login' to re-authenticate.",
85+
(int)response.StatusCode);
86+
return cachePath;
87+
}
88+
89+
logger.LogError(
90+
"Nitro authentication failed (HTTP {Status}) and no cached archive available. "
91+
+ "Run 'nitro login' to authenticate.",
92+
(int)response.StatusCode);
93+
return null;
94+
}
95+
96+
if (response.StatusCode is HttpStatusCode.NotFound)
97+
{
98+
logger.LogError(
99+
"Nitro archive not found for API '{ApiId}' stage '{Stage}'.",
100+
config.ApiId, config.Stage);
101+
return null;
102+
}
103+
104+
if (!response.IsSuccessStatusCode)
105+
{
106+
if (File.Exists(cachePath))
107+
{
108+
logger.LogWarning(
109+
"Nitro download failed (HTTP {Status}). Using cached archive.",
110+
(int)response.StatusCode);
111+
return cachePath;
112+
}
113+
114+
logger.LogError(
115+
"Nitro download failed (HTTP {Status}) and no cached archive available.",
116+
(int)response.StatusCode);
117+
return null;
118+
}
119+
120+
var cacheDir = IOPath.GetDirectoryName(cachePath)!;
121+
Directory.CreateDirectory(cacheDir);
122+
123+
var tempPath = cachePath + $".tmp.{IOPath.GetRandomFileName()}";
124+
125+
await using (var fs = File.Create(tempPath))
126+
{
127+
await response.Content.CopyToAsync(fs, cancellationToken);
128+
}
129+
130+
File.Move(tempPath, cachePath, overwrite: true);
131+
132+
logger.LogInformation(
133+
"✅ Downloaded Nitro archive for API '{ApiId}' stage '{Stage}'.",
134+
config.ApiId, config.Stage);
135+
return cachePath;
136+
}
137+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
138+
{
139+
throw;
140+
}
141+
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
142+
{
143+
if (File.Exists(cachePath))
144+
{
145+
logger.LogWarning(
146+
ex,
147+
"Nitro download failed. Using cached archive.");
148+
return cachePath;
149+
}
150+
151+
logger.LogError(
152+
ex,
153+
"Nitro download failed and no cached archive available.");
154+
return null;
155+
}
156+
}
157+
158+
private static string GetCachePath(NitroConfigurationAnnotation config)
159+
{
160+
var key = $"{config.ApiId}_{config.Stage}";
161+
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..16];
162+
return IOPath.Combine(IOPath.GetTempPath(), "nitro-aspire", $"{hash}.far");
163+
}
164+
165+
private static bool IsCacheFresh(string path)
166+
=> File.Exists(path)
167+
&& DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < s_cacheTtl;
168+
169+
private static Session? ReadSession(ILogger logger)
170+
{
171+
try
172+
{
173+
var sessionPath = IOPath.Combine(
174+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
175+
"nitro",
176+
"session.json");
177+
178+
if (!File.Exists(sessionPath))
179+
{
180+
return null;
181+
}
182+
183+
var json = File.ReadAllText(sessionPath);
184+
return JsonSerializer.Deserialize<Session>(json, s_jsonOptions);
185+
}
186+
catch (Exception ex)
187+
{
188+
logger.LogWarning(ex, "Failed to read Nitro session file.");
189+
return null;
190+
}
191+
}
192+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Aspire.Hosting.ApplicationModel;
2+
3+
namespace HotChocolate.Fusion.Aspire;
4+
5+
internal sealed class NitroConfigurationAnnotation : IResourceAnnotation
6+
{
7+
public required string ApiId { get; init; }
8+
9+
public required string Stage { get; init; }
10+
11+
public bool AlwaysDownload { get; init; }
12+
}

0 commit comments

Comments
 (0)