From 278b2c68acc9ee46185f5f0f7b96c01b093e9444 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Mon, 18 May 2026 16:08:36 +0200 Subject: [PATCH 1/4] Add OperationRequestBuilder.SetFiles --- src/All.slnx | 4 ++ .../src/AspNetCore.Pipeline/FormFileLookup.cs | 2 +- .../HotChocolate.AspNetCore.Pipeline.csproj | 1 + .../Testing/Directory.Build.props | 3 ++ .../Testing/src/Directory.Build.props | 8 ++++ .../src/Testing/HotChocolate.Testing.csproj | 14 +++++++ .../OperationRequestBuilderExtensions.cs | 38 +++++++++++++++++++ 7 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/Testing/Directory.Build.props create mode 100644 src/HotChocolate/Testing/src/Directory.Build.props create mode 100644 src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj create mode 100644 src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs diff --git a/src/All.slnx b/src/All.slnx index d1ed667624e..f3b1576edba 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -333,6 +333,10 @@ + + + + diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs index 2d6cb78abe6..2ca30890c33 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs @@ -9,7 +9,7 @@ internal sealed class FormFileLookup : IFileLookup { private readonly Dictionary _fileMap; - private FormFileLookup(Dictionary fileMap) + public FormFileLookup(Dictionary fileMap) { _fileMap = fileMap; } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj index 10fcfa74bad..a6720ae021d 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj @@ -34,6 +34,7 @@ + diff --git a/src/HotChocolate/Testing/Directory.Build.props b/src/HotChocolate/Testing/Directory.Build.props new file mode 100644 index 00000000000..106b12775b2 --- /dev/null +++ b/src/HotChocolate/Testing/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/src/HotChocolate/Testing/src/Directory.Build.props b/src/HotChocolate/Testing/src/Directory.Build.props new file mode 100644 index 00000000000..db20bbe5a51 --- /dev/null +++ b/src/HotChocolate/Testing/src/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj b/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj new file mode 100644 index 00000000000..842390f449e --- /dev/null +++ b/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj @@ -0,0 +1,14 @@ + + + + HotChocolate.Testing + HotChocolate.Testing + HotChocolate.Testing + Testing helpers for Hot Chocolate. + + + + + + + diff --git a/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs b/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs new file mode 100644 index 00000000000..5eefad717b3 --- /dev/null +++ b/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs @@ -0,0 +1,38 @@ +using HotChocolate.AspNetCore; +using HotChocolate.Execution; +using HotChocolate.Types; + +namespace HotChocolate.Testing; + +/// +/// Testing extensions for . +/// +public static class OperationRequestBuilderExtensions +{ + /// + /// Registers a set of uploaded files with the operation request so that + /// Upload variables can be resolved through an feature. + /// + /// + /// The operation request builder. + /// + /// + /// A map of multipart request keys to instances. + /// + /// + /// The same for configuration chaining. + /// + /// + /// or is null. + /// + public static OperationRequestBuilder SetFiles( + this OperationRequestBuilder builder, + Dictionary fileMap) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(fileMap); + + builder.Features.Set(new FormFileLookup(fileMap)); + return builder; + } +} From fd31d928125e3ec1edc9cfa998d6c2f7fdf0862d Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 26 May 2026 11:23:48 +0200 Subject: [PATCH 2/4] Cleanup --- src/All.slnx | 4 - .../src/AspNetCore.Pipeline/FormFileLookup.cs | 2 +- .../HotChocolate.AspNetCore.Pipeline.csproj | 1 - .../OperationRequestBuilderExtensions.cs | 48 ++++++ .../Execution/OperationRequestFileLookup.cs | 17 +++ .../OperationRequestBuilderExtensionsTests.cs | 144 ++++++++++++++++++ .../Testing/Directory.Build.props | 3 - .../Testing/src/Directory.Build.props | 8 - .../src/Testing/HotChocolate.Testing.csproj | 14 -- .../OperationRequestBuilderExtensions.cs | 38 ----- 10 files changed, 210 insertions(+), 69 deletions(-) create mode 100644 src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationRequestFileLookup.cs create mode 100644 src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs delete mode 100644 src/HotChocolate/Testing/Directory.Build.props delete mode 100644 src/HotChocolate/Testing/src/Directory.Build.props delete mode 100644 src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj delete mode 100644 src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs diff --git a/src/All.slnx b/src/All.slnx index f3b1576edba..d1ed667624e 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -333,10 +333,6 @@ - - - - diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs index 2ca30890c33..2d6cb78abe6 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/FormFileLookup.cs @@ -9,7 +9,7 @@ internal sealed class FormFileLookup : IFileLookup { private readonly Dictionary _fileMap; - public FormFileLookup(Dictionary fileMap) + private FormFileLookup(Dictionary fileMap) { _fileMap = fileMap; } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj index a6720ae021d..10fcfa74bad 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj @@ -34,7 +34,6 @@ - diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs index f5feee26b18..18daff32ce8 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using HotChocolate.Features; +using HotChocolate.Types; namespace HotChocolate.Execution; @@ -21,4 +23,50 @@ public static OperationRequestBuilder SetUser( public static OperationRequestBuilder MarkAsWarmupRequest( this OperationRequestBuilder builder) => builder.SetGlobalState(ExecutionContextData.IsWarmupRequest, true); + + /// + /// Registers an uploaded file with the operation request so that an Upload variable + /// referencing can be resolved through the + /// feature. + /// + /// + /// The operation request builder. + /// + /// + /// The multipart request map key that identifies the file. + /// + /// + /// The file to associate with . + /// + /// + /// The same for configuration chaining. + /// + /// + /// or is null. + /// + /// + /// is null or empty. + /// + public static OperationRequestBuilder AddFile( + this OperationRequestBuilder builder, + string name, + IFile file) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(file); + + var lookup = builder.Features.GetOrSet( + static () => new OperationRequestFileLookup()); + + if (lookup is not OperationRequestFileLookup mutable) + { + throw new InvalidOperationException( + "An IFileLookup feature has already been set on this request " + + "and cannot be extended."); + } + + mutable.Add(name, file); + return builder; + } } diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationRequestFileLookup.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationRequestFileLookup.cs new file mode 100644 index 00000000000..69e4baff2ab --- /dev/null +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/OperationRequestFileLookup.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Types; + +namespace HotChocolate.Execution; + +internal sealed class OperationRequestFileLookup : IFileLookup +{ + private readonly Dictionary _files = []; + + public void Add(string name, IFile file) + { + _files[name] = file; + } + + public bool TryGetFile(string name, [NotNullWhen(true)] out IFile? file) + => _files.TryGetValue(name, out file); +} diff --git a/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs new file mode 100644 index 00000000000..464e58d0736 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs @@ -0,0 +1,144 @@ +using HotChocolate.Features; +using HotChocolate.Types; + +namespace HotChocolate.Execution; + +public class OperationRequestBuilderExtensionsTests +{ + [Fact] + public void AddFile_Should_RegisterFileInLookup_When_NamedFileProvided() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + var file = new TestFile("a.txt"); + + // act + builder.AddFile("variables.file", file); + + // assert + var lookup = builder.Features.Get(); + Assert.NotNull(lookup); + Assert.True(lookup.TryGetFile("variables.file", out var resolved)); + Assert.Same(file, resolved); + } + + [Fact] + public void AddFile_Should_RegisterAllFiles_When_CalledMultipleTimes() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + var first = new TestFile("a.txt"); + var second = new TestFile("b.txt"); + + // act + builder.AddFile("0", first).AddFile("1", second); + + // assert + var lookup = builder.Features.GetRequired(); + Assert.True(lookup.TryGetFile("0", out var resolvedFirst)); + Assert.True(lookup.TryGetFile("1", out var resolvedSecond)); + Assert.Same(first, resolvedFirst); + Assert.Same(second, resolvedSecond); + } + + [Fact] + public void AddFile_Should_ReuseSingleLookupFeature_When_CalledMultipleTimes() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + + // act + builder + .AddFile("0", new TestFile("a.txt")) + .AddFile("1", new TestFile("b.txt")); + + // assert + var lookupAfterFirst = builder.Features.Get(); + builder.AddFile("2", new TestFile("c.txt")); + var lookupAfterThird = builder.Features.Get(); + Assert.Same(lookupAfterFirst, lookupAfterThird); + } + + [Fact] + public void AddFile_Should_OverwriteEntry_When_SameNameUsedTwice() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + var initial = new TestFile("a.txt"); + var replacement = new TestFile("b.txt"); + + // act + builder.AddFile("file", initial).AddFile("file", replacement); + + // assert + var lookup = builder.Features.GetRequired(); + Assert.True(lookup.TryGetFile("file", out var resolved)); + Assert.Same(replacement, resolved); + } + + [Fact] + public void AddFile_Should_Throw_When_BuilderIsNull() + { + // arrange + OperationRequestBuilder builder = null!; + + // act + void Act() => builder.AddFile("file", new TestFile("a.txt")); + + // assert + Assert.Throws(Act); + } + + [Fact] + public void AddFile_Should_Throw_When_FileIsNull() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + + // act + void Act() => builder.AddFile("file", null!); + + // assert + Assert.Throws(Act); + } + + [Fact] + public void AddFile_Should_Throw_When_NameIsNull() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + + // act + void Act() => builder.AddFile(null!, new TestFile("a.txt")); + + // assert + Assert.Throws(Act); + } + + [Fact] + public void AddFile_Should_Throw_When_NameIsEmpty() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + + // act + void Act() => builder.AddFile(string.Empty, new TestFile("a.txt")); + + // assert + Assert.Throws(Act); + } + + private sealed class TestFile(string name) : IFile + { + public string Name { get; } = name; + + public long? Length => null; + + public string? ContentType => null; + + public Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Stream OpenReadStream() => Stream.Null; + } +} diff --git a/src/HotChocolate/Testing/Directory.Build.props b/src/HotChocolate/Testing/Directory.Build.props deleted file mode 100644 index 106b12775b2..00000000000 --- a/src/HotChocolate/Testing/Directory.Build.props +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/HotChocolate/Testing/src/Directory.Build.props b/src/HotChocolate/Testing/src/Directory.Build.props deleted file mode 100644 index db20bbe5a51..00000000000 --- a/src/HotChocolate/Testing/src/Directory.Build.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - - true - - - diff --git a/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj b/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj deleted file mode 100644 index 842390f449e..00000000000 --- a/src/HotChocolate/Testing/src/Testing/HotChocolate.Testing.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - HotChocolate.Testing - HotChocolate.Testing - HotChocolate.Testing - Testing helpers for Hot Chocolate. - - - - - - - diff --git a/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs b/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs deleted file mode 100644 index 5eefad717b3..00000000000 --- a/src/HotChocolate/Testing/src/Testing/OperationRequestBuilderExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using HotChocolate.AspNetCore; -using HotChocolate.Execution; -using HotChocolate.Types; - -namespace HotChocolate.Testing; - -/// -/// Testing extensions for . -/// -public static class OperationRequestBuilderExtensions -{ - /// - /// Registers a set of uploaded files with the operation request so that - /// Upload variables can be resolved through an feature. - /// - /// - /// The operation request builder. - /// - /// - /// A map of multipart request keys to instances. - /// - /// - /// The same for configuration chaining. - /// - /// - /// or is null. - /// - public static OperationRequestBuilder SetFiles( - this OperationRequestBuilder builder, - Dictionary fileMap) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(fileMap); - - builder.Features.Set(new FormFileLookup(fileMap)); - return builder; - } -} From 61369a6064c6294a4fb9d0820f5b20da2b2d7b6b Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 26 May 2026 11:50:24 +0200 Subject: [PATCH 3/4] Update migration guide --- .../v16/migrating/migrate-from-15-to-16.md | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index 15b009b172d..8aa7272c122 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -646,30 +646,19 @@ If you hit a mismatch, you have two options: 1. Provide variables as raw JSON through `SetVariableValues(string)`, bypassing CLR serialization entirely. 2. Register a custom `JsonConverter` for the affected type so the emitted JSON matches the scalar's expected format. -If you need to pass an Upload scalar value, you can do the following: +If you need to pass an `Upload` scalar value, register the file on the builder via `AddFile` and reference it from your variables by the same key: ```csharp -var requestBuilder = new OperationRequestBuilder(); -requestBuilder.SetVariableValues("""{ "file" : "yourKey" }"""); -requestBuilder.Features.Set(fileLookup); +var file = new StreamFile("Foo.txt", () => new MemoryStream(/* your bytes */)); -public class FileLookup : IFileLookup -{ - public bool TryGetFile(string name, [NotNullWhen(true)] out IFile? file) - { - if (name == "yourKey") - { - file = new StreamFile("Foo.txt", () => new MemoryStream()); - return true; - } - - file = null; - return false; - } -} +var request = OperationRequestBuilder.New() + .SetDocument("mutation ($file: Upload!) { upload(file: $file) }") + .SetVariableValues("""{ "file": "yourKey" }""") + .AddFile("yourKey", file) + .Build(); ``` -`yourKey` in this case is just a marker you choose for the file. +`yourKey` is just a marker you choose to correlate the variable value with the file. Call `AddFile` multiple times to register additional files on the same request. **Global state methods** @@ -688,7 +677,7 @@ Use `OperationRequestBuilder.From(request)` to create a builder pre-populated fr **Features collection** -The builder now exposes a `Features` property of type `IFeatureCollection` for attaching extensibility features (such as `IFileLookup` for file uploads). +The builder now exposes a `Features` property of type `IFeatureCollection` for attaching extensibility features. ## Snapshot matching on IExecutionResult From 4091f9fde14ec39ca1b1f6ee0c3ff7a2d9e73e3e Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 26 May 2026 12:01:51 +0200 Subject: [PATCH 4/4] Cleanup --- .../OperationRequestBuilderExtensions.cs | 9 +++++-- .../OperationRequestBuilderExtensionsTests.cs | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs index 18daff32ce8..9fdb077a4b2 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/Extensions/OperationRequestBuilderExtensions.cs @@ -42,10 +42,15 @@ public static OperationRequestBuilder MarkAsWarmupRequest( /// The same for configuration chaining. /// /// - /// or is null. + /// , , or + /// is null. /// /// - /// is null or empty. + /// is empty. + /// + /// + /// An feature has already been set on the builder + /// and cannot be extended. /// public static OperationRequestBuilder AddFile( this OperationRequestBuilder builder, diff --git a/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs index 464e58d0736..7359b04e125 100644 --- a/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs +++ b/src/HotChocolate/Core/test/Execution.Abstractions.Tests/Execution/OperationRequestBuilderExtensionsTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using HotChocolate.Features; using HotChocolate.Types; @@ -128,6 +129,29 @@ public void AddFile_Should_Throw_When_NameIsEmpty() Assert.Throws(Act); } + [Fact] + public void AddFile_Should_Throw_When_DifferentFileLookupAlreadySet() + { + // arrange + var builder = OperationRequestBuilder.New().SetDocument("{ foo }"); + builder.Features.Set(new ForeignFileLookup()); + + // act + void Act() => builder.AddFile("file", new TestFile("a.txt")); + + // assert + Assert.Throws(Act); + } + + private sealed class ForeignFileLookup : IFileLookup + { + public bool TryGetFile(string name, [NotNullWhen(true)] out IFile? file) + { + file = null; + return false; + } + } + private sealed class TestFile(string name) : IFile { public string Name { get; } = name;