Skip to content

Commit ce23dfb

Browse files
authored
[Fusion] Adds Fusion ARchive Format (#8493)
1 parent 2fc51a3 commit ce23dfb

19 files changed

+2500
-0
lines changed

src/All.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,17 @@
181181
<Project Path="HotChocolate/Fusion-vnext/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj" />
182182
<Project Path="HotChocolate/Fusion-vnext/src/Fusion.Language/HotChocolate.Fusion.Language.csproj" />
183183
<Project Path="HotChocolate/Fusion-vnext/src/Fusion.Utilities/HotChocolate.Fusion.Utilities.csproj" />
184+
<Project Path="HotChocolate/Fusion-vnext/src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj" />
184185
</Folder>
186+
<Folder Name="/HotChocolate/Fusion-vnext/src/Fusion.Packaging/" />
185187
<Folder Name="/HotChocolate/Fusion-vnext/test/">
186188
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj" />
187189
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.CommandLine.Tests/HotChocolate.Fusion.CommandLine.Tests.csproj" />
188190
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj" />
189191
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/HotChocolate.Fusion.Execution.Tests.csproj" />
190192
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.Language.Tests/HotChocolate.Fusion.Language.Tests.csproj" />
191193
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.Utilities.Tests/HotChocolate.Fusion.Utilities.Tests.csproj" />
194+
<Project Path="HotChocolate/Fusion-vnext/test/Fusion.Packaging.Tests/HotChocolate.Fusion.Packaging.Tests.csproj" />
192195
</Folder>
193196
<Folder Name="/HotChocolate/Language/" />
194197
<Folder Name="/HotChocolate/Language/src/">

src/Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0-preview.5" />
9292
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="10.0.0-preview.5" />
9393
<PackageVersion Include="System.IO.Hashing" Version="10.0.0-preview.5.25277.114" />
94+
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.0-preview.6.25358.103" />
9495
</ItemGroup>
9596
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
9697
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="9.0.2" />
@@ -114,6 +115,7 @@
114115
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.3" />
115116
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.3" />
116117
<PackageVersion Include="System.IO.Hashing" Version="9.0.6" />
118+
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="9.0.8" />
117119
</ItemGroup>
118120
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
119121
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.0" />
@@ -138,6 +140,7 @@
138140
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.10" />
139141
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
140142
<PackageVersion Include="System.IO.Hashing" Version="8.0.0" />
143+
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="8.0.1" />
141144
</ItemGroup>
142145
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
143146
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="6.0.0" />

src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<Project Path="src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj" />
88
<Project Path="src/Fusion.Language/HotChocolate.Fusion.Language.csproj" />
99
<Project Path="src/Fusion.Utilities/HotChocolate.Fusion.Utilities.csproj" />
10+
<Project Path="src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj" />
1011
</Folder>
1112
<Folder Name="/test/">
1213
<Project Path="test/Fusion.CommandLine.Tests/HotChocolate.Fusion.CommandLine.Tests.csproj" />
@@ -15,5 +16,12 @@
1516
<Project Path="test/Fusion.Language.Tests/HotChocolate.Fusion.Language.Tests.csproj" />
1617
<Project Path="test/Fusion.Utilities.Tests/HotChocolate.Fusion.Utilities.Tests.csproj" />
1718
<Project Path="test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj" />
19+
<Project Path="test/Fusion.Packaging.Tests/HotChocolate.Fusion.Packaging.Tests.csproj" />
1820
</Folder>
21+
<Project Path="../../CookieCrumble/src/CookieCrumble.Analyzers/CookieCrumble.Analyzers.csproj" />
22+
<Project Path="../../CookieCrumble/src/CookieCrumble.HotChocolate.Language/CookieCrumble.HotChocolate.Language.csproj" />
23+
<Project Path="../../CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj" />
24+
<Project Path="../../CookieCrumble/src/CookieCrumble/CookieCrumble.csproj" />
25+
<Project Path="../Language/src/Language.SyntaxTree/HotChocolate.Language.SyntaxTree.csproj" />
26+
<Project Path="../Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj" />
1927
</Solution>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Collections.Immutable;
2+
3+
namespace HotChocolate.Fusion.Packaging;
4+
5+
/// <summary>
6+
/// Contains metadata about a Fusion Archive, describing its format version,
7+
/// supported gateway formats, and included source schemas.
8+
/// </summary>
9+
public record ArchiveMetadata
10+
{
11+
/// <summary>
12+
/// Gets or sets the version of the Fusion Archive format specification.
13+
/// Used to ensure compatibility between different versions of tooling.
14+
/// </summary>
15+
public Version FormatVersion { get; init; } = new("1.0.0");
16+
17+
/// <summary>
18+
/// Gets or sets the list of gateway format versions contained in this archive.
19+
/// Multiple versions allow gradual migration and compatibility testing.
20+
/// The gateway will select the highest compatible version at runtime.
21+
/// </summary>
22+
public required ImmutableArray<Version> SupportedGatewayFormats { get; init; }
23+
24+
/// <summary>
25+
/// Gets or sets the list of source schema names included in this archive.
26+
/// These correspond to the distributed GraphQL services that comprise the composite schema.
27+
/// Each name must be a valid schema identifier according to the Fusion specification.
28+
/// </summary>
29+
public required ImmutableArray<string> SourceSchemas { get; init; }
30+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
using System.IO.Compression;
2+
3+
namespace HotChocolate.Fusion.Packaging;
4+
5+
internal sealed class ArchiveSession : IDisposable
6+
{
7+
private readonly Dictionary<string, FileEntry> _files = [];
8+
private readonly ZipArchive _archive;
9+
private FusionArchiveMode _mode;
10+
private bool _disposed;
11+
12+
public ArchiveSession(ZipArchive archive, FusionArchiveMode mode)
13+
{
14+
ArgumentNullException.ThrowIfNull(archive);
15+
16+
_archive = archive;
17+
_mode = mode;
18+
}
19+
20+
public bool HasUncommittedChanges
21+
=> _files.Values.Any(file => file.State is not FileState.Read);
22+
23+
public IEnumerable<string> GetFiles()
24+
{
25+
var tempFiles = _files.Where(file => file.Value.State is not FileState.Deleted).Select(file => file.Key);
26+
27+
if (_mode is FusionArchiveMode.Create)
28+
{
29+
return tempFiles;
30+
}
31+
32+
var files = new HashSet<string>(tempFiles);
33+
34+
foreach (var entry in _archive.Entries)
35+
{
36+
files.Add(entry.FullName);
37+
}
38+
39+
return files;
40+
}
41+
42+
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken)
43+
{
44+
if (_files.TryGetValue(path, out var file))
45+
{
46+
return file.State is not FileState.Deleted;
47+
}
48+
49+
if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is { } entry)
50+
{
51+
file = FileEntry.Read(path);
52+
#if NET10_0_OR_GREATER
53+
await entry.ExtractToFileAsync(file.TempPath, cancellationToken);
54+
#else
55+
entry.ExtractToFile(file.TempPath);
56+
await Task.CompletedTask;
57+
#endif
58+
_files.Add(path, file);
59+
return true;
60+
}
61+
62+
return false;
63+
}
64+
65+
public bool Exists(string path)
66+
{
67+
if (_files.TryGetValue(path, out var file))
68+
{
69+
return file.State is not FileState.Deleted;
70+
}
71+
72+
return _mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null;
73+
}
74+
75+
public async Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken)
76+
{
77+
if (_files.TryGetValue(path, out var file))
78+
{
79+
if (file.State is FileState.Deleted)
80+
{
81+
throw new FileNotFoundException(path);
82+
}
83+
84+
return File.OpenRead(file.TempPath);
85+
}
86+
87+
if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is { } entry)
88+
{
89+
file = FileEntry.Read(path);
90+
#if NET10_0_OR_GREATER
91+
await entry.ExtractToFileAsync(file.TempPath, cancellationToken);
92+
#else
93+
entry.ExtractToFile(file.TempPath);
94+
await Task.CompletedTask;
95+
#endif
96+
var stream = File.OpenRead(file.TempPath);
97+
_files.Add(path, file);
98+
return stream;
99+
}
100+
101+
throw new FileNotFoundException(path);
102+
}
103+
104+
public Stream OpenWrite(string path)
105+
{
106+
if (_mode is FusionArchiveMode.Read)
107+
{
108+
throw new InvalidOperationException("Cannot write to a read-only archive.");
109+
}
110+
111+
if (_files.TryGetValue(path, out var file))
112+
{
113+
file.MarkMutated();
114+
return File.Open(file.TempPath, FileMode.Create, FileAccess.Write);
115+
}
116+
117+
if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null)
118+
{
119+
file = FileEntry.Read(path);
120+
file.MarkMutated();
121+
}
122+
123+
file ??= FileEntry.Created(path);
124+
var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write);
125+
_files.Add(path, file);
126+
return stream;
127+
}
128+
129+
public void SetMode(FusionArchiveMode mode)
130+
{
131+
_mode = mode;
132+
}
133+
134+
public async Task CommitAsync(CancellationToken cancellationToken)
135+
{
136+
foreach (var file in _files.Values)
137+
{
138+
#if NET10_0_OR_GREATER
139+
switch (file.State)
140+
{
141+
case FileState.Created:
142+
await _archive.CreateEntryFromFileAsync(
143+
file.TempPath,
144+
file.Path,
145+
cancellationToken: cancellationToken);
146+
break;
147+
148+
case FileState.Replaced:
149+
_archive.GetEntry(file.Path)?.Delete();
150+
await _archive.CreateEntryFromFileAsync(
151+
file.TempPath,
152+
file.Path,
153+
cancellationToken);
154+
break;
155+
156+
case FileState.Deleted:
157+
_archive.GetEntry(file.Path)?.Delete();
158+
break;
159+
}
160+
#else
161+
switch (file.State)
162+
{
163+
case FileState.Created:
164+
_archive.CreateEntryFromFile(file.TempPath, file.Path);
165+
break;
166+
167+
case FileState.Replaced:
168+
_archive.GetEntry(file.Path)?.Delete();
169+
_archive.CreateEntryFromFile(file.TempPath, file.Path);
170+
break;
171+
172+
case FileState.Deleted:
173+
_archive.GetEntry(file.Path)?.Delete();
174+
break;
175+
}
176+
177+
await Task.CompletedTask;
178+
#endif
179+
180+
file.MarkRead();
181+
}
182+
}
183+
184+
public void Dispose()
185+
{
186+
if (_disposed)
187+
{
188+
return;
189+
}
190+
191+
_disposed = true;
192+
foreach (var file in _files.Values)
193+
{
194+
if (file.State is not FileState.Deleted)
195+
{
196+
File.Delete(file.TempPath);
197+
}
198+
}
199+
}
200+
201+
private class FileEntry
202+
{
203+
private FileEntry(string path, string tempPath, FileState state)
204+
{
205+
Path = path;
206+
TempPath = tempPath;
207+
State = state;
208+
}
209+
210+
public string Path { get; }
211+
212+
public string TempPath { get; }
213+
214+
public FileState State { get; private set; }
215+
216+
public void MarkMutated()
217+
{
218+
if (State is FileState.Read or FileState.Deleted)
219+
{
220+
State = FileState.Replaced;
221+
}
222+
}
223+
224+
public void MarkRead()
225+
{
226+
State = FileState.Read;
227+
}
228+
229+
public static FileEntry Created(string path)
230+
=> new(path, GetRandomTempFileName(), FileState.Created);
231+
232+
public static FileEntry Read(string path)
233+
=> new(path, GetRandomTempFileName(), FileState.Read);
234+
235+
private static string GetRandomTempFileName()
236+
{
237+
var tempDir = System.IO.Path.GetTempPath();
238+
var fileName = System.IO.Path.GetRandomFileName();
239+
return System.IO.Path.Combine(tempDir, fileName);
240+
}
241+
}
242+
243+
private enum FileState
244+
{
245+
Read,
246+
Created,
247+
Replaced,
248+
Deleted
249+
}
250+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace HotChocolate.Fusion.Packaging;
2+
3+
internal static class FileNames
4+
{
5+
private const string GatewaySchemaFormat = "gateway/{0}/gateway.graphqls";
6+
private const string GatewaySettingsFormat = "gateway/{0}/gateway-settings.json";
7+
private const string SourceSchemaFormat = "source-schemas/{0}/schema.graphqls";
8+
9+
public const string ArchiveMetadata = "archive-metadata.json";
10+
public const string CompositionSettings = "composition-settings.json";
11+
public const string SignatureManifest = ".signature/manifest.json";
12+
public const string Signature = ".signature/signature.p7s";
13+
14+
public static string GetGatewaySchemaPath(Version version)
15+
=> string.Format(GatewaySchemaFormat, version);
16+
17+
public static string GetGatewaySettingsPath(Version version)
18+
=> string.Format(GatewaySettingsFormat, version);
19+
20+
public static string GetSourceSchemaPath(string schemaName)
21+
=> string.Format(SourceSchemaFormat, schemaName);
22+
}

0 commit comments

Comments
 (0)