diff --git a/src/HotChocolate/AspNetCore/src/Transport.Abstractions/FileReference.cs b/src/HotChocolate/AspNetCore/src/Transport.Abstractions/FileReference.cs index 6c458f5702d..36b044dea12 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Abstractions/FileReference.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Abstractions/FileReference.cs @@ -10,6 +10,12 @@ public sealed class FileReference { private readonly Func _openRead; + /// + public FileReference(Stream stream, string fileName) + : this(stream, fileName, null) + { + } + /// /// Creates a new instance of /// @@ -19,13 +25,16 @@ public sealed class FileReference /// /// The file name. /// + /// + /// The file content type. + /// /// /// is null. /// /// /// is null, empty or white space. /// - public FileReference(Stream stream, string fileName) + public FileReference(Stream stream, string fileName, string? contentType) { ArgumentNullException.ThrowIfNull(stream); @@ -38,6 +47,13 @@ public FileReference(Stream stream, string fileName) _openRead = () => stream; FileName = fileName; + ContentType = contentType; + } + + /// + public FileReference(Func openRead, string fileName) + : this(openRead, fileName, null) + { } /// @@ -49,13 +65,16 @@ public FileReference(Stream stream, string fileName) /// /// The file name. /// + /// + /// The file content type. + /// /// /// is null, empty or white space. /// /// /// is null. /// - public FileReference(Func openRead, string fileName) + public FileReference(Func openRead, string fileName, string? contentType) { if (string.IsNullOrWhiteSpace(fileName)) { @@ -66,13 +85,19 @@ public FileReference(Func openRead, string fileName) _openRead = openRead ?? throw new ArgumentNullException(nameof(openRead)); FileName = fileName; + ContentType = contentType; } /// - /// The file name eg. foo.txt. + /// The file name e.g. "foo.txt". /// public string FileName { get; } + /// + /// The content type of the file e.g. "application/pdf". + /// + public string? ContentType { get; } + /// /// Opens the file stream. /// diff --git a/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs b/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs index 77196d02e2c..fc13218585c 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs @@ -215,8 +215,13 @@ private static HttpContent CreateMultipartContent( foreach (var fileInfo in fileInfos) { - var file = new StreamContent(fileInfo.File.OpenRead()); - form.Add(file, fileInfo.Name, fileInfo.File.FileName); + var fileContent = new StreamContent(fileInfo.File.OpenRead()); + if (!string.IsNullOrEmpty(fileInfo.File.ContentType)) + { + fileContent.Headers.ContentType = new MediaTypeHeaderValue(fileInfo.File.ContentType); + } + + form.Add(fileContent, fileInfo.Name, fileInfo.File.FileName); } return form; diff --git a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientTests.cs b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientTests.cs index 02e18271606..a3f9b952aea 100644 --- a/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientTests.cs +++ b/src/HotChocolate/AspNetCore/test/Transport.Http.Tests/GraphQLHttpClientTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text; using System.Text.Json; using HotChocolate.AspNetCore.Tests.Utilities; using HotChocolate.Language; @@ -881,13 +882,19 @@ public async Task Get_Subscription_Over_SSE_With_Errors() await snapshot.MatchMarkdownAsync(cts.Token); } - [Fact] - public async Task Post_GraphQL_FileUpload() + [Theory] + [InlineData((string?)null)] + [InlineData("application/pdf")] + public async Task Post_GraphQL_FileUpload(string? contentType) { // arrange using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5000)); - using var testServer = CreateStarWarsServer(); - var httpClient = testServer.CreateClient(); + var server = CreateStarWarsServer( + configureServices: s => s + .AddGraphQLServer("test") + .AddType() + .AddQueryType()); + var httpClient = server.CreateClient(); var client = new DefaultGraphQLHttpClient(httpClient); var stream = new MemoryStream("abc"u8.ToArray()); @@ -895,15 +902,19 @@ public async Task Post_GraphQL_FileUpload() var operation = new OperationRequest( """ query ($upload: Upload!) { - singleUpload(file: $upload) + singleInfoUpload(file: $upload) { + name + content + contentType + } } """, variables: new Dictionary { - ["upload"] = new FileReference(() => stream, "test.txt") + ["upload"] = new FileReference(() => stream, "test.txt", contentType) }); - var requestUri = new Uri(CreateUrl("/upload")); + var requestUri = new Uri(CreateUrl("/test")); var request = new GraphQLHttpRequest(operation, requestUri) { @@ -917,10 +928,14 @@ public async Task Post_GraphQL_FileUpload() // assert using var body = await response.ReadAsResultAsync(cts.Token); body.MatchInlineSnapshot( - """ + $$$""" { "data": { - "singleUpload": "abc" + "singleInfoUpload": { + "name": "test.txt", + "content": "abc", + "contentType": "{{{contentType}}}" + } } } """); @@ -1006,4 +1021,26 @@ public async IAsyncEnumerable CreateStream() public string OnError([EventMessage] string message) => message; } + + public class UploadTestQuery + { + public async Task SingleInfoUpload(IFile file) + { + await using var stream = file.OpenReadStream(); + using var sr = new StreamReader(stream, Encoding.UTF8); + return new FileInfoOutput + { + Content = await sr.ReadToEndAsync(), + ContentType = file.ContentType ?? string.Empty, + Name = file.Name + }; + } + + public class FileInfoOutput + { + public string? Content { get; init; } + public string? ContentType { get; init; } + public string? Name { get; init; } + } + } } diff --git a/src/StrawberryShake/Client/src/Core/Upload.cs b/src/StrawberryShake/Client/src/Core/Upload.cs index 92e2c670506..4108b4e8822 100644 --- a/src/StrawberryShake/Client/src/Core/Upload.cs +++ b/src/StrawberryShake/Client/src/Core/Upload.cs @@ -5,13 +5,20 @@ namespace StrawberryShake; /// public readonly struct Upload { + /// + public Upload(Stream content, string fileName) + : this(content, fileName, null) + { + } + /// - /// Creates a new instance of Upload + /// Creates a new instance of the Upload-scalar. /// - public Upload(Stream content, string fileName) + public Upload(Stream content, string fileName, string? contentType) { Content = content; FileName = fileName; + ContentType = contentType; } /// @@ -23,4 +30,12 @@ public Upload(Stream content, string fileName) /// The name of the file /// public string FileName { get; } + + /// + /// The optional MIME type of the file. + /// + /// + /// If specified, this value is applied as the HTTP Content-Type header. + /// + public string? ContentType { get; } } diff --git a/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs b/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs index 25ab30e9f07..4bbd073ce13 100644 --- a/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs +++ b/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs @@ -241,7 +241,7 @@ private static void MapVariables(List variables) { case Dictionary result: result[currentPath] = - new FileReference(upload.Value.Content, upload.Value.FileName); + new FileReference(upload.Value.Content, upload.Value.FileName, upload.Value.ContentType); break; case List array: @@ -258,7 +258,7 @@ private static void MapVariables(List variables) } array[arrayIndex] = - new FileReference(upload.Value.Content, upload.Value.FileName); + new FileReference(upload.Value.Content, upload.Value.FileName, upload.Value.ContentType); break; diff --git a/src/StrawberryShake/Client/src/Transport.InMemory/DependencyInjection/InMemoryClient.cs b/src/StrawberryShake/Client/src/Transport.InMemory/DependencyInjection/InMemoryClient.cs index 8740fd55577..32c29a389fb 100644 --- a/src/StrawberryShake/Client/src/Transport.InMemory/DependencyInjection/InMemoryClient.cs +++ b/src/StrawberryShake/Client/src/Transport.InMemory/DependencyInjection/InMemoryClient.cs @@ -129,7 +129,7 @@ await interceptor default: if (fileValue is Upload upload) { - return new StreamFile(upload.FileName, () => upload.Content); + return new StreamFile(upload.FileName, () => upload.Content, null, upload.ContentType); } return variables; diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalarTest.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalarTest.cs index 28765ded0db..0399ac93649 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalarTest.cs +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalarTest.cs @@ -12,8 +12,10 @@ public UploadScalarTest(TestServerFactory serverFactory) : base(serverFactory) { } - [Fact] - public async Task Execute_UploadScalar_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalar_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -24,7 +26,7 @@ public async Task Execute_UploadScalar_Argument() // act var result = await client.TestUpload.ExecuteAsync( "foo", - new Upload(data, "test-file"), + new Upload(data, "test-file", contentType), null, null, null, @@ -33,11 +35,13 @@ public async Task Execute_UploadScalar_Argument() cancellationToken: ct); // assert - Assert.Equal("test-file:a", result.Data!.Upload); + Assert.Equal($"[test-file:a|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_UploadScalarList_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalarList_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -50,7 +54,7 @@ public async Task Execute_UploadScalarList_Argument() var result = await client.TestUpload.ExecuteAsync( "foo", null, - new Upload?[] { new Upload(dataA, "A"), new Upload(dataB, "B") }, + new Upload?[] { new Upload(dataA, "A", contentType), new Upload(dataB, "B", contentType) }, null, null, null, @@ -58,11 +62,13 @@ public async Task Execute_UploadScalarList_Argument() cancellationToken: ct); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_UploadScalarNested_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalarNested_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -76,18 +82,20 @@ public async Task Execute_UploadScalarNested_Argument() "foo", null, null, - new[] { new Upload?[] { new Upload(dataA, "A"), new Upload(dataB, "B") } }, + new[] { new Upload?[] { new Upload(dataA, "A", contentType), new Upload(dataB, "B", contentType) } }, null, null, null, cancellationToken: ct); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_Input_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_Input_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -105,7 +113,7 @@ public async Task Execute_Input_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(data, "test-file") } + Baz = new BazInput() { File = new Upload(data, "test-file", contentType) } } }, null, @@ -113,11 +121,13 @@ public async Task Execute_Input_Argument() cancellationToken: ct); // assert - Assert.Equal("test-file:a", result.Data!.Upload); + Assert.Equal($"[test-file:a|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_InputList_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_InputList_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -138,14 +148,14 @@ public async Task Execute_InputList_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataA, "A") } + Baz = new BazInput() { File = new Upload(dataA, "A", contentType) } } }, new TestInput() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataB, "B") } + Baz = new BazInput() { File = new Upload(dataB, "B", contentType) } } } }, @@ -153,11 +163,13 @@ public async Task Execute_InputList_Argument() cancellationToken: ct); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_InputNested_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_InputNested_Argument(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -182,14 +194,14 @@ public async Task Execute_InputNested_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataA, "A") } + Baz = new BazInput() { File = new Upload(dataA, "A", contentType) } } }, new TestInput() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataB, "B") } + Baz = new BazInput() { File = new Upload(dataB, "B", contentType) } } } } @@ -197,7 +209,7 @@ public async Task Execute_InputNested_Argument() cancellationToken: ct); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } public static UploadScalarClient CreateClient(IWebHost host, int port) @@ -218,8 +230,10 @@ public static UploadScalarClient CreateClient(IWebHost host, int port) return services.GetRequiredService(); } - [Fact] - public async Task Execute_ListWorksWithNull() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_ListWorksWithNull(string? contentType) { // arrange var ct = new CancellationTokenSource(20_000).Token; @@ -232,7 +246,7 @@ public async Task Execute_ListWorksWithNull() var result = await client.TestUpload.ExecuteAsync( "foo", null, - new Upload?[] { new Upload(dataA, "A"), null, new Upload(dataB, "B") }, + new Upload?[] { new Upload(dataA, "A", contentType), null, new Upload(dataB, "B", contentType) }, null, null, null, @@ -240,6 +254,6 @@ public async Task Execute_ListWorksWithNull() cancellationToken: ct); // assert - Assert.Equal("A:a,null,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[|],[B:b|{contentType}]", result.Data!.Upload); } } diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalar_InMemoryTest.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalar_InMemoryTest.cs index d0c1f81b363..6e00e0c19aa 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalar_InMemoryTest.cs +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadScalar_InMemoryTest.cs @@ -10,8 +10,10 @@ public UploadScalarInMemoryTest(TestServerFactory serverFactory) : base(serverFa { } - [Fact] - public async Task Execute_UploadScalar_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalar_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -20,7 +22,7 @@ public async Task Execute_UploadScalar_Argument() // act var result = await client.TestUpload.ExecuteAsync( "foo", - new Upload(data, "test-file"), + new Upload(data, "test-file", contentType), null, null, null, @@ -28,11 +30,13 @@ public async Task Execute_UploadScalar_Argument() null); // assert - Assert.Equal("test-file:a", result.Data!.Upload); + Assert.Equal($"[test-file:a|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_UploadScalarList_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalarList_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -43,18 +47,20 @@ public async Task Execute_UploadScalarList_Argument() var result = await client.TestUpload.ExecuteAsync( "foo", null, - new Upload?[] { new Upload(dataA, "A"), new Upload(dataB, "B") }, + new Upload?[] { new Upload(dataA, "A", contentType), new Upload(dataB, "B", contentType) }, null, null, null, null); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_UploadScalarNested_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_UploadScalarNested_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -66,17 +72,19 @@ public async Task Execute_UploadScalarNested_Argument() "foo", null, null, - new[] { new Upload?[] { new Upload(dataA, "A"), new Upload(dataB, "B") } }, + new[] { new Upload?[] { new Upload(dataA, "A", contentType), new Upload(dataB, "B", contentType) } }, null, null, null); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_Input_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_Input_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -92,18 +100,20 @@ public async Task Execute_Input_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(data, "test-file") } + Baz = new BazInput() { File = new Upload(data, "test-file", contentType) } } }, null, null); // assert - Assert.Equal("test-file:a", result.Data!.Upload); + Assert.Equal($"[test-file:a|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_InputList_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_InputList_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -122,25 +132,27 @@ public async Task Execute_InputList_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataA, "A") } + Baz = new BazInput() { File = new Upload(dataA, "A", contentType) } } }, new TestInput() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataB, "B") } + Baz = new BazInput() { File = new Upload(dataB, "B", contentType) } } } }, null); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } - [Fact] - public async Task Execute_InputNested_Argument() + [Theory] + [InlineData(null)] + [InlineData("application/pdf")] + public async Task Execute_InputNested_Argument(string? contentType) { // arrange var client = CreateClient(); @@ -163,21 +175,21 @@ public async Task Execute_InputNested_Argument() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataA, "A") } + Baz = new BazInput() { File = new Upload(dataA, "A", contentType) } } }, new TestInput() { Bar = new BarInput() { - Baz = new BazInput() { File = new Upload(dataB, "B") } + Baz = new BazInput() { File = new Upload(dataB, "B", contentType) } } } } }); // assert - Assert.Equal("A:a,B:b", result.Data!.Upload); + Assert.Equal($"[A:a|{contentType}],[B:b|{contentType}]", result.Data!.Upload); } public static UploadScalar_InMemoryClient CreateClient() diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadSchemaHelpers.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadSchemaHelpers.cs index 09bd62d2af1..0d12fbec085 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadSchemaHelpers.cs +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/UploadSchemaHelpers.cs @@ -42,40 +42,43 @@ public string Upload( { if (single is not null) { - return single.ReadContents(); + return Format(single); } if (list is not null) { - return string.Join(",", list.Select(x => x?.ReadContents() ?? "null")); + return string.Join(",", list.Select(Format)); } if (nested is not null) { return string.Join(",", - nested.SelectMany(y => y!.Select(x => x?.ReadContents() ?? "null"))); + nested.SelectMany(y => y!.Select(Format))); } if (objectSingle is not null) { - return objectSingle.Bar!.Baz!.File!.ReadContents(); + return Format(objectSingle.Bar!.Baz!.File); } if (objectList is not null) { return string.Join(",", - objectList.Select(x => x?.Bar!.Baz!.File!.ReadContents() ?? "null")); + objectList.Select(x => Format(x?.Bar!.Baz!.File))); } if (objectNested is not null) { return string.Join(",", objectNested.SelectMany(y - => y!.Select(x => x?.Bar!.Baz!.File!.ReadContents() ?? "null"))); + => y!.Select(x => Format(x?.Bar!.Baz!.File)))); } return "error"; } + + private static string Format(IFile? file) => + $"[{file?.ReadContents()}|{file?.ContentType}]"; } public record Test(Bar? Bar);