diff --git a/src/All.slnx b/src/All.slnx
index 5620c0e8200..35b9f5e35c7 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -189,10 +189,12 @@
+
+
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 3697ecf170e..835f7f2c500 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -46,6 +46,7 @@
net10.0; net9.0; net8.0
net10.0; net9.0; net8.0; netstandard2.0
+ net10.0; net9.0
diff --git a/src/HotChocolate/Adapters/Adapters.slnx b/src/HotChocolate/Adapters/Adapters.slnx
index a3597e10165..a729a58fa6a 100644
--- a/src/HotChocolate/Adapters/Adapters.slnx
+++ b/src/HotChocolate/Adapters/Adapters.slnx
@@ -8,6 +8,7 @@
+
@@ -16,5 +17,6 @@
+
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/HotChocolate.Adapters.OpenApi.Core.csproj b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/HotChocolate.Adapters.OpenApi.Core.csproj
index f04bec8dd00..a6c6d92fe9b 100644
--- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/HotChocolate.Adapters.OpenApi.Core.csproj
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Core/HotChocolate.Adapters.OpenApi.Core.csproj
@@ -3,7 +3,7 @@
HotChocolate.Adapters.OpenApi
HotChocolate.Adapters.OpenApi.Core
- net10.0; net9.0
+ $(OpenApiTargetFrameworks)
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveMetadata.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveMetadata.cs
new file mode 100644
index 00000000000..921fc4c263b
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveMetadata.cs
@@ -0,0 +1,25 @@
+using System.Collections.Immutable;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Contains metadata about an OpenAPI collection archive.
+///
+public record ArchiveMetadata
+{
+ ///
+ /// Gets or sets the version of the OpenAPI collection archive format specification.
+ /// Used to ensure compatibility between different versions of tooling.
+ ///
+ public Version FormatVersion { get; init; } = new("1.0.0");
+
+ ///
+ /// Gets or sets the names of endpoints contained in this archive.
+ ///
+ public ImmutableArray Endpoints { get; init; } = [];
+
+ ///
+ /// Gets or sets the names of models contained in this archive.
+ ///
+ public ImmutableArray Models { get; init; } = [];
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileKind.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileKind.cs
new file mode 100644
index 00000000000..cef3eca1c02
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileKind.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+internal enum FileKind
+{
+ Unknown,
+ Operation,
+ Settings,
+ Fragment,
+ Metadata
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileNames.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileNames.cs
new file mode 100644
index 00000000000..45425aa9b0b
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileNames.cs
@@ -0,0 +1,36 @@
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+internal static class FileNames
+{
+ public const string ArchiveMetadata = "archive-metadata.json";
+
+ public static string GetEndpointOperationPath(string name)
+ => $"endpoints/{name}/operation.graphql";
+
+ public static string GetEndpointSettingsPath(string name)
+ => $"endpoints/{name}/settings.json";
+
+ public static string GetModelFragmentPath(string name)
+ => $"models/{name}/fragment.graphql";
+
+ public static FileKind GetFileKind(string fileName)
+ {
+ switch (Path.GetFileName(fileName))
+ {
+ case "operation.graphql":
+ return FileKind.Operation;
+
+ case "settings.json":
+ return FileKind.Settings;
+
+ case "archive-metadata.json":
+ return FileKind.Metadata;
+
+ case "fragment.graphql":
+ return FileKind.Fragment;
+
+ default:
+ return FileKind.Unknown;
+ }
+ }
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/HotChocolate.Adapters.OpenApi.Packaging.csproj b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/HotChocolate.Adapters.OpenApi.Packaging.csproj
new file mode 100644
index 00000000000..6e5e525edfe
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/HotChocolate.Adapters.OpenApi.Packaging.csproj
@@ -0,0 +1,9 @@
+
+
+
+ HotChocolate.Adapters.OpenApi.Packaging
+ HotChocolate.Adapters.OpenApi.Packaging
+ $(OpenApiTargetFrameworks)
+
+
+
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/NameValidator.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/NameValidator.cs
new file mode 100644
index 00000000000..4087765c743
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/NameValidator.cs
@@ -0,0 +1,27 @@
+using System.Text.RegularExpressions;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Validates names for endpoints and models in the archive.
+///
+internal static partial class NameValidator
+{
+ // Name Grammar:
+ // Name ::= NameStart NameContinue* [lookahead != NameContinue]
+ // NameStart ::= Letter | `_`
+ // NameContinue ::= Letter | Digit | `_` | `-`
+
+ [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_-]*$")]
+ private static partial Regex NameRegex();
+
+ ///
+ /// Validates whether the given string is a valid name.
+ ///
+ /// The name to validate.
+ /// True if the name is valid, false otherwise.
+ public static bool IsValidName(string? name)
+ {
+ return !string.IsNullOrEmpty(name) && NameRegex().IsMatch(name) && name.Any(char.IsLetterOrDigit);
+ }
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs
new file mode 100644
index 00000000000..4428e26df86
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs
@@ -0,0 +1,528 @@
+using System.Buffers;
+using System.IO.Compression;
+using System.IO.Pipelines;
+using System.Text.Json;
+using HotChocolate.Adapters.OpenApi.Packaging.Serializers;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Provides functionality for creating, reading, and modifying OpenAPI collection files.
+/// An OpenAPI collection is a ZIP-based container format that packages OpenAPI endpoint and model definitions.
+///
+public sealed class OpenApiCollectionArchive : IDisposable
+{
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+ private readonly OpenApiCollectionArchiveSession _session;
+ private ZipArchive _archive;
+ private OpenApiCollectionArchiveMode _mode;
+ private ArrayBufferWriter? _buffer;
+ private ArchiveMetadata? _metadata;
+ private bool _disposed;
+
+ private OpenApiCollectionArchive(
+ Stream stream,
+ OpenApiCollectionArchiveMode mode,
+ bool leaveOpen,
+ OpenApiCollectionArchiveReadOptions options)
+ {
+ _stream = stream;
+ _mode = mode;
+ _leaveOpen = leaveOpen;
+ _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen);
+ _session = new OpenApiCollectionArchiveSession(_archive, mode, options);
+ }
+
+ ///
+ /// Creates a new OpenAPI collection archive with the specified filename.
+ ///
+ /// The path to the archive file to create.
+ /// A new OpenApiCollectionArchive instance in Create mode.
+ /// Thrown when filename is null.
+ public static OpenApiCollectionArchive Create(string filename)
+ {
+ ArgumentNullException.ThrowIfNull(filename);
+ return Create(File.Create(filename));
+ }
+
+ ///
+ /// Creates a new OpenAPI collection archive using the provided stream.
+ ///
+ /// The stream to write the archive to.
+ /// True to leave the stream open after disposal; otherwise, false.
+ /// A new OpenApiCollectionArchive instance in Create mode.
+ /// Thrown when stream is null.
+ public static OpenApiCollectionArchive Create(Stream stream, bool leaveOpen = false)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ return new OpenApiCollectionArchive(stream, OpenApiCollectionArchiveMode.Create, leaveOpen, OpenApiCollectionArchiveReadOptions.Default);
+ }
+
+ ///
+ /// Opens an existing OpenAPI collection archive from a file.
+ ///
+ /// The path to the archive file to open.
+ /// The mode to open the archive in.
+ /// A OpenApiCollectionArchive instance opened in the specified mode.
+ /// Thrown when filename is null.
+ /// Thrown when mode is invalid.
+ public static OpenApiCollectionArchive Open(
+ string filename,
+ OpenApiCollectionArchiveMode mode = OpenApiCollectionArchiveMode.Read)
+ {
+ ArgumentNullException.ThrowIfNull(filename);
+
+ return mode switch
+ {
+ OpenApiCollectionArchiveMode.Read => Open(File.OpenRead(filename), mode),
+ OpenApiCollectionArchiveMode.Create => Create(File.Create(filename)),
+ OpenApiCollectionArchiveMode.Update => Open(File.Open(filename, FileMode.Open, FileAccess.ReadWrite), mode),
+ _ => throw new ArgumentException("Invalid mode.", nameof(mode))
+ };
+ }
+
+ ///
+ /// Opens an OpenAPI collection 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.
+ /// An OpenAPI collection archive instance opened in the specified mode.
+ /// Thrown when stream is null.
+ public static OpenApiCollectionArchive Open(
+ Stream stream,
+ OpenApiCollectionArchiveMode mode = OpenApiCollectionArchiveMode.Read,
+ bool leaveOpen = false,
+ OpenApiCollectionArchiveOptions options = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ var readOptions = new OpenApiCollectionArchiveReadOptions(
+ options.MaxAllowedOperationSize ?? OpenApiCollectionArchiveReadOptions.Default.MaxAllowedOperationSize,
+ options.MaxAllowedSettingsSize ?? OpenApiCollectionArchiveReadOptions.Default.MaxAllowedSettingsSize);
+ return new OpenApiCollectionArchive(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 containing format version and schema information.
+ /// 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);
+ }
+ }
+
+ ///
+ /// Adds an OpenAPI endpoint to the archive.
+ ///
+ /// The unique name for this endpoint.
+ /// The operation data to store.
+ /// The settings document for this endpoint.
+ /// Token to cancel the operation.
+ /// Thrown when name is invalid.
+ /// Thrown when operation is empty.
+ /// Thrown when settings is null.
+ /// Thrown when the archive has been disposed.
+ /// Thrown when the archive is read-only, metadata is not set, or endpoint already exists.
+ public async Task AddOpenApiEndpointAsync(
+ string name,
+ ReadOnlyMemory operation,
+ JsonDocument settings,
+ CancellationToken cancellationToken = default)
+ {
+ if (!NameValidator.IsValidName(name))
+ {
+ throw new ArgumentException($"The endpoint name '{name}' is invalid.", nameof(name));
+ }
+
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operation.Length, 0);
+ ArgumentNullException.ThrowIfNull(settings);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ EnsureMutable();
+
+ var metadata = await GetArchiveMetadataAsync(cancellationToken);
+
+ if (metadata is null)
+ {
+ throw new InvalidOperationException(
+ "You need to first define the archive metadata.");
+ }
+
+ if (metadata.Endpoints.Contains(name))
+ {
+ throw new InvalidOperationException(
+ $"An endpoint with the name '{name}' already exists in the archive.");
+ }
+
+ await using (var stream = _session.OpenWrite(FileNames.GetEndpointOperationPath(name)))
+ {
+ await stream.WriteAsync(operation, cancellationToken);
+ }
+
+ await using (var stream = _session.OpenWrite(FileNames.GetEndpointSettingsPath(name)))
+ {
+ await using var jsonWriter = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
+ settings.WriteTo(jsonWriter);
+ await jsonWriter.FlushAsync(cancellationToken);
+ }
+
+ _metadata = metadata with { Endpoints = metadata.Endpoints.Add(name) };
+ await SetArchiveMetadataAsync(_metadata, cancellationToken);
+ }
+
+ ///
+ /// Tries to get an OpenAPI endpoint by name.
+ ///
+ /// The name of the endpoint to retrieve.
+ /// Token to cancel the operation.
+ /// The OpenAPI endpoint if found, or null if not found.
+ /// Thrown when name is invalid.
+ /// Thrown when the archive has been disposed.
+ public async Task TryGetOpenApiEndpointAsync(
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ if (!NameValidator.IsValidName(name))
+ {
+ throw new ArgumentException($"The endpoint name '{name}' is invalid.", nameof(name));
+ }
+
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var metadata = await GetArchiveMetadataAsync(cancellationToken);
+
+ if (metadata?.Endpoints.Contains(name) != true)
+ {
+ return null;
+ }
+
+ var operationPath = FileNames.GetEndpointOperationPath(name);
+ var settingsPath = FileNames.GetEndpointSettingsPath(name);
+
+ if (!_session.Exists(operationPath) || !_session.Exists(settingsPath))
+ {
+ return null;
+ }
+
+ var buffer = TryRentBuffer();
+
+ try
+ {
+ await using var operationStream = await _session.OpenReadAsync(
+ operationPath,
+ FileKind.Operation,
+ cancellationToken);
+ await operationStream.CopyToAsync(buffer, cancellationToken);
+ var operation = buffer.WrittenMemory.ToArray();
+ buffer.Clear();
+
+ await using var settingsStream = await _session.OpenReadAsync(
+ settingsPath,
+ FileKind.Settings,
+ cancellationToken);
+ var settings = await JsonDocument.ParseAsync(settingsStream, cancellationToken: cancellationToken);
+
+ return new OpenApiEndpoint(operation, settings);
+ }
+ finally
+ {
+ TryReturnBuffer(buffer);
+ }
+ }
+
+ ///
+ /// Adds an OpenAPI model to the archive.
+ ///
+ /// The unique name for this model.
+ /// The GraphQL fragment data to store.
+ /// Token to cancel the operation.
+ /// Thrown when name is invalid.
+ /// Thrown when fragment is empty.
+ /// Thrown when the archive has been disposed.
+ /// Thrown when the archive is read-only, metadata is not set, or model already exists.
+ public async Task AddOpenApiModelAsync(
+ string name,
+ ReadOnlyMemory fragment,
+ CancellationToken cancellationToken = default)
+ {
+ if (!NameValidator.IsValidName(name))
+ {
+ throw new ArgumentException($"The model name '{name}' is invalid.", nameof(name));
+ }
+
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(fragment.Length, 0);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ EnsureMutable();
+
+ var metadata = await GetArchiveMetadataAsync(cancellationToken);
+
+ if (metadata is null)
+ {
+ throw new InvalidOperationException(
+ "You need to first define the archive metadata.");
+ }
+
+ if (metadata.Models.Contains(name))
+ {
+ throw new InvalidOperationException(
+ $"A model with the name '{name}' already exists in the archive.");
+ }
+
+ await using var stream = _session.OpenWrite(FileNames.GetModelFragmentPath(name));
+ await stream.WriteAsync(fragment, cancellationToken);
+
+ _metadata = metadata with { Models = metadata.Models.Add(name) };
+ await SetArchiveMetadataAsync(_metadata, cancellationToken);
+ }
+
+ ///
+ /// Tries to get an OpenAPI model by name.
+ ///
+ /// The name of the model to retrieve.
+ /// Token to cancel the operation.
+ /// The OpenAPI model if found, or null if not found.
+ /// Thrown when name is invalid.
+ /// Thrown when the archive has been disposed.
+ public async Task TryGetOpenApiModelAsync(
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ if (!NameValidator.IsValidName(name))
+ {
+ throw new ArgumentException($"The model name '{name}' is invalid.", nameof(name));
+ }
+
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var metadata = await GetArchiveMetadataAsync(cancellationToken);
+
+ if (metadata?.Models.Contains(name) != true)
+ {
+ return null;
+ }
+
+ var fragmentPath = FileNames.GetModelFragmentPath(name);
+
+ if (!_session.Exists(fragmentPath))
+ {
+ return null;
+ }
+
+ var buffer = TryRentBuffer();
+
+ try
+ {
+ await using var fragmentStream = await _session.OpenReadAsync(
+ fragmentPath,
+ FileKind.Fragment,
+ cancellationToken);
+ await fragmentStream.CopyToAsync(buffer, cancellationToken);
+ var fragment = buffer.WrittenMemory.ToArray();
+
+ return new OpenApiModel(fragment);
+ }
+ finally
+ {
+ TryReturnBuffer(buffer);
+ }
+ }
+
+ ///
+ /// 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 OpenApiCollectionArchiveMode.Read)
+ {
+ throw new InvalidOperationException("Cannot modify a read-only archive.");
+ }
+ }
+
+ ///
+ /// 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.
+ public async Task CommitAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_mode is OpenApiCollectionArchiveMode.Read)
+ {
+ throw new InvalidOperationException("Cannot commit changes to a read-only archive.");
+ }
+
+ 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 = OpenApiCollectionArchiveMode.Update;
+ _session.SetMode(_mode);
+ }
+ else
+ {
+ _mode = OpenApiCollectionArchiveMode.Read;
+ }
+ }
+ }
+
+ ///
+ /// Releases all resources used by the OpenApiCollectionArchive.
+ /// 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();
+ }
+ }
+}
+
+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/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveMode.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveMode.cs
new file mode 100644
index 00000000000..3eb28cbaa1f
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveMode.cs
@@ -0,0 +1,27 @@
+using System.IO.Compression;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Specifies the mode for opening or creating an OpenAPI collection archive.
+///
+public enum OpenApiCollectionArchiveMode
+{
+ ///
+ /// 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/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveOptions.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveOptions.cs
new file mode 100644
index 00000000000..26efbf2f6ff
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveOptions.cs
@@ -0,0 +1,17 @@
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Specifies the options for an OpenAPI collection archive.
+///
+public struct OpenApiCollectionArchiveOptions
+{
+ ///
+ /// Gets or sets the maximum allowed size of an operation in the archive.
+ ///
+ public int? MaxAllowedOperationSize { 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/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveReadOptions.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveReadOptions.cs
new file mode 100644
index 00000000000..47ba9932486
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveReadOptions.cs
@@ -0,0 +1,15 @@
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Specifies the read options for an OpenAPI collection archive.
+///
+internal readonly record struct OpenApiCollectionArchiveReadOptions(
+ int MaxAllowedOperationSize,
+ int MaxAllowedSettingsSize)
+{
+ ///
+ /// Gets the default read options.
+ ///
+ public static OpenApiCollectionArchiveReadOptions Default { get; }
+ = new(50_000_000, 512_000);
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs
new file mode 100644
index 00000000000..6199d6f0b92
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs
@@ -0,0 +1,299 @@
+using System.Buffers;
+using System.IO.Compression;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+internal sealed class OpenApiCollectionArchiveSession : IDisposable
+{
+ private readonly Dictionary _files = [];
+ private readonly ZipArchive _archive;
+ private readonly OpenApiCollectionArchiveReadOptions _readOptions;
+ private OpenApiCollectionArchiveMode _mode;
+ private bool _disposed;
+
+ public OpenApiCollectionArchiveSession(
+ ZipArchive archive,
+ OpenApiCollectionArchiveMode mode,
+ OpenApiCollectionArchiveReadOptions 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 OpenApiCollectionArchiveMode.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 OpenApiCollectionArchiveMode.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 OpenApiCollectionArchiveMode.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 OpenApiCollectionArchiveMode.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 OpenApiCollectionArchiveMode.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 OpenApiCollectionArchiveMode.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(OpenApiCollectionArchiveMode mode)
+ {
+ _mode = mode;
+ }
+
+ public async Task CommitAsync(CancellationToken cancellationToken)
+ {
+ foreach (var file in _files.Values)
+ {
+#if NET10_0_OR_GREATER
+ switch (file.State)
+ {
+ case FileState.Created:
+ await _archive.CreateEntryFromFileAsync(
+ file.TempPath,
+ file.Path,
+ cancellationToken: cancellationToken);
+ break;
+
+ case FileState.Replaced:
+ _archive.GetEntry(file.Path)?.Delete();
+ await _archive.CreateEntryFromFileAsync(
+ file.TempPath,
+ file.Path,
+ cancellationToken);
+ break;
+
+ case FileState.Deleted:
+ _archive.GetEntry(file.Path)?.Delete();
+ break;
+ }
+#else
+ switch (file.State)
+ {
+ case FileState.Created:
+ _archive.CreateEntryFromFile(file.TempPath, file.Path);
+ break;
+
+ case FileState.Replaced:
+ _archive.GetEntry(file.Path)?.Delete();
+ _archive.CreateEntryFromFile(file.TempPath, file.Path);
+ break;
+
+ case FileState.Deleted:
+ _archive.GetEntry(file.Path)?.Delete();
+ break;
+ }
+
+ await Task.CompletedTask;
+#endif
+
+ file.MarkRead();
+ }
+ }
+
+ 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.Operation or FileKind.Fragment
+ => _readOptions.MaxAllowedOperationSize,
+ 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/Adapters/src/Adapters.OpenApi.Packaging/OpenApiEndpoint.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiEndpoint.cs
new file mode 100644
index 00000000000..f36c6d3d507
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiEndpoint.cs
@@ -0,0 +1,21 @@
+using System.Text.Json;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Represents an OpenAPI endpoint containing operation data and settings.
+///
+/// The operation data as raw bytes.
+/// The settings document for this endpoint.
+public sealed record OpenApiEndpoint(
+ ReadOnlyMemory Operation,
+ JsonDocument Settings) : IDisposable
+{
+ ///
+ /// Releases the resources used by the endpoint.
+ ///
+ public void Dispose()
+ {
+ Settings.Dispose();
+ }
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiModel.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiModel.cs
new file mode 100644
index 00000000000..11fdc8abfc5
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiModel.cs
@@ -0,0 +1,7 @@
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+///
+/// Represents an OpenAPI model containing a GraphQL fragment.
+///
+/// The GraphQL fragment as raw bytes.
+public sealed record OpenApiModel(ReadOnlyMemory Fragment);
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs
new file mode 100644
index 00000000000..cea80570800
--- /dev/null
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs
@@ -0,0 +1,94 @@
+using System.Buffers;
+using System.Collections.Immutable;
+using System.Text.Json;
+
+namespace HotChocolate.Adapters.OpenApi.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());
+
+ if (archiveMetadata.Endpoints.Length > 0)
+ {
+ jsonWriter.WriteStartArray("endpoints");
+ foreach (var endpoint in archiveMetadata.Endpoints)
+ {
+ jsonWriter.WriteStringValue(endpoint);
+ }
+ jsonWriter.WriteEndArray();
+ }
+
+ if (archiveMetadata.Models.Length > 0)
+ {
+ jsonWriter.WriteStartArray("models");
+ foreach (var model in archiveMetadata.Models)
+ {
+ jsonWriter.WriteStringValue(model);
+ }
+ jsonWriter.WriteEndArray();
+ }
+
+ 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.");
+ }
+
+ var endpoints = ImmutableArray.Empty;
+ if (root.TryGetProperty("endpoints", out var endpointsProp)
+ && endpointsProp.ValueKind is JsonValueKind.Array)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+ foreach (var item in endpointsProp.EnumerateArray())
+ {
+ if (item.ValueKind is JsonValueKind.String)
+ {
+ builder.Add(item.GetString()!);
+ }
+ }
+ endpoints = builder.ToImmutable();
+ }
+
+ var models = ImmutableArray.Empty;
+ if (root.TryGetProperty("models", out var modelsProp)
+ && modelsProp.ValueKind is JsonValueKind.Array)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+ foreach (var item in modelsProp.EnumerateArray())
+ {
+ if (item.ValueKind is JsonValueKind.String)
+ {
+ builder.Add(item.GetString()!);
+ }
+ }
+ models = builder.ToImmutable();
+ }
+
+ return new ArchiveMetadata
+ {
+ FormatVersion = new Version(formatVersionProp.GetString()!),
+ Endpoints = endpoints,
+ Models = models
+ };
+ }
+}
diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi/HotChocolate.Adapters.OpenApi.csproj b/src/HotChocolate/Adapters/src/Adapters.OpenApi/HotChocolate.Adapters.OpenApi.csproj
index 0521a680b36..c720fc4c54e 100644
--- a/src/HotChocolate/Adapters/src/Adapters.OpenApi/HotChocolate.Adapters.OpenApi.csproj
+++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi/HotChocolate.Adapters.OpenApi.csproj
@@ -3,7 +3,7 @@
HotChocolate.Adapters.OpenApi
HotChocolate.Adapters.OpenApi
- net10.0; net9.0
+ $(OpenApiTargetFrameworks)
diff --git a/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/HotChocolate.Fusion.Adapters.OpenApi.csproj b/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/HotChocolate.Fusion.Adapters.OpenApi.csproj
index 55724191dea..296d5344a93 100644
--- a/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/HotChocolate.Fusion.Adapters.OpenApi.csproj
+++ b/src/HotChocolate/Adapters/src/Fusion.Adapters.OpenApi/HotChocolate.Fusion.Adapters.OpenApi.csproj
@@ -3,7 +3,7 @@
HotChocolate.Adapters.OpenApi
HotChocolate.Fusion.Adapters.OpenApi
- net10.0; net9.0
+ $(OpenApiTargetFrameworks)
diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/HotChocolate.Adapters.OpenApi.Packaging.Tests.csproj b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/HotChocolate.Adapters.OpenApi.Packaging.Tests.csproj
new file mode 100644
index 00000000000..c01edaed32f
--- /dev/null
+++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/HotChocolate.Adapters.OpenApi.Packaging.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+ HotChocolate.Adapters.OpenApi.Packaging
+ HotChocolate.Adapters.OpenApi.Packaging.Tests
+ $(OpenApiTargetFrameworks)
+
+
+
+
+
+
+
diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/OpenApiCollectionArchiveTests.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/OpenApiCollectionArchiveTests.cs
new file mode 100644
index 00000000000..fd03d085164
--- /dev/null
+++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/OpenApiCollectionArchiveTests.cs
@@ -0,0 +1,1046 @@
+using System.Text;
+using System.Text.Json;
+
+namespace HotChocolate.Adapters.OpenApi.Packaging;
+
+public class OpenApiCollectionArchiveTests : IDisposable
+{
+ private readonly List _streamsToDispose = [];
+
+ [Fact]
+ public void Create_WithNullStream_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => OpenApiCollectionArchive.Create(null!));
+ }
+
+ [Fact]
+ public void Open_WithNullStream_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => OpenApiCollectionArchive.Open(default(Stream)!));
+ }
+
+ [Fact]
+ public void Open_WithNullString_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => OpenApiCollectionArchive.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 = OpenApiCollectionArchive.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 = OpenApiCollectionArchive.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 = OpenApiCollectionArchive.Create(stream);
+ await Assert.ThrowsAsync(
+ () => archive.SetArchiveMetadataAsync(null!));
+ }
+
+ // [Fact]
+ // public async Task GetLatestSupportedGatewayFormat_WithValidMetadata_ReturnsHighestVersion()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("1.0.0"), new Version("2.1.0"), new Version("2.0.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // var latest = await archive.GetLatestSupportedGatewayFormatAsync();
+ // Assert.Equal(new Version("2.1.0"), latest);
+ // }
+ //
+ // [Fact]
+ // public async Task GetLatestSupportedGatewayFormat_WithoutMetadata_ThrowsInvalidOperationException()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream);
+ // await Assert.ThrowsAsync(
+ // () => archive.GetLatestSupportedGatewayFormatAsync());
+ // }
+ //
+ // [Fact]
+ // public async Task SetCompositionSettings_WithValidJsonDocument_StoresCorrectly()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // const string settingsJson = """{"enableNodeSpec": true, "maxDepth": 10}""";
+ // using var settings = JsonDocument.Parse(settingsJson);
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetCompositionSettingsAsync(settings);
+ //
+ // // Can read immediately within the same session
+ // using var retrieved = await archive.GetCompositionSettingsAsync();
+ // Assert.NotNull(retrieved);
+ // Assert.True(retrieved.RootElement.GetProperty("enableNodeSpec").GetBoolean());
+ // Assert.Equal(10, retrieved.RootElement.GetProperty("maxDepth").GetInt32());
+ // }
+ //
+ // [Fact]
+ // public async Task GetCompositionSettings_WhenNotSet_ReturnsNull()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream);
+ // var result = await archive.GetCompositionSettingsAsync();
+ // Assert.Null(result);
+ // }
+ //
+ // [Fact]
+ // public async Task SetGatewaySchema_WithStringContent_StoresCorrectly()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // const string schema = "type Query { hello: String }";
+ // var settings = CreateSettingsJson();
+ // var version = new Version("2.0.0");
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetGatewayConfigurationAsync(schema, settings, version);
+ //
+ // // Can read immediately within the same session
+ // var result = await archive.TryGetGatewayConfigurationAsync(version);
+ //
+ // Assert.NotNull(result);
+ // Assert.Equal(version, result.Version);
+ //
+ // using (var streamReader = new StreamReader(await result.OpenReadSchemaAsync()))
+ // {
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal(schema, retrievedSchema);
+ // }
+ //
+ // result.Dispose();
+ // }
+ //
+ // [Fact]
+ // public async Task SetGatewaySchema_WithByteContent_StoresCorrectly()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var schema = "type Query { hello: String }"u8.ToArray();
+ // var settings = CreateSettingsJson();
+ // var version = new Version("2.0.0");
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetGatewayConfigurationAsync(schema, settings, version);
+ //
+ // // Can read immediately within the same session
+ // var result = await archive.TryGetGatewayConfigurationAsync(version);
+ //
+ // Assert.NotNull(result);
+ // Assert.Equal(version, result.Version);
+ //
+ // using (var streamReader = new StreamReader(await result.OpenReadSchemaAsync()))
+ // {
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal(Encoding.UTF8.GetString(schema), retrievedSchema);
+ // }
+ //
+ // result.Dispose();
+ // }
+ //
+ // [Fact]
+ // public async Task SetGatewaySchema_WithoutMetadata_ThrowsInvalidOperationException()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream);
+ // await Assert.ThrowsAsync(
+ // () => archive.SetGatewayConfigurationAsync("schema", CreateSettingsJson(), new Version("1.0.0")));
+ // }
+ //
+ // [Fact]
+ // public async Task SetGatewaySchema_WithUnsupportedVersion_ThrowsInvalidOperationException()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // await Assert.ThrowsAsync(() =>
+ // archive.SetGatewayConfigurationAsync("schema", CreateSettingsJson(), new Version("3.0.0")));
+ // }
+ //
+ // [Fact]
+ // public async Task TryGetGatewaySchema_WithCompatibleVersion_ReturnsCorrectVersion()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("1.0.0"), new Version("2.0.0"), new Version("2.1.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetGatewayConfigurationAsync("schema v1.0", CreateSettingsJson(), new Version("1.0.0"));
+ // await archive.SetGatewayConfigurationAsync("schema v2.0", CreateSettingsJson(), new Version("2.0.0"));
+ // await archive.SetGatewayConfigurationAsync("schema v2.1", CreateSettingsJson(), new Version("2.1.0"));
+ //
+ // // Request max version 2.0.0, should get 2.0.0
+ // var result = await archive.TryGetGatewayConfigurationAsync(new Version("2.0.0"));
+ //
+ // Assert.NotNull(result);
+ // Assert.Equal(new Version("2.0.0"), result.Version);
+ //
+ // using (var streamReader = new StreamReader(await result.OpenReadSchemaAsync()))
+ // {
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal("schema v2.0", retrievedSchema);
+ // }
+ //
+ // result.Dispose();
+ // }
+ //
+ // [Fact]
+ // public async Task TryGetGatewaySchema_WithIncompatibleVersion_ReturnsFalse()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // var result = await archive.TryGetGatewayConfigurationAsync(new Version("1.0.0"));
+ //
+ // Assert.Null(result);
+ // }
+ //
+ // [Fact]
+ // public async Task SetSourceSchema_WithValidSchema_StoresCorrectly()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var schemaContent = "type User { id: ID! name: String! }"u8.ToArray();
+ // var settings = CreateSettingsJson();
+ // const string schemaName = "user-service";
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetSourceSchemaConfigurationAsync(schemaName, schemaContent, settings);
+ //
+ // // Can read immediately within the same session
+ // var found = await archive.TryGetSourceSchemaConfigurationAsync(schemaName);
+ //
+ // Assert.NotNull(found);
+ //
+ // using var streamReader = new StreamReader(await found.OpenReadSchemaAsync());
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal(Encoding.UTF8.GetString(schemaContent), retrievedSchema);
+ // }
+ //
+ // [Fact]
+ // public async Task SetSourceSchema_WithInvalidSchemaName_ThrowsArgumentException()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // await Assert.ThrowsAsync(
+ // () => archive.SetSourceSchemaConfigurationAsync(
+ // "invalid name!",
+ // "schema"u8.ToArray(),
+ // CreateSettingsJson()));
+ // }
+ //
+ // [Fact]
+ // public async Task SetSourceSchema_WithUndeclaredSchemaName_ThrowsInvalidOperationException()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = ["declared-schema"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // await Assert.ThrowsAsync(
+ // () => archive.SetSourceSchemaConfigurationAsync(
+ // "undeclared-schema",
+ // "schema"u8.ToArray(),
+ // CreateSettingsJson()));
+ // }
+ //
+ // [Fact]
+ // public async Task TryGetSourceSchema_WithNonExistentSchema_ReturnsFalse()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var found = await archive.TryGetSourceSchemaConfigurationAsync("non-existent");
+ // Assert.Null(found);
+ // }
+
+ // [Fact]
+ // public async Task CommitAndReopen_PersistsChanges()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ // const string schema = "type Query { hello: String }";
+ //
+ // // Act - Create and commit
+ // using (var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true))
+ // {
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetGatewayConfigurationAsync(schema, CreateSettingsJson(), new Version("2.0.0"));
+ // await archive.CommitAsync();
+ // }
+ //
+ // // Assert - Reopen and verify persistence
+ // stream.Position = 0;
+ // using (var readArchive = OpenApiCollectionArchive.Open(stream, leaveOpen: true))
+ // {
+ // var retrievedMetadata = await readArchive.GetArchiveMetadataAsync();
+ // Assert.NotNull(retrievedMetadata);
+ // Assert.Equal(
+ // metadata.SupportedGatewayFormats.ToArray(),
+ // retrievedMetadata.SupportedGatewayFormats.ToArray());
+ //
+ // var result = await readArchive.TryGetGatewayConfigurationAsync(new Version("2.0.0"));
+ // Assert.NotNull(result);
+ //
+ // using var streamReader = new StreamReader(await result.OpenReadSchemaAsync());
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal(schema, retrievedSchema);
+ //
+ // result.Dispose();
+ // }
+ // }
+ //
+ // [Fact]
+ // public async Task UpdateMode_CanModifyExistingArchive()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ //
+ // // Act - Create initial archive
+ // using (var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true))
+ // {
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetGatewayConfigurationAsync("original schema", CreateSettingsJson(), new Version("2.0.0"));
+ // await archive.CommitAsync();
+ // }
+ //
+ // // Act - Update existing archive
+ // stream.Position = 0;
+ // using (var updateArchive = OpenApiCollectionArchive.Open(stream, OpenApiCollectionArchiveMode.Update, leaveOpen: true))
+ // {
+ // await updateArchive.SetGatewayConfigurationAsync(
+ // "modified schema",
+ // CreateSettingsJson(),
+ // new Version("2.0.0"));
+ // await updateArchive.CommitAsync();
+ // }
+ //
+ // // Assert - Verify modification
+ // stream.Position = 0;
+ // using (var readArchive = OpenApiCollectionArchive.Open(stream, leaveOpen: true))
+ // {
+ // var result = await readArchive.TryGetGatewayConfigurationAsync(new Version("2.0.0"));
+ // Assert.NotNull(result);
+ //
+ // using var streamReader = new StreamReader(await result.OpenReadSchemaAsync());
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal("modified schema", retrievedSchema);
+ //
+ // result.Dispose();
+ // }
+ // }
+ //
+ // [Fact]
+ // public async Task OverwriteFile_WithinSession_ReplacesContent()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // var metadata = CreateTestMetadata();
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // // Set schema twice within the same session
+ // await archive.SetGatewayConfigurationAsync("first schema", CreateSettingsJson(), new Version("2.0.0"));
+ // await archive.SetGatewayConfigurationAsync("second schema", CreateSettingsJson(), new Version("2.0.0"));
+ //
+ // // Should get the last value
+ // var result = await archive.TryGetGatewayConfigurationAsync(new Version("2.0.0"));
+ //
+ // Assert.NotNull(result);
+ //
+ // using var streamReader = new StreamReader(await result.OpenReadSchemaAsync());
+ // var retrievedSchema = await streamReader.ReadToEndAsync();
+ // Assert.Equal("second schema", retrievedSchema);
+ //
+ // result.Dispose();
+ // }
+ //
+ // [Fact]
+ // public async Task GetSourceSchemaNames_WithMetadata_ReturnsOrderedNames()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = ["zebra-service", "alpha-service", "beta-service"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // var names = await archive.GetSourceSchemaNamesAsync();
+ // Assert.Equal(["alpha-service", "beta-service", "zebra-service"], names);
+ // }
+ //
+ // [Fact]
+ // public async Task GetSupportedGatewayFormats_WithMetadata_ReturnsDescendingOrder()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("1.0.0"), new Version("2.1.0"), new Version("2.0.0")],
+ // SourceSchemas = ["test-service"]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // var versions = await archive.GetSupportedGatewayFormatsAsync();
+ // Assert.Equal([new Version("2.1.0"), new Version("2.0.0"), new Version("1.0.0")], versions);
+ // }
+ //
+ // [Theory]
+ // [InlineData("valid-schema")]
+ // [InlineData("Valid_Schema")]
+ // [InlineData("schema123")]
+ // [InlineData("_schema")]
+ // public async Task SetSourceSchema_WithValidSchemaNames_Succeeds(string schemaName)
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = [schemaName]
+ // };
+ //
+ // // Act & Assert - Should not throw
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ // await archive.SetSourceSchemaConfigurationAsync(schemaName, "schema"u8.ToArray(), CreateSettingsJson());
+ // }
+ //
+ // [Theory]
+ // [InlineData("invalid name")]
+ // [InlineData("123invalid")]
+ // [InlineData("")]
+ // [InlineData("schema/name")]
+ // public async Task SetSourceSchema_WithInvalidSchemaNames_ThrowsException(string schemaName)
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ // var metadata = new ArchiveMetadata
+ // {
+ // SupportedGatewayFormats = [new Version("2.0.0")],
+ // SourceSchemas = [schemaName]
+ // };
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ // await archive.SetArchiveMetadataAsync(metadata);
+ //
+ // await Assert.ThrowsAsync(
+ // () => archive.SetSourceSchemaConfigurationAsync(
+ // schemaName,
+ // "schema"u8.ToArray(),
+ // CreateSettingsJson()));
+ // }
+ //
+ // [Fact]
+ // public async Task GetSupportedGatewayFormats_WithoutMetadata_ReturnsEmpty()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream);
+ // var formats = await archive.GetSupportedGatewayFormatsAsync();
+ // Assert.Empty(formats);
+ // }
+ //
+ // [Fact]
+ // public async Task GetSourceSchemaNames_WithoutMetadata_ReturnsEmpty()
+ // {
+ // // Arrange
+ // await using var stream = CreateStream();
+ //
+ // // Act & Assert
+ // using var archive = OpenApiCollectionArchive.Create(stream);
+ // var names = await archive.GetSourceSchemaNamesAsync();
+ // Assert.Empty(names);
+ // }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithValidData_StoresCorrectly()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUsers { users { id name } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUsers", operation, settings);
+
+ // Assert - Can read immediately within the same session
+ var endpoint = await archive.TryGetOpenApiEndpointAsync("GetUsers");
+
+ Assert.NotNull(endpoint);
+ Assert.Equal(operation, endpoint.Operation.ToArray());
+ Assert.Equal("GET", endpoint.Settings.RootElement.GetProperty("method").GetString());
+ Assert.Equal("/api/users", endpoint.Settings.RootElement.GetProperty("route").GetString());
+
+ endpoint.Dispose();
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithMultipleEndpoints_StoresAll()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation1 = "query GetUsers { users { id name } }"u8.ToArray();
+ var operation2 = "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id } }"u8.ToArray();
+ var operation3 = "mutation DeleteUser($id: ID!) { deleteUser(id: $id) }"u8.ToArray();
+ using var settings1 = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+ using var settings2 = JsonDocument.Parse("""{"method": "POST", "route": "/api/users"}""");
+ using var settings3 = JsonDocument.Parse("""{"method": "DELETE", "route": "/api/users/{id}"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUsers", operation1, settings1);
+ await archive.AddOpenApiEndpointAsync("CreateUser", operation2, settings2);
+ await archive.AddOpenApiEndpointAsync("DeleteUser", operation3, settings3);
+
+ // Assert
+ var endpoint1 = await archive.TryGetOpenApiEndpointAsync("GetUsers");
+ var endpoint2 = await archive.TryGetOpenApiEndpointAsync("CreateUser");
+ var endpoint3 = await archive.TryGetOpenApiEndpointAsync("DeleteUser");
+
+ Assert.NotNull(endpoint1);
+ Assert.Equal(operation1, endpoint1.Operation.ToArray());
+ Assert.Equal("GET", endpoint1.Settings.RootElement.GetProperty("method").GetString());
+ Assert.Equal("/api/users", endpoint1.Settings.RootElement.GetProperty("route").GetString());
+
+ Assert.NotNull(endpoint2);
+ Assert.Equal(operation2, endpoint2.Operation.ToArray());
+ Assert.Equal("POST", endpoint2.Settings.RootElement.GetProperty("method").GetString());
+
+ Assert.NotNull(endpoint3);
+ Assert.Equal(operation3, endpoint3.Operation.ToArray());
+ Assert.Equal("DELETE", endpoint3.Settings.RootElement.GetProperty("method").GetString());
+
+ endpoint1.Dispose();
+ endpoint2.Dispose();
+ endpoint3.Dispose();
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithoutMetadata_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query { users { id } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream);
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiEndpointAsync("GetUsers", operation, settings));
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithEmptyOperation_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = ReadOnlyMemory.Empty;
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiEndpointAsync("GetUsers", operation, settings));
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithNullSettings_ThrowsArgumentNullException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query { users { id } }"u8.ToArray();
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiEndpointAsync("GetUsers", operation, null!));
+ }
+
+ [Fact]
+ public async Task TryGetOpenApiEndpoint_WhenNotExists_ReturnsNull()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+
+ var endpoint = await archive.TryGetOpenApiEndpointAsync("NonExistent");
+
+ // Assert
+ Assert.Null(endpoint);
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_CommitAndReopen_PersistsEndpoints()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUserById($id: ID!) { userById(id: $id) { id name email } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users/{id}"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act - Create and commit
+ using (var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true))
+ {
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUserById", operation, settings);
+ await archive.CommitAsync();
+ }
+
+ // Assert - Reopen and verify persistence
+ stream.Position = 0;
+ using (var readArchive = OpenApiCollectionArchive.Open(stream, leaveOpen: true))
+ {
+ var endpoint = await readArchive.TryGetOpenApiEndpointAsync("GetUserById");
+
+ Assert.NotNull(endpoint);
+ Assert.Equal(operation, endpoint.Operation.ToArray());
+ Assert.Equal("GET", endpoint.Settings.RootElement.GetProperty("method").GetString());
+ Assert.Equal("/api/users/{id}", endpoint.Settings.RootElement.GetProperty("route").GetString());
+
+ endpoint.Dispose();
+ }
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithComplexSettings_PreservesJsonStructure()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query SearchUsers($filter: UserFilter!, $first: Int) { users(filter: $filter, first: $first) { id name } }"u8.ToArray();
+ const string settingsJson = """
+ {
+ "method": "GET",
+ "route": "/api/users/search",
+ "headers": {
+ "X-Api-Version": "2.0"
+ },
+ "queryParams": ["filter", "first"],
+ "deprecated": false
+ }
+ """;
+ using var settings = JsonDocument.Parse(settingsJson);
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("SearchUsers", operation, settings);
+
+ // Assert
+ var endpoint = await archive.TryGetOpenApiEndpointAsync("SearchUsers");
+
+ Assert.NotNull(endpoint);
+ var retrievedSettings = endpoint.Settings.RootElement;
+ Assert.Equal("GET", retrievedSettings.GetProperty("method").GetString());
+ Assert.Equal("/api/users/search", retrievedSettings.GetProperty("route").GetString());
+ Assert.Equal("2.0", retrievedSettings.GetProperty("headers").GetProperty("X-Api-Version").GetString());
+ Assert.Equal(2, retrievedSettings.GetProperty("queryParams").GetArrayLength());
+ Assert.False(retrievedSettings.GetProperty("deprecated").GetBoolean());
+
+ endpoint.Dispose();
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_WithValidData_StoresCorrectly()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment = "fragment UserFields on User { id name email }"u8.ToArray();
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiModelAsync("UserFields", fragment);
+
+ // Assert - Can read immediately within the same session
+ var model = await archive.TryGetOpenApiModelAsync("UserFields");
+
+ Assert.NotNull(model);
+ Assert.Equal(fragment, model.Fragment.ToArray());
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_WithMultipleModels_StoresAll()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment1 = "fragment UserFields on User { id name email }"u8.ToArray();
+ var fragment2 = "fragment ProductFields on Product { id title price }"u8.ToArray();
+ var fragment3 = "fragment OrderFields on Order { id status createdAt }"u8.ToArray();
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiModelAsync("UserFields", fragment1);
+ await archive.AddOpenApiModelAsync("ProductFields", fragment2);
+ await archive.AddOpenApiModelAsync("OrderFields", fragment3);
+
+ // Assert
+ var model1 = await archive.TryGetOpenApiModelAsync("UserFields");
+ var model2 = await archive.TryGetOpenApiModelAsync("ProductFields");
+ var model3 = await archive.TryGetOpenApiModelAsync("OrderFields");
+
+ Assert.NotNull(model1);
+ Assert.Equal(fragment1, model1.Fragment.ToArray());
+
+ Assert.NotNull(model2);
+ Assert.Equal(fragment2, model2.Fragment.ToArray());
+
+ Assert.NotNull(model3);
+ Assert.Equal(fragment3, model3.Fragment.ToArray());
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_WithoutMetadata_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment = "fragment UserFields on User { id }"u8.ToArray();
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream);
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiModelAsync("UserFields", fragment));
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_WithEmptyFragment_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment = ReadOnlyMemory.Empty;
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiModelAsync("UserFields", fragment));
+ }
+
+ [Fact]
+ public async Task TryGetOpenApiModel_WhenNotExists_ReturnsNull()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+
+ var model = await archive.TryGetOpenApiModelAsync("NonExistent");
+
+ // Assert
+ Assert.Null(model);
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_CommitAndReopen_PersistsModels()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment = "fragment UserFields on User { id name email createdAt }"u8.ToArray();
+ var metadata = CreateTestMetadata();
+
+ // Act - Create and commit
+ using (var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true))
+ {
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiModelAsync("UserFields", fragment);
+ await archive.CommitAsync();
+ }
+
+ // Assert - Reopen and verify persistence
+ stream.Position = 0;
+ using (var readArchive = OpenApiCollectionArchive.Open(stream, leaveOpen: true))
+ {
+ var model = await readArchive.TryGetOpenApiModelAsync("UserFields");
+
+ Assert.NotNull(model);
+ Assert.Equal(fragment, model.Fragment.ToArray());
+ }
+ }
+
+ [Fact]
+ public async Task AddEndpointsAndModels_Together_StoresSeparately()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUser($id: ID!) { userById(id: $id) { ...UserFields } }"u8.ToArray();
+ var fragment = "fragment UserFields on User { id name email }"u8.ToArray();
+ using var endpointSettings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users/{id}"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUser", operation, endpointSettings);
+ await archive.AddOpenApiModelAsync("UserFields", fragment);
+
+ // Assert - Endpoints
+ var endpoint = await archive.TryGetOpenApiEndpointAsync("GetUser");
+
+ Assert.NotNull(endpoint);
+ Assert.Equal(operation, endpoint.Operation.ToArray());
+
+ // Assert - Models
+ var model = await archive.TryGetOpenApiModelAsync("UserFields");
+
+ Assert.NotNull(model);
+ Assert.Equal(fragment, model.Fragment.ToArray());
+
+ endpoint.Dispose();
+ }
+
+ [Fact]
+ public async Task AddOpenApiEndpoint_WithDuplicateName_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUsers { users { id } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUsers", operation, settings);
+
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiEndpointAsync("GetUsers", operation, settings));
+ }
+
+ [Fact]
+ public async Task AddOpenApiModel_WithDuplicateName_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var fragment = "fragment UserFields on User { id }"u8.ToArray();
+ var metadata = CreateTestMetadata();
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiModelAsync("UserFields", fragment);
+
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiModelAsync("UserFields", fragment));
+ }
+
+ [Theory]
+ [InlineData("valid-name")]
+ [InlineData("Valid_Name")]
+ [InlineData("name123")]
+ [InlineData("_name")]
+ public async Task AddOpenApiEndpoint_WithValidNames_Succeeds(string name)
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUsers { users { id } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+
+ // Act & Assert - Should not throw
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+ await archive.AddOpenApiEndpointAsync(name, operation, settings);
+
+ var endpoint = await archive.TryGetOpenApiEndpointAsync(name);
+ Assert.NotNull(endpoint);
+ endpoint.Dispose();
+ }
+
+ [Theory]
+ [InlineData("invalid name")]
+ [InlineData("123invalid")]
+ [InlineData("")]
+ [InlineData("name/path")]
+ public async Task AddOpenApiEndpoint_WithInvalidNames_ThrowsArgumentException(string name)
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUsers { users { id } }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+
+ // Act & Assert
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(CreateTestMetadata());
+
+ await Assert.ThrowsAsync(
+ () => archive.AddOpenApiEndpointAsync(name, operation, settings));
+ }
+
+ [Fact]
+ public async Task GetArchiveMetadata_ReturnsEndpointAndModelNames()
+ {
+ // Arrange
+ await using var stream = CreateStream();
+ var operation = "query GetUsers { users { id } }"u8.ToArray();
+ var fragment = "fragment UserFields on User { id }"u8.ToArray();
+ using var settings = JsonDocument.Parse("""{"method": "GET", "route": "/api/users"}""");
+ var metadata = CreateTestMetadata();
+
+ // Act
+ using var archive = OpenApiCollectionArchive.Create(stream, leaveOpen: true);
+ await archive.SetArchiveMetadataAsync(metadata);
+ await archive.AddOpenApiEndpointAsync("GetUsers", operation, settings);
+ await archive.AddOpenApiModelAsync("UserFields", fragment);
+
+ // Assert
+ var retrievedMetadata = await archive.GetArchiveMetadataAsync();
+ Assert.NotNull(retrievedMetadata);
+ Assert.Contains("GetUsers", retrievedMetadata.Endpoints);
+ Assert.Contains("UserFields", retrievedMetadata.Models);
+ }
+
+ private Stream CreateStream()
+ {
+ var stream = new MemoryStream();
+ _streamsToDispose.Add(stream);
+ return stream;
+ }
+
+ private static ArchiveMetadata CreateTestMetadata()
+ {
+ return new ArchiveMetadata
+ {
+ FormatVersion = new Version("1.0.0")
+ };
+ }
+
+ public void Dispose()
+ {
+ foreach (var stream in _streamsToDispose)
+ {
+ stream.Dispose();
+ }
+ }
+}
diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/HotChocolate.Adapters.OpenApi.Tests.csproj b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/HotChocolate.Adapters.OpenApi.Tests.csproj
index ad22323b85b..3093c5a3a77 100644
--- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/HotChocolate.Adapters.OpenApi.Tests.csproj
+++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/HotChocolate.Adapters.OpenApi.Tests.csproj
@@ -4,7 +4,7 @@
HotChocolate.Adapters.OpenApi
HotChocolate.Adapters.OpenApi.Tests
- net10.0; net9.0
+ $(OpenApiTargetFrameworks)
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/ArchiveSession.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/ArchiveSession.cs
index 894f4b86282..8a8bb6fc0be 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/ArchiveSession.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/ArchiveSession.cs
@@ -183,21 +183,28 @@ private static async Task ExtractFileAsync(
var buffer = ArrayPool.Shared.Rent(4096);
var consumed = 0;
- 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)
+ try
{
- consumed += read;
+ await using var readStream = zipEntry.Open();
+ await using var writeStream = File.Open(fileEntry.TempPath, FileMode.Create, FileAccess.Write);
- if (consumed > maxAllowedSize)
+ int read;
+ while ((read = await readStream.ReadAsync(buffer, cancellationToken)) > 0)
{
- throw new InvalidOperationException(
- $"File is too large and exceeds the allowed size of {maxAllowedSize}.");
- }
+ 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);
+ await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
}
}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/FusionArchive.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/FusionArchive.cs
index f5a5996a881..d04e0b498a1 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/FusionArchive.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/FusionArchive.cs
@@ -779,7 +779,7 @@ private void TryReturnBuffer(ArrayBufferWriter buffer)
buffer.Clear();
var currentBuffer = _buffer;
- var currentCapacity = _buffer?.Capacity ?? 0;
+ var currentCapacity = currentBuffer?.Capacity ?? 0;
if (currentCapacity < buffer.Capacity)
{
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/Serializers/ArchiveMetadataSerializer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/Serializers/ArchiveMetadataSerializer.cs
index 037c72cc6b5..ccd864ed8ec 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/Serializers/ArchiveMetadataSerializer.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Packaging/Serializers/ArchiveMetadataSerializer.cs
@@ -36,7 +36,7 @@ public static void Format(ArchiveMetadata archiveMetadata, IBufferWriter w
public static ArchiveMetadata Parse(ReadOnlyMemory data)
{
- var document = JsonDocument.Parse(data);
+ using var document = JsonDocument.Parse(data);
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)