From a01b8bcc972b231fa3d69b2e81241c03909a7e5d Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:11:19 +0100 Subject: [PATCH 1/3] Add new project --- src/All.slnx | 2 ++ src/Directory.Build.props | 1 + src/HotChocolate/Adapters/Adapters.slnx | 2 ++ .../HotChocolate.Adapters.OpenApi.Core.csproj | 2 +- .../HotChocolate.Adapters.OpenApi.Packaging.csproj | 9 +++++++++ .../HotChocolate.Adapters.OpenApi.csproj | 2 +- .../HotChocolate.Fusion.Adapters.OpenApi.csproj | 2 +- ...otChocolate.Adapters.OpenApi.Packaging.Tests.csproj | 10 ++++++++++ .../HotChocolate.Adapters.OpenApi.Tests.csproj | 2 +- 9 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/HotChocolate.Adapters.OpenApi.Packaging.csproj create mode 100644 src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/HotChocolate.Adapters.OpenApi.Packaging.Tests.csproj 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/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/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..29be6985a68 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/HotChocolate.Adapters.OpenApi.Packaging.Tests.csproj @@ -0,0 +1,10 @@ + + + + + HotChocolate.Adapters.OpenApi.Packaging + HotChocolate.Adapters.OpenApi.Packaging.Tests + $(OpenApiTargetFrameworks) + + + 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) From 9d9853ca64c789691a808494bcc79f500a7929da Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:40:17 +0100 Subject: [PATCH 2/3] Implement basic archive --- .../ArchiveMetadata.cs | 25 + .../ArchiveSession.cs | 291 +++++ .../Adapters.OpenApi.Packaging/FileKind.cs | 10 + .../Adapters.OpenApi.Packaging/FileNames.cs | 36 + .../NameValidator.cs | 27 + .../OpenApiCollectionArchive.cs | 528 +++++++++ .../OpenApiCollectionArchiveMode.cs | 27 + .../OpenApiCollectionArchiveOptions.cs | 17 + .../OpenApiCollectionArchiveReadOptions.cs | 15 + .../OpenApiEndpoint.cs | 21 + .../OpenApiModel.cs | 7 + .../Serializers/ArchiveMetadataSerializer.cs | 94 ++ ...te.Adapters.OpenApi.Packaging.Tests.csproj | 4 + .../OpenApiCollectionArchiveTests.cs | 1046 +++++++++++++++++ 14 files changed, 2148 insertions(+) create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveMetadata.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileKind.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/FileNames.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/NameValidator.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveMode.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveOptions.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveReadOptions.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiEndpoint.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiModel.cs create mode 100644 src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs create mode 100644 src/HotChocolate/Adapters/test/Adapters.OpenApi.Packaging.Tests/OpenApiCollectionArchiveTests.cs 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/ArchiveSession.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs new file mode 100644 index 00000000000..3a6b682c959 --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs @@ -0,0 +1,291 @@ +using System.Buffers; +using System.IO.Compression; + +namespace HotChocolate.Adapters.OpenApi.Packaging; + +internal sealed class ArchiveSession : IDisposable +{ + private readonly Dictionary _files = []; + private readonly ZipArchive _archive; + private readonly OpenApiCollectionArchiveReadOptions _readOptions; + private OpenApiCollectionArchiveMode _mode; + private bool _disposed; + + public ArchiveSession( + 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; + + 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); + } + } + + 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/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/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..f7be124f097 --- /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 ArchiveSession _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 ArchiveSession(_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 = _buffer?.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/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..196a984e939 --- /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) + { + 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/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 index 29be6985a68..c01edaed32f 100644 --- 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 @@ -7,4 +7,8 @@ $(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(); + } + } +} From 072a8b910eeceadc3af9bab833bfcc74626e6f7d Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:46:58 +0100 Subject: [PATCH 3/3] Some fixes --- .../OpenApiCollectionArchive.cs | 6 ++-- ....cs => OpenApiCollectionArchiveSession.cs} | 34 ++++++++++++------- .../Serializers/ArchiveMetadataSerializer.cs | 2 +- .../src/Fusion.Packaging/ArchiveSession.cs | 29 ++++++++++------ .../src/Fusion.Packaging/FusionArchive.cs | 2 +- .../Serializers/ArchiveMetadataSerializer.cs | 2 +- 6 files changed, 45 insertions(+), 30 deletions(-) rename src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/{ArchiveSession.cs => OpenApiCollectionArchiveSession.cs} (90%) diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs index f7be124f097..4428e26df86 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchive.cs @@ -14,7 +14,7 @@ public sealed class OpenApiCollectionArchive : IDisposable { private readonly Stream _stream; private readonly bool _leaveOpen; - private readonly ArchiveSession _session; + private readonly OpenApiCollectionArchiveSession _session; private ZipArchive _archive; private OpenApiCollectionArchiveMode _mode; private ArrayBufferWriter? _buffer; @@ -31,7 +31,7 @@ private OpenApiCollectionArchive( _mode = mode; _leaveOpen = leaveOpen; _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen); - _session = new ArchiveSession(_archive, mode, options); + _session = new OpenApiCollectionArchiveSession(_archive, mode, options); } /// @@ -420,7 +420,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/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs similarity index 90% rename from src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs rename to src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs index 3a6b682c959..6199d6f0b92 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/ArchiveSession.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/OpenApiCollectionArchiveSession.cs @@ -3,7 +3,7 @@ namespace HotChocolate.Adapters.OpenApi.Packaging; -internal sealed class ArchiveSession : IDisposable +internal sealed class OpenApiCollectionArchiveSession : IDisposable { private readonly Dictionary _files = []; private readonly ZipArchive _archive; @@ -11,7 +11,7 @@ internal sealed class ArchiveSession : IDisposable private OpenApiCollectionArchiveMode _mode; private bool _disposed; - public ArchiveSession( + public OpenApiCollectionArchiveSession( ZipArchive archive, OpenApiCollectionArchiveMode mode, OpenApiCollectionArchiveReadOptions readOptions) @@ -119,6 +119,7 @@ public Stream OpenWrite(string path) file ??= FileEntry.Created(path); var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write); _files.Add(path, file); + return stream; } @@ -186,21 +187,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; - await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + 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); } } diff --git a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs index 196a984e939..cea80570800 100644 --- a/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs +++ b/src/HotChocolate/Adapters/src/Adapters.OpenApi.Packaging/Serializers/ArchiveMetadataSerializer.cs @@ -40,7 +40,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) 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)