diff --git a/src/All.slnx b/src/All.slnx index 44def7fe344..cce54994251 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -219,6 +219,7 @@ + @@ -227,6 +228,7 @@ + diff --git a/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx b/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx index a9e77bdf2b3..9e2a631d796 100644 --- a/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx +++ b/src/HotChocolate/Fusion-vnext/HotChocolate.Fusion-vnext.slnx @@ -10,6 +10,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs new file mode 100644 index 00000000000..c1aa97e9483 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/ArchiveMetadata.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +/// +/// Contains metadata about a Fusion Source schema archive. +/// +public record ArchiveMetadata +{ + /// + /// Gets or sets the version of the Fusion source schema archive format specification. + /// Used to ensure compatibility between different versions of tooling. + /// + public Version FormatVersion { get; init; } = new("1.0.0"); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs new file mode 100644 index 00000000000..f7f6d76be8c --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileKind.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +internal enum FileKind +{ + Unknown, + GraphQLSchema, + Settings, + Metadata +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs new file mode 100644 index 00000000000..dd971a69486 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FileNames.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +internal static class FileNames +{ + public const string ArchiveMetadata = "archive-metadata.json"; + public const string GraphQLSchema = "schema.graphqls"; + public const string Settings = "schema-settings.json"; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs new file mode 100644 index 00000000000..8931fdd0699 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs @@ -0,0 +1,443 @@ +using System.Buffers; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Text.Json; +using HotChocolate.Fusion.SourceSchema.Packaging.Serializers; + +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +/// +/// Provides functionality for creating, reading, and modifying a Fusion source schema archive. +/// An Fusion source schema archive is a ZIP-based container format +/// that packages a GraphQL schema and source schema settings. +/// +public sealed class FusionSourceSchemaArchive : IDisposable +{ + private readonly Stream _stream; + private readonly bool _leaveOpen; + private readonly FusionSourceSchemaArchiveSession _session; + private ZipArchive _archive; + private FusionSourceSchemaArchiveMode _mode; + private ArrayBufferWriter? _buffer; + private ArchiveMetadata? _metadata; + private bool _disposed; + + private FusionSourceSchemaArchive( + Stream stream, + FusionSourceSchemaArchiveMode mode, + bool leaveOpen, + FusionSourceSchemaArchiveReadOptions options) + { + _stream = stream; + _mode = mode; + _leaveOpen = leaveOpen; + _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen); + _session = new FusionSourceSchemaArchiveSession(_archive, mode, options); + } + + /// + /// Creates a new Fusion source schema archive with the specified filename. + /// + /// The path to the archive file to create. + /// A new FusionSourceSchemaArchive instance in Create mode. + /// Thrown when filename is null. + public static FusionSourceSchemaArchive Create(string filename) + { + ArgumentNullException.ThrowIfNull(filename); + return Create(File.Create(filename)); + } + + /// + /// Creates a new Fusion source schema archive using the provided stream. + /// + /// The stream to write the archive to. + /// True to leave the stream open after disposal; otherwise, false. + /// A new FusionSourceSchemaArchive instance in Create mode. + /// Thrown when stream is null. + public static FusionSourceSchemaArchive Create(Stream stream, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(stream); + return new FusionSourceSchemaArchive(stream, FusionSourceSchemaArchiveMode.Create, leaveOpen, FusionSourceSchemaArchiveReadOptions.Default); + } + + /// + /// Opens an existing Fusion source schema archive from a file. + /// + /// The path to the archive file to open. + /// The mode to open the archive in. + /// A FusionSourceSchemaArchive instance opened in the specified mode. + /// Thrown when filename is null. + /// Thrown when mode is invalid. + public static FusionSourceSchemaArchive Open( + string filename, + FusionSourceSchemaArchiveMode mode = FusionSourceSchemaArchiveMode.Read) + { + ArgumentNullException.ThrowIfNull(filename); + + return mode switch + { + FusionSourceSchemaArchiveMode.Read => Open(File.OpenRead(filename), mode), + FusionSourceSchemaArchiveMode.Create => Create(File.Create(filename)), + FusionSourceSchemaArchiveMode.Update => Open(File.Open(filename, FileMode.Open, FileAccess.ReadWrite), mode), + _ => throw new ArgumentException("Invalid mode.", nameof(mode)) + }; + } + + /// + /// Opens a Fusion source schema archive from a stream. + /// + /// The stream containing the archive data. + /// The mode to open the archive in. + /// True to leave the stream open after disposal; otherwise, false. + /// The options to use when reading from the archive. + /// A FusionSourceSchemaArchive instance opened in the specified mode. + /// Thrown when stream is null. + public static FusionSourceSchemaArchive Open( + Stream stream, + FusionSourceSchemaArchiveMode mode = FusionSourceSchemaArchiveMode.Read, + bool leaveOpen = false, + FusionSourceSchemaArchiveOptions options = default) + { + ArgumentNullException.ThrowIfNull(stream); + var readOptions = new FusionSourceSchemaArchiveReadOptions( + options.MaxAllowedSchemaSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSchemaSize, + options.MaxAllowedSettingsSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSettingsSize); + return new FusionSourceSchemaArchive(stream, mode, leaveOpen, readOptions); + } + + /// + /// Sets the archive metadata. + /// + /// The metadata to store in the archive. + /// Token to cancel the operation. + /// Thrown when metadata is null. + /// Thrown when the archive has been disposed. + /// Thrown when the archive is read-only. + public async Task SetArchiveMetadataAsync( + ArchiveMetadata metadata, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadata); + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureMutable(); + + Exception? exception = null; + + await using var stream = _session.OpenWrite(FileNames.ArchiveMetadata); + + var writer = PipeWriter.Create(stream); + + try + { + ArchiveMetadataSerializer.Format(metadata, writer); + await writer.FlushAsync(cancellationToken); + _metadata = metadata; + } + catch (Exception ex) + { + exception = ex; + throw; + } + finally + { + await writer.CompleteAsync(exception); + } + } + + /// + /// Gets the archive metadata. + /// Returns null if no metadata is present in the archive. + /// + /// Token to cancel the operation. + /// The archive metadata or null if not present. + /// Thrown when the archive has been disposed. + public async Task GetArchiveMetadataAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_metadata is not null) + { + return _metadata; + } + + if (!await _session.ExistsAsync(FileNames.ArchiveMetadata, FileKind.Metadata, cancellationToken)) + { + return null; + } + + var buffer = TryRentBuffer(); + + try + { + await using var stream = await _session.OpenReadAsync( + FileNames.ArchiveMetadata, + FileKind.Metadata, + cancellationToken); + await stream.CopyToAsync(buffer, cancellationToken); + var metadata = ArchiveMetadataSerializer.Parse(buffer.WrittenMemory); + _metadata = metadata; + return metadata; + } + finally + { + TryReturnBuffer(buffer); + } + } + + /// + /// Sets the GraphQL schema of the source schema. + /// + /// The GraphQL schema to store. + /// Token to cancel the operation. + /// Thrown when the GraphQL schema is empty. + /// Thrown when the archive has been disposed. + /// Thrown when the archive is read-only. + public async Task SetSchemaAsync( + ReadOnlyMemory schema, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(schema.Length, 0); + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureMutable(); + + await using (var stream = _session.OpenWrite(FileNames.GraphQLSchema)) + { + await stream.WriteAsync(schema, cancellationToken); + } + } + + /// + /// Tries to get the GraphQL schema of the source schema. + /// + /// Token to cancel the operation. + /// The GraphQL schema of the source schema if found, or null if not found. + /// Thrown when the archive has been disposed. + public async Task?> TryGetSchemaAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + const string schemaPath = FileNames.GraphQLSchema; + + if (!_session.Exists(schemaPath)) + { + return null; + } + + var buffer = TryRentBuffer(); + + try + { + await using var schemaStream = await _session.OpenReadAsync( + schemaPath, + FileKind.GraphQLSchema, + cancellationToken); + await schemaStream.CopyToAsync(buffer, cancellationToken); + + return buffer.WrittenMemory.ToArray(); + } + finally + { + TryReturnBuffer(buffer); + } + } + + /// + /// Sets the settings of the source schema. + /// + /// The source schema settings to store. + /// Token to cancel the operation. + /// Thrown when settings is null. + /// Thrown when the archive has been disposed. + /// Thrown when the archive is read-only. + public async Task SetSettingsAsync( + JsonDocument settings, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureMutable(); + + await using (var stream = _session.OpenWrite(FileNames.Settings)) + { + await using var jsonWriter = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + settings.WriteTo(jsonWriter); + await jsonWriter.FlushAsync(cancellationToken); + } + } + + /// + /// Tries to get the settings of the source schema. + /// + /// Token to cancel the operation. + /// The settings of the source schema if found, or null if not found. + /// Thrown when the archive has been disposed. + public async Task TryGetSettingsAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + const string settingsPath = FileNames.Settings; + + if (!_session.Exists(settingsPath)) + { + return null; + } + + var buffer = TryRentBuffer(); + + try + { + await using var settingsStream = await _session.OpenReadAsync( + settingsPath, + FileKind.Settings, + cancellationToken); + var settings = await JsonDocument.ParseAsync(settingsStream, cancellationToken: cancellationToken); + + return settings; + } + finally + { + TryReturnBuffer(buffer); + } + } + + /// + /// Commits any pending changes to the archive and flushes them to the underlying stream. + /// After committing, the archive may transition to Update mode if the stream supports it. + /// + /// Token to cancel the operation. + /// Thrown when the archive has been disposed. + /// + /// Thrown when the archive is read-only, or one of the following has not been set in the archive: + /// GraphQL schema, settings or archive metadata. + /// + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_mode is FusionSourceSchemaArchiveMode.Read) + { + throw new InvalidOperationException("Cannot commit changes to a read-only archive."); + } + + if (!_session.Exists(FileNames.GraphQLSchema) + || !_session.Exists(FileNames.Settings) + || !_session.Exists(FileNames.ArchiveMetadata)) + { + throw new InvalidOperationException( + "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata."); + } + + if (_session.HasUncommittedChanges) + { + await _session.CommitAsync(cancellationToken); +#if NET10_0_OR_GREATER + await _archive.DisposeAsync(); +#else + _archive.Dispose(); +#endif + + if (_stream is { CanSeek: true, CanRead: true, CanWrite: true }) + { + _stream.Seek(0, SeekOrigin.Begin); + _archive = new ZipArchive(_stream, ZipArchiveMode.Update, _leaveOpen); + _mode = FusionSourceSchemaArchiveMode.Update; + _session.SetMode(_mode); + } + else + { + _mode = FusionSourceSchemaArchiveMode.Read; + } + } + } + + /// + /// Releases all resources used by the FusionSourceSchemaArchive. + /// If leaveOpen was false when opening the archive, the underlying stream is also disposed. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _session.Dispose(); + _archive.Dispose(); + + if (!_leaveOpen) + { + _stream.Dispose(); + } + } + + /// + /// We will try to work with a single buffer for all file interactions. + /// + private ArrayBufferWriter TryRentBuffer() + { + return Interlocked.Exchange(ref _buffer, null) ?? new ArrayBufferWriter(4096); + } + + /// + /// Tries to preserve a used buffer. + /// + /// + /// The buffer that shall be preserved. + /// + private void TryReturnBuffer(ArrayBufferWriter buffer) + { + buffer.Clear(); + + var currentBuffer = _buffer; + var currentCapacity = currentBuffer?.Capacity ?? 0; + + if (currentCapacity < buffer.Capacity) + { + Interlocked.CompareExchange(ref _buffer, buffer, currentBuffer); + } + } + + private void EnsureMutable() + { + if (_mode is FusionSourceSchemaArchiveMode.Read) + { + throw new InvalidOperationException("Cannot modify a read-only archive."); + } + } +} + +file static class Extensions +{ + public static Task CopyToAsync( + this Stream stream, + IBufferWriter buffer, + CancellationToken cancellationToken) + => stream.CopyToAsync(buffer, 4096, cancellationToken); + + public static async Task CopyToAsync( + this Stream stream, + IBufferWriter buffer, + int expectedStreamLength, + CancellationToken cancellationToken) + { + int bytesRead; + var bufferSize = Math.Min(expectedStreamLength, 4096); + + do + { + var memory = buffer.GetMemory(bufferSize); + bytesRead = await stream.ReadAsync(memory, cancellationToken); + if (bytesRead > 0) + { + buffer.Advance(bytesRead); + } + } while (bytesRead > 0); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs new file mode 100644 index 00000000000..780fda605d9 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveMode.cs @@ -0,0 +1,27 @@ +using System.IO.Compression; + +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +/// +/// Specifies the mode for opening or creating a Fusion source schema archive. +/// +public enum FusionSourceSchemaArchiveMode +{ + /// + /// Opens an existing archive for reading only. No modifications are allowed. + /// The archive must already exist and contain valid data. + /// + Read = ZipArchiveMode.Read, + + /// + /// Creates a new archive for writing. If the target already exists, it will be overwritten. + /// + Create = ZipArchiveMode.Create, + + /// + /// Opens an existing archive for both reading and writing. Allows modification of + /// existing entries and addition of new entries. The archive must already exist. + /// Use this mode when you need to modify an existing archive. + /// + Update = ZipArchiveMode.Update +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs new file mode 100644 index 00000000000..0cf28a8bf3f --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +/// +/// Specifies the options for a Fusion source schema archive. +/// +public struct FusionSourceSchemaArchiveOptions +{ + /// + /// Gets or sets the maximum allowed size of the GraphQL schema in the archive. + /// + public int? MaxAllowedSchemaSize { get; set; } + + /// + /// Gets or sets the maximum allowed size of the settings in the archive. + /// + public int? MaxAllowedSettingsSize { get; set; } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs new file mode 100644 index 00000000000..4365ff7eb99 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs @@ -0,0 +1,15 @@ +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +/// +/// Specifies the read options for a Fusion source schema archive. +/// +internal readonly record struct FusionSourceSchemaArchiveReadOptions( + int MaxAllowedSchemaSize, + int MaxAllowedSettingsSize) +{ + /// + /// Gets the default read options. + /// + public static FusionSourceSchemaArchiveReadOptions Default { get; } + = new(50_000_000, 512_000); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs new file mode 100644 index 00000000000..bd5bbebd717 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs @@ -0,0 +1,295 @@ +using System.Buffers; +using System.IO.Compression; + +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +internal sealed class FusionSourceSchemaArchiveSession : IDisposable +{ + private readonly Dictionary _files = []; + private readonly ZipArchive _archive; + private readonly FusionSourceSchemaArchiveReadOptions _readOptions; + private FusionSourceSchemaArchiveMode _mode; + private bool _disposed; + + public FusionSourceSchemaArchiveSession( + ZipArchive archive, + FusionSourceSchemaArchiveMode mode, + FusionSourceSchemaArchiveReadOptions readOptions) + { + ArgumentNullException.ThrowIfNull(archive); + + _archive = archive; + _mode = mode; + _readOptions = readOptions; + } + + public bool HasUncommittedChanges + => _files.Values.Any(file => file.State is not FileState.Read); + + public IEnumerable GetFiles() + { + var tempFiles = _files.Where(file => file.Value.State is not FileState.Deleted).Select(file => file.Key); + + if (_mode is FusionSourceSchemaArchiveMode.Create) + { + return tempFiles; + } + + var files = new HashSet(tempFiles); + + foreach (var entry in _archive.Entries) + { + files.Add(entry.FullName); + } + + return files; + } + + public async Task ExistsAsync(string path, FileKind kind, CancellationToken cancellationToken) + { + if (_files.TryGetValue(path, out var file)) + { + return file.State is not FileState.Deleted; + } + + if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry) + { + file = FileEntry.Read(path); + await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); + _files.Add(path, file); + return true; + } + + return false; + } + + public bool Exists(string path) + { + if (_files.TryGetValue(path, out var file)) + { + return file.State is not FileState.Deleted; + } + + return _mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null; + } + + public async Task OpenReadAsync(string path, FileKind kind, CancellationToken cancellationToken) + { + if (_files.TryGetValue(path, out var file)) + { + if (file.State is FileState.Deleted) + { + throw new FileNotFoundException(path); + } + + return File.OpenRead(file.TempPath); + } + + if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry) + { + file = FileEntry.Read(path); + await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); + var stream = File.OpenRead(file.TempPath); + _files.Add(path, file); + return stream; + } + + throw new FileNotFoundException(path); + } + + public Stream OpenWrite(string path) + { + if (_mode is FusionSourceSchemaArchiveMode.Read) + { + throw new InvalidOperationException("Cannot write to a read-only archive."); + } + + if (_files.TryGetValue(path, out var file)) + { + file.MarkMutated(); + return File.Open(file.TempPath, FileMode.Create, FileAccess.Write); + } + + if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null) + { + file = FileEntry.Read(path); + file.MarkMutated(); + } + + file ??= FileEntry.Created(path); + var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write); + _files.Add(path, file); + + return stream; + } + + public void SetMode(FusionSourceSchemaArchiveMode mode) + { + _mode = mode; + } + + public async Task CommitAsync(CancellationToken cancellationToken) + { + foreach (var file in _files.Values.OrderBy(f => f.Path, StringComparer.Ordinal)) + { + switch (file.State) + { + case FileState.Created: + await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + break; + + case FileState.Replaced: + _archive.GetEntry(file.Path)?.Delete(); + await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + break; + + case FileState.Deleted: + _archive.GetEntry(file.Path)?.Delete(); + break; + } + + file.MarkRead(); + } + } + + /// + /// Creates a ZIP entry from a file with a deterministic timestamp. + /// Using a fixed timestamp ensures binary reproducibility of the archive. + /// + private async Task CreateEntryFromFileAsync( + string sourceFileName, + string entryName, + CancellationToken cancellationToken) + { + var entry = _archive.CreateEntry(entryName); + // Use a fixed timestamp to ensure deterministic archive output + entry.LastWriteTime = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + + await using var source = File.OpenRead(sourceFileName); +#if NET10_0_OR_GREATER + await using var destination = await entry.OpenAsync(cancellationToken); +#else + await using var destination = entry.Open(); +#endif + await source.CopyToAsync(destination, cancellationToken); + } + + private static async Task ExtractFileAsync( + ZipArchiveEntry zipEntry, + FileEntry fileEntry, + int maxAllowedSize, + CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(4096); + var consumed = 0; + + try + { + await using var readStream = zipEntry.Open(); + await using var writeStream = File.Open(fileEntry.TempPath, FileMode.Create, FileAccess.Write); + + int read; + while ((read = await readStream.ReadAsync(buffer, cancellationToken)) > 0) + { + consumed += read; + + if (consumed > maxAllowedSize) + { + throw new InvalidOperationException( + $"File is too large and exceeds the allowed size of {maxAllowedSize}."); + } + + await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private int GetAllowedSize(FileKind kind) + => kind switch + { + FileKind.GraphQLSchema + => _readOptions.MaxAllowedSchemaSize, + FileKind.Settings or FileKind.Metadata + => _readOptions.MaxAllowedSettingsSize, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var file in _files.Values) + { + if (file.State is not FileState.Deleted && File.Exists(file.TempPath)) + { + try + { + File.Delete(file.TempPath); + } + catch + { + // ignore + } + } + } + + _disposed = true; + } + + private class FileEntry + { + private FileEntry(string path, string tempPath, FileState state) + { + Path = path; + TempPath = tempPath; + State = state; + } + + public string Path { get; } + + public string TempPath { get; } + + public FileState State { get; private set; } + + public void MarkMutated() + { + if (State is FileState.Read or FileState.Deleted) + { + State = FileState.Replaced; + } + } + + public void MarkRead() + { + State = FileState.Read; + } + + public static FileEntry Created(string path) + => new(path, GetRandomTempFileName(), FileState.Created); + + public static FileEntry Read(string path) + => new(path, GetRandomTempFileName(), FileState.Read); + + private static string GetRandomTempFileName() + { + var tempDir = System.IO.Path.GetTempPath(); + var fileName = System.IO.Path.GetRandomFileName(); + return System.IO.Path.Combine(tempDir, fileName); + } + } + + private enum FileState + { + Read, + Created, + Replaced, + Deleted + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj new file mode 100644 index 00000000000..f258e4e6093 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj @@ -0,0 +1,12 @@ + + + + HotChocolate.Fusion.SourceSchema.Packaging + HotChocolate.Fusion.SourceSchema.Packaging + + + + + + + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs new file mode 100644 index 00000000000..c1d473100fa --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.SourceSchema.Packaging/Serializers/ArchiveMetadataSerializer.cs @@ -0,0 +1,42 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Text.Json; + +namespace HotChocolate.Fusion.SourceSchema.Packaging.Serializers; + +internal static class ArchiveMetadataSerializer +{ + public static void Format(ArchiveMetadata archiveMetadata, IBufferWriter writer) + { + using var jsonWriter = new Utf8JsonWriter(writer); + + jsonWriter.WriteStartObject(); + + jsonWriter.WriteString("formatVersion", archiveMetadata.FormatVersion.ToString()); + + jsonWriter.WriteEndObject(); + jsonWriter.Flush(); + } + + public static ArchiveMetadata Parse(ReadOnlyMemory data) + { + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + + if (root.ValueKind is not JsonValueKind.Object) + { + throw new JsonException("Invalid archive metadata format."); + } + + var formatVersionProp = root.GetProperty("formatVersion"); + if (formatVersionProp.ValueKind is not JsonValueKind.String) + { + throw new JsonException("The archive metadata must contain a formatVersion property."); + } + + return new ArchiveMetadata + { + FormatVersion = new Version(formatVersionProp.GetString()!) + }; + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs new file mode 100644 index 00000000000..fc6a06e905f --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveTests.cs @@ -0,0 +1,340 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +public class FusionSourceSchemaArchiveTests : IDisposable +{ + private readonly List _streamsToDispose = []; + + [Fact] + public void Create_WithNullStream_ThrowsArgumentNullException() + { + // act & Assert + Assert.Throws(() => FusionSourceSchemaArchive.Create(null!)); + } + + [Fact] + public void Open_WithNullStream_ThrowsArgumentNullException() + { + // act & Assert + Assert.Throws(() => FusionSourceSchemaArchive.Open(default(Stream)!)); + } + + [Fact] + public void Open_WithNullString_ThrowsArgumentNullException() + { + // act & Assert + Assert.Throws(() => FusionSourceSchemaArchive.Open(default(string)!)); + } + + [Fact] + public async Task SetArchiveMetadata_WithValidData_StoresCorrectly() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata + { + FormatVersion = new Version("2.0.0") + }; + + // act & Assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetArchiveMetadataAsync(metadata); + + // Can read immediately within the same session + var retrieved = await archive.GetArchiveMetadataAsync(); + Assert.NotNull(retrieved); + Assert.Equal(metadata.FormatVersion, retrieved.FormatVersion); + } + + [Fact] + public async Task GetArchiveMetadata_WhenNotSet_ReturnsNull() + { + // arrange + await using var stream = CreateStream(); + + // act & Assert + using var archive = FusionSourceSchemaArchive.Create(stream); + var result = await archive.GetArchiveMetadataAsync(); + Assert.Null(result); + } + + [Fact] + public async Task SetArchiveMetadata_WithNullMetadata_ThrowsArgumentNullException() + { + // arrange + await using var stream = CreateStream(); + + // act & Assert + using var archive = FusionSourceSchemaArchive.Create(stream); + await Assert.ThrowsAsync( + () => archive.SetArchiveMetadataAsync(null!)); + } + + [Fact] + public async Task SetSchema_WithValidData_StoresCorrectly() + { + // arrange + await using var stream = CreateStream(); + var schema = "type Query { hello: String }"u8.ToArray(); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetSchemaAsync(schema); + + // assert - Can read immediately within the same session + var retrieved = await archive.TryGetSchemaAsync(); + + Assert.NotNull(retrieved); + Assert.Equal(schema, retrieved.Value.ToArray()); + } + + [Fact] + public async Task SetSchema_WithEmptySchema_ThrowsArgumentOutOfRangeException() + { + // arrange + await using var stream = CreateStream(); + var schema = ReadOnlyMemory.Empty; + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await Assert.ThrowsAsync( + () => archive.SetSchemaAsync(schema)); + } + + [Fact] + public async Task TryGetSchema_WhenNotSet_ReturnsNull() + { + // arrange + await using var stream = CreateStream(); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream); + var result = await archive.TryGetSchemaAsync(); + + // assert + Assert.Null(result); + } + + [Fact] + public async Task SetSettings_WithValidData_StoresCorrectly() + { + // arrange + await using var stream = CreateStream(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}"""); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetSettingsAsync(settings); + + // assert - Can read immediately within the same session + using var retrieved = await archive.TryGetSettingsAsync(); + + Assert.NotNull(retrieved); + Assert.Equal("1.0", retrieved.RootElement.GetProperty("version").GetString()); + Assert.Equal("test", retrieved.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public async Task SetSettings_WithNullSettings_ThrowsArgumentNullException() + { + // arrange + await using var stream = CreateStream(); + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await Assert.ThrowsAsync( + () => archive.SetSettingsAsync(null!)); + } + + [Fact] + public async Task TryGetSettings_WhenNotSet_ReturnsNull() + { + // arrange + await using var stream = CreateStream(); + + // act + using var archive = FusionSourceSchemaArchive.Create(stream); + var result = await archive.TryGetSettingsAsync(); + + // assert + Assert.Null(result); + } + + [Fact] + public async Task CommitAsync_WhenSchemaNotSet_ThrowsInvalidOperationException() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}"""); + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSettingsAsync(settings); + + var exception = await Assert.ThrowsAsync( + () => archive.CommitAsync()); + Assert.Equal( + "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.", + exception.Message); + } + + [Fact] + public async Task CommitAsync_WhenSettingsNotSet_ThrowsInvalidOperationException() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata(); + var schema = "type Query { hello: String }"u8.ToArray(); + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSchemaAsync(schema); + + var exception = await Assert.ThrowsAsync( + () => archive.CommitAsync()); + Assert.Equal( + "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.", + exception.Message); + } + + [Fact] + public async Task CommitAsync_WhenArchiveMetadataNotSet_ThrowsInvalidOperationException() + { + // arrange + await using var stream = CreateStream(); + var schema = "type Query { hello: String }"u8.ToArray(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}"""); + + // act & assert + using var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true); + await archive.SetSchemaAsync(schema); + await archive.SetSettingsAsync(settings); + + var exception = await Assert.ThrowsAsync( + () => archive.CommitAsync()); + Assert.Equal( + "Cannot commit changes as long as one of the following has not been set: GraphQL schema, settings or archive metadata.", + exception.Message); + } + + [Fact] + public async Task CommitAsync_WhenReadOnly_ThrowsInvalidOperationException() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata(); + var schema = "type Query { hello: String }"u8.ToArray(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}"""); + + // Create and commit a valid archive first + using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSchemaAsync(schema); + await archive.SetSettingsAsync(settings); + await archive.CommitAsync(); + } + + // act & assert - Open in read mode and try to commit + stream.Position = 0; + using (var readArchive = FusionSourceSchemaArchive.Open(stream, FusionSourceSchemaArchiveMode.Read, leaveOpen: true)) + { + var exception = await Assert.ThrowsAsync( + () => readArchive.CommitAsync()); + Assert.Equal("Cannot commit changes to a read-only archive.", exception.Message); + } + } + + [Fact] + public async Task CommitAndReopen_PersistsAllData() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata(); + var schema = "type Query { users: [User] } type User { id: ID! name: String! }"u8.ToArray(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "users"}"""); + + // act - Create and commit + using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSchemaAsync(schema); + await archive.SetSettingsAsync(settings); + await archive.CommitAsync(); + } + + // assert - Reopen and verify persistence + stream.Position = 0; + using (var readArchive = FusionSourceSchemaArchive.Open(stream, leaveOpen: true)) + { + var retrievedMetadata = await readArchive.GetArchiveMetadataAsync(); + Assert.NotNull(retrievedMetadata); + Assert.Equal(metadata.FormatVersion, retrievedMetadata.FormatVersion); + + var retrievedSchema = await readArchive.TryGetSchemaAsync(); + Assert.NotNull(retrievedSchema); + Assert.Equal(schema, retrievedSchema.Value.ToArray()); + + using var retrievedSettings = await readArchive.TryGetSettingsAsync(); + Assert.NotNull(retrievedSettings); + Assert.Equal("1.0", retrievedSettings.RootElement.GetProperty("version").GetString()); + Assert.Equal("users", retrievedSettings.RootElement.GetProperty("name").GetString()); + } + } + + [Fact] + public async Task UpdateMode_CanModifyExistingArchive() + { + // arrange + await using var stream = CreateStream(); + var metadata = new ArchiveMetadata(); + var schema1 = "type Query { hello: String }"u8.ToArray(); + var schema2 = "type Query { hello: String! goodbye: String }"u8.ToArray(); + using var settings = JsonDocument.Parse("""{"version": "1.0", "name": "test"}"""); + + // act - Create initial archive + using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync(metadata); + await archive.SetSchemaAsync(schema1); + await archive.SetSettingsAsync(settings); + await archive.CommitAsync(); + } + + // act - Update existing archive with new schema + stream.Position = 0; + using (var updateArchive = FusionSourceSchemaArchive.Open(stream, FusionSourceSchemaArchiveMode.Update, leaveOpen: true)) + { + await updateArchive.SetSchemaAsync(schema2); + await updateArchive.CommitAsync(); + } + + // assert - Verify updated schema + stream.Position = 0; + using (var readArchive = FusionSourceSchemaArchive.Open(stream, leaveOpen: true)) + { + var retrievedSchema = await readArchive.TryGetSchemaAsync(); + Assert.NotNull(retrievedSchema); + Assert.Equal(schema2, retrievedSchema.Value.ToArray()); + } + } + + private Stream CreateStream() + { + var stream = new MemoryStream(); + _streamsToDispose.Add(stream); + return stream; + } + + public void Dispose() + { + foreach (var stream in _streamsToDispose) + { + stream.Dispose(); + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj new file mode 100644 index 00000000000..fb5d8ae3e0a --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.SourceSchema.Packaging.Tests/HotChocolate.Fusion.SourceSchema.Packaging.Tests.csproj @@ -0,0 +1,17 @@ + + + + + HotChocolate.Fusion.SourceSchema.Packaging.Tests + HotChocolate.Fusion.SourceSchema.Packaging + + + + + + + + + + +