Skip to content

Commit 8aa369d

Browse files
committed
[#327] [add] impl
1 parent bfad8dc commit 8aa369d

6 files changed

Lines changed: 248 additions & 33 deletions

File tree

src/Simplify.Web.Tests/Responses/FileTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Simplify.Web.Http.ResponseWriting;
66
using Simplify.Web.Modules.Context;
77
using Simplify.Web.Responses;
8+
using Stream = System.IO.Stream;
89

910
namespace Simplify.Web.Tests.Responses;
1011

@@ -49,4 +50,58 @@ public async Task Process_NormalData_FileSent()
4950
_context.VerifySet(x => x.Response.ContentType = "application/example");
5051
_responseWriter.Verify(x => x.WriteAsync(It.IsAny<HttpResponse>(), It.Is<byte[]>(d => d == data)));
5152
}
53+
54+
[Test]
55+
public async Task Process_InlineBytesWithCachingHeaders_HeadersSetAndBytesSent()
56+
{
57+
// Arrange
58+
59+
var data = "\r"u8.ToArray();
60+
var file = new Mock<File>(data, "application/example", "Foo.txt",
61+
ContentDispositionType.Inline, "public, max-age=31536000, immutable", "\"ABC123\"", 200)
62+
{ CallBase = true };
63+
64+
file.SetupGet(x => x.Context).Returns(_context.Object);
65+
file.SetupGet(x => x.ResponseWriter).Returns(_responseWriter.Object);
66+
67+
// Act
68+
var result = await file.Object.ExecuteAsync();
69+
70+
// Assert
71+
72+
Assert.That(result, Is.EqualTo(ResponseBehavior.RawOutput));
73+
Assert.That(_headerDictionary["Content-Disposition"], Is.EqualTo("inline; filename=\"Foo.txt\""));
74+
Assert.That(_headerDictionary["Cache-Control"], Is.EqualTo("public, max-age=31536000, immutable"));
75+
Assert.That(_headerDictionary["ETag"], Is.EqualTo("\"ABC123\""));
76+
77+
_context.VerifySet(x => x.Response.ContentType = "application/example");
78+
_responseWriter.Verify(x => x.WriteAsync(It.IsAny<HttpResponse>(), It.Is<byte[]>(d => d == data)));
79+
}
80+
81+
[Test]
82+
public async Task Process_Stream_StreamSentAndDisposed()
83+
{
84+
// Arrange
85+
86+
var stream = new Mock<Stream> { CallBase = false };
87+
stream.SetupGet(x => x.CanRead).Returns(true);
88+
89+
var file = new Mock<File>(stream.Object, "application/example", null!,
90+
ContentDispositionType.Inline, null!, null!, 200)
91+
{ CallBase = true };
92+
93+
file.SetupGet(x => x.Context).Returns(_context.Object);
94+
file.SetupGet(x => x.ResponseWriter).Returns(_responseWriter.Object);
95+
96+
// Act
97+
var result = await file.Object.ExecuteAsync();
98+
99+
// Assert
100+
101+
Assert.That(result, Is.EqualTo(ResponseBehavior.RawOutput));
102+
Assert.That(_headerDictionary["Content-Disposition"], Is.EqualTo("inline"));
103+
104+
_responseWriter.Verify(x => x.WriteAsync(It.IsAny<HttpResponse>(), It.Is<Stream>(s => s == stream.Object)));
105+
stream.Verify(x => x.Close());
106+
}
52107
}

src/Simplify.Web/Http/ResponseWriting/IResponseWriter.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Threading.Tasks;
1+
using System.IO;
2+
using System.Threading.Tasks;
23
using Microsoft.AspNetCore.Http;
34

45
namespace Simplify.Web.Http.ResponseWriting;
@@ -21,4 +22,11 @@ public interface IResponseWriter
2122
/// <param name="response">The response.</param>
2223
/// <param name="data">The data.</param>
2324
Task WriteAsync(HttpResponse response, byte[] data);
25+
26+
/// <summary>
27+
/// Writes the specified stream to the response body asynchronously (copied from its current position).
28+
/// </summary>
29+
/// <param name="response">The response.</param>
30+
/// <param name="data">The data stream.</param>
31+
Task WriteAsync(HttpResponse response, Stream data);
2432
}

src/Simplify.Web/Http/ResponseWriting/ResponseWriter.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Threading.Tasks;
1+
using System.IO;
2+
using System.Threading.Tasks;
23
using Microsoft.AspNetCore.Http;
34

45
namespace Simplify.Web.Http.ResponseWriting;
@@ -22,4 +23,11 @@ public class ResponseWriter : IResponseWriter
2223
/// <param name="response">The response.</param>
2324
/// <param name="data">The data.</param>
2425
public Task WriteAsync(HttpResponse response, byte[] data) => response.Body.WriteAsync(data, 0, data.Length);
26+
27+
/// <summary>
28+
/// Writes the specified stream to the response body asynchronously (copied from its current position).
29+
/// </summary>
30+
/// <param name="response">The response.</param>
31+
/// <param name="data">The data stream.</param>
32+
public Task WriteAsync(HttpResponse response, Stream data) => data.CopyToAsync(response.Body);
2533
}

src/Simplify.Web/ResponseShortcutsControllerBase.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public abstract class ResponseShortcutsControllerBase : ActionModulesAccessor
3838
protected Created Created(string content, string contentType = "text/plain") => new(content, contentType);
3939

4040
/// <summary>
41-
/// Initializes a new instance of the <see cref="File" /> class.
41+
/// Initializes a new instance of the <see cref="Responses.File" /> class which sends the specified bytes as a downloadable attachment.
4242
/// </summary>
4343
/// <param name="outputFileName">The name of the file.</param>
4444
/// <param name="contentType">Type of the content.</param>
@@ -47,6 +47,39 @@ public abstract class ResponseShortcutsControllerBase : ActionModulesAccessor
4747
/// <exception cref="ArgumentNullException"></exception>
4848
protected File File(string outputFileName, string contentType, byte[] data, int statusCode = 200) => new(outputFileName, contentType, data, statusCode);
4949

50+
/// <summary>
51+
/// Initializes a new instance of the <see cref="Responses.File" /> class which sends the specified bytes with full control over disposition and caching.
52+
/// </summary>
53+
/// <param name="data">The data of the file.</param>
54+
/// <param name="contentType">Type of the content.</param>
55+
/// <param name="outputFileName">The name of the file (appended to the <c>Content-Disposition</c> header when specified).</param>
56+
/// <param name="disposition">The <c>Content-Disposition</c> type.</param>
57+
/// <param name="cacheControl">The <c>Cache-Control</c> header value (not sent when <see langword="null" />).</param>
58+
/// <param name="eTag">The <c>ETag</c> header value (not sent when <see langword="null" />).</param>
59+
/// <param name="statusCode">The HTTP response status code.</param>
60+
/// <exception cref="ArgumentNullException"></exception>
61+
protected File File(byte[] data, string contentType, string outputFileName = null,
62+
ContentDispositionType disposition = ContentDispositionType.Inline,
63+
string cacheControl = null, string eTag = null, int statusCode = 200) =>
64+
new(data, contentType, outputFileName, disposition, cacheControl, eTag, statusCode);
65+
66+
/// <summary>
67+
/// Initializes a new instance of the <see cref="Responses.File" /> class which streams the specified stream with full control over disposition and caching.
68+
/// The <paramref name="dataStream" /> is disposed once it has been written to the response.
69+
/// </summary>
70+
/// <param name="dataStream">The stream of the file.</param>
71+
/// <param name="contentType">Type of the content.</param>
72+
/// <param name="outputFileName">The name of the file (appended to the <c>Content-Disposition</c> header when specified).</param>
73+
/// <param name="disposition">The <c>Content-Disposition</c> type.</param>
74+
/// <param name="cacheControl">The <c>Cache-Control</c> header value (not sent when <see langword="null" />).</param>
75+
/// <param name="eTag">The <c>ETag</c> header value (not sent when <see langword="null" />).</param>
76+
/// <param name="statusCode">The HTTP response status code.</param>
77+
/// <exception cref="ArgumentNullException"></exception>
78+
protected File File(global::System.IO.Stream dataStream, string contentType, string outputFileName = null,
79+
ContentDispositionType disposition = ContentDispositionType.Inline,
80+
string cacheControl = null, string eTag = null, int statusCode = 200) =>
81+
new(dataStream, contentType, outputFileName, disposition, cacheControl, eTag, statusCode);
82+
5083
/// <summary>
5184
/// Initializes a new instance of the <see cref="Responses.InlineTpl" /> class.
5285
/// </summary>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Simplify.Web.Responses;
2+
3+
/// <summary>
4+
/// Represents the HTTP <c>Content-Disposition</c> type used by the <see cref="File" /> response.
5+
/// </summary>
6+
public enum ContentDispositionType
7+
{
8+
/// <summary>
9+
/// The file should be downloaded as an attachment (<c>attachment; filename="..."</c>).
10+
/// </summary>
11+
Attachment,
12+
13+
/// <summary>
14+
/// The file should be displayed inline by the client (<c>inline; filename="..."</c>).
15+
/// </summary>
16+
Inline
17+
}

src/Simplify.Web/Responses/File.cs

Lines changed: 124 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,161 @@
11
using System;
2+
using System.IO;
23
using System.Threading.Tasks;
3-
using Microsoft.AspNetCore.Http;
44

55
namespace Simplify.Web.Responses;
66

77
/// <summary>
88
/// Provides the HTTP file response (sends file to HTTP response).
99
/// </summary>
1010
/// <seealso cref="ControllerResponse" />
11-
/// <remarks>
12-
/// Initializes a new instance of the <see cref="File" /> class.
13-
/// </remarks>
14-
/// <param name="outputFileName">The name of the file.</param>
15-
/// <param name="contentType">Type of the content.</param>
16-
/// <param name="data">The data of the file.</param>
17-
/// <param name="statusCode">The HTTP response status code.</param>
18-
/// <exception cref="ArgumentNullException"></exception>
19-
public class File(string outputFileName, string contentType, byte[] data, int statusCode = 200) : ControllerResponse
11+
public class File : ControllerResponse
2012
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="File" /> class which sends the specified bytes as a downloadable attachment.
15+
/// </summary>
16+
/// <param name="outputFileName">The name of the file.</param>
17+
/// <param name="contentType">Type of the content.</param>
18+
/// <param name="data">The data of the file.</param>
19+
/// <param name="statusCode">The HTTP response status code.</param>
20+
/// <exception cref="ArgumentNullException"></exception>
21+
public File(string outputFileName, string contentType, byte[] data, int statusCode = 200)
22+
{
23+
OutputFileName = outputFileName ?? throw new ArgumentNullException(nameof(outputFileName));
24+
ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType));
25+
Data = data ?? throw new ArgumentNullException(nameof(data));
26+
StatusCode = statusCode;
27+
Disposition = ContentDispositionType.Attachment;
28+
}
29+
30+
/// <summary>
31+
/// Initializes a new instance of the <see cref="File" /> class which sends the specified bytes with full control over disposition and caching.
32+
/// </summary>
33+
/// <param name="data">The data of the file.</param>
34+
/// <param name="contentType">Type of the content.</param>
35+
/// <param name="outputFileName">The name of the file (appended to the <c>Content-Disposition</c> header when specified).</param>
36+
/// <param name="disposition">The <c>Content-Disposition</c> type.</param>
37+
/// <param name="cacheControl">The <c>Cache-Control</c> header value (not sent when <see langword="null" />).</param>
38+
/// <param name="eTag">The <c>ETag</c> header value (not sent when <see langword="null" />).</param>
39+
/// <param name="statusCode">The HTTP response status code.</param>
40+
/// <exception cref="ArgumentNullException"></exception>
41+
public File(byte[] data, string contentType, string? outputFileName = null,
42+
ContentDispositionType disposition = ContentDispositionType.Inline,
43+
string? cacheControl = null, string? eTag = null, int statusCode = 200)
44+
{
45+
Data = data ?? throw new ArgumentNullException(nameof(data));
46+
ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType));
47+
OutputFileName = outputFileName;
48+
Disposition = disposition;
49+
CacheControl = cacheControl;
50+
ETag = eTag;
51+
StatusCode = statusCode;
52+
}
53+
54+
/// <summary>
55+
/// Initializes a new instance of the <see cref="File" /> class which streams the specified stream with full control over disposition and caching.
56+
/// The <paramref name="dataStream" /> is read from its current position and disposed once it has been written to the response.
57+
/// </summary>
58+
/// <param name="dataStream">The stream of the file.</param>
59+
/// <param name="contentType">Type of the content.</param>
60+
/// <param name="outputFileName">The name of the file (appended to the <c>Content-Disposition</c> header when specified).</param>
61+
/// <param name="disposition">The <c>Content-Disposition</c> type.</param>
62+
/// <param name="cacheControl">The <c>Cache-Control</c> header value (not sent when <see langword="null" />).</param>
63+
/// <param name="eTag">The <c>ETag</c> header value (not sent when <see langword="null" />).</param>
64+
/// <param name="statusCode">The HTTP response status code.</param>
65+
/// <exception cref="ArgumentNullException"></exception>
66+
public File(Stream dataStream, string contentType, string? outputFileName = null,
67+
ContentDispositionType disposition = ContentDispositionType.Inline,
68+
string? cacheControl = null, string? eTag = null, int statusCode = 200)
69+
{
70+
DataStream = dataStream ?? throw new ArgumentNullException(nameof(dataStream));
71+
ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType));
72+
OutputFileName = outputFileName;
73+
Disposition = disposition;
74+
CacheControl = cacheControl;
75+
ETag = eTag;
76+
StatusCode = statusCode;
77+
}
78+
2179
/// <summary>
2280
/// Gets the name of the output file.
2381
/// </summary>
24-
/// <value>
25-
/// The name of the output file.
26-
/// </value>
27-
public string OutputFileName { get; } = outputFileName ?? throw new ArgumentNullException(nameof(outputFileName));
82+
public string? OutputFileName { get; }
2883

2984
/// <summary>
3085
/// Gets the type of the content.
3186
/// </summary>
32-
/// <value>
33-
/// The type of the content.
34-
/// </value>
35-
public string ContentType { get; } = contentType ?? throw new ArgumentNullException(nameof(contentType));
87+
public string ContentType { get; }
3688

3789
/// <summary>
3890
/// Gets the data.
3991
/// </summary>
40-
/// <value>
41-
/// The data.
42-
/// </value>
43-
public byte[] Data { get; } = data ?? throw new ArgumentNullException(nameof(data));
92+
public byte[]? Data { get; }
93+
94+
/// <summary>
95+
/// Gets the data stream.
96+
/// </summary>
97+
public Stream? DataStream { get; }
98+
99+
/// <summary>
100+
/// Gets the <c>Content-Disposition</c> type.
101+
/// </summary>
102+
public ContentDispositionType Disposition { get; }
103+
104+
/// <summary>
105+
/// Gets the <c>Cache-Control</c> header value.
106+
/// </summary>
107+
public string? CacheControl { get; }
108+
109+
/// <summary>
110+
/// Gets the <c>ETag</c> header value.
111+
/// </summary>
112+
public string? ETag { get; }
44113

45114
/// <summary>
46115
/// Gets the HTTP response status code.
47116
/// </summary>
48-
/// <value>
49-
/// The HTTP response status code.
50-
/// </value>
51-
public int StatusCode { get; set; } = statusCode;
117+
public int StatusCode { get; set; }
52118

53119
/// <summary>
54120
/// Executes this response asynchronously.
55121
/// </summary>
56122
public override async Task<ResponseBehavior> ExecuteAsync()
57123
{
58124
Context.Response.StatusCode = StatusCode;
59-
60-
Context.Response.Headers.Append("Content-Disposition", "attachment; filename=\"" + OutputFileName + "\"");
61125
Context.Response.ContentType = ContentType;
126+
Context.Response.Headers["Content-Disposition"] = BuildContentDisposition();
127+
128+
if (CacheControl != null)
129+
Context.Response.Headers["Cache-Control"] = CacheControl;
130+
131+
if (ETag != null)
132+
Context.Response.Headers["ETag"] = ETag;
62133

63-
await ResponseWriter.WriteAsync(Context.Response, Data);
134+
if (DataStream != null)
135+
{
136+
try
137+
{
138+
await ResponseWriter.WriteAsync(Context.Response, DataStream);
139+
}
140+
finally
141+
{
142+
DataStream.Dispose();
143+
}
144+
}
145+
else
146+
{
147+
await ResponseWriter.WriteAsync(Context.Response, Data!);
148+
}
64149

65150
return ResponseBehavior.RawOutput;
66151
}
67-
}
152+
153+
private string BuildContentDisposition()
154+
{
155+
var type = Disposition == ContentDispositionType.Attachment ? "attachment" : "inline";
156+
157+
return OutputFileName != null
158+
? $"{type}; filename=\"{OutputFileName}\""
159+
: type;
160+
}
161+
}

0 commit comments

Comments
 (0)