Skip to content
This repository was archived by the owner on Sep 3, 2024. It is now read-only.

Commit fa0a786

Browse files
conditional requests
1 parent afbd675 commit fa0a786

16 files changed

Lines changed: 367 additions & 19 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
namespace SqlStreamStore.HAL.Tests
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Threading.Tasks;
8+
using Shouldly;
9+
using SqlStreamStore.Streams;
10+
using Xunit;
11+
12+
public class ConditionalRequestTests : IDisposable
13+
{
14+
private readonly SqlStreamStoreHalMiddlewareFixture _fixture;
15+
16+
public ConditionalRequestTests()
17+
{
18+
_fixture = new SqlStreamStoreHalMiddlewareFixture(true);
19+
}
20+
21+
public static IEnumerable<object[]> IfNoneMatchCases()
22+
{
23+
var message = new NewStreamMessage(Guid.NewGuid(), "type", "{}", "{}");
24+
const string streamId = "stream-1";
25+
yield return new object[]
26+
{
27+
"/stream",
28+
new Func<IStreamStore, Task>(
29+
streamStore => streamStore.AppendToStream(streamId, ExpectedVersion.NoStream, message)),
30+
};
31+
yield return new object[]
32+
{
33+
$"/streams/{streamId}",
34+
new Func<IStreamStore, Task>(
35+
streamStore => streamStore.AppendToStream(streamId, ExpectedVersion.NoStream, message))
36+
};
37+
yield return new object[]
38+
{
39+
$"/streams/{streamId}/metadata",
40+
new Func<IStreamStore, Task>(
41+
streamStore => streamStore.SetStreamMetadata(streamId, ExpectedVersion.NoStream, 1))
42+
};
43+
}
44+
45+
[Theory, MemberData(nameof(IfNoneMatchCases))]
46+
public async Task when_match(string path, Func<IStreamStore, Task> operation)
47+
{
48+
await operation(_fixture.StreamStore);
49+
50+
using(var unconditionalResponse = await _fixture.HttpClient.SendAsync(
51+
new HttpRequestMessage(HttpMethod.Get, path)))
52+
{
53+
unconditionalResponse.Headers.ETag.ShouldNotBeNull();
54+
55+
using(var conditionalResponse = await _fixture.HttpClient.SendAsync(
56+
new HttpRequestMessage(HttpMethod.Get, path)
57+
{
58+
Headers = { IfNoneMatch = { unconditionalResponse.Headers.ETag } }
59+
}))
60+
{
61+
conditionalResponse.StatusCode.ShouldBe(HttpStatusCode.NotModified);
62+
}
63+
}
64+
}
65+
66+
public void Dispose() => _fixture.Dispose();
67+
}
68+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
namespace SqlStreamStore.HAL.Tests
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
/// <summary>
12+
/// A delegating handler that handles HTTP redirects (301, 302, 303, 307, and 308).
13+
/// https://gist.github.com/joelverhagen/3be85bc0d5733756befa#file-redirectinghandler-cs
14+
/// </summary>
15+
internal class RedirectingHandler : DelegatingHandler
16+
{
17+
/// <summary>
18+
/// The property key used to access the list of responses.
19+
/// </summary>
20+
public const string HistoryPropertyKey = "Knapcode.Http.Handlers.RedirectingHandler.ResponseHistory";
21+
22+
private static readonly ISet<HttpStatusCode> s_redirectStatusCodes = new HashSet<HttpStatusCode>(new[]
23+
{
24+
HttpStatusCode.MovedPermanently,
25+
HttpStatusCode.Found,
26+
HttpStatusCode.SeeOther,
27+
HttpStatusCode.PermanentRedirect,
28+
});
29+
30+
private static readonly ISet<HttpStatusCode> s_keepRequestBodyRedirectStatusCodes = new HashSet<HttpStatusCode>(
31+
new[]
32+
{
33+
HttpStatusCode.TemporaryRedirect,
34+
HttpStatusCode.PermanentRedirect,
35+
});
36+
37+
38+
/// <summary>
39+
/// Initializes a new instance of the <see cref="RedirectingHandler"/> class.
40+
/// </summary>
41+
public RedirectingHandler()
42+
{
43+
AllowAutoRedirect = true;
44+
MaxAutomaticRedirections = 50;
45+
DisableInnerAutoRedirect = true;
46+
DownloadContentOnRedirect = true;
47+
KeepResponseHistory = true;
48+
}
49+
50+
/// <summary>
51+
/// Gets or sets a value that indicates whether the handler should follow redirection responses.
52+
/// </summary>
53+
public bool AllowAutoRedirect { get; set; }
54+
55+
/// <summary>
56+
/// Gets or sets the maximum number of redirects that the handler follows.
57+
/// </summary>
58+
public int MaxAutomaticRedirections { get; set; }
59+
60+
/// <summary>
61+
/// Gets or sets a value indicating whether the response body should be downloaded before each redirection.
62+
/// </summary>
63+
public bool DownloadContentOnRedirect { get; set; }
64+
65+
/// <summary>
66+
/// Gets or sets a value indicating inner redirections on <see cref="HttpClientHandler"/> and <see cref="RedirectingHandler"/> should be disabled.
67+
/// </summary>
68+
public bool DisableInnerAutoRedirect { get; set; }
69+
70+
/// <summary>
71+
/// Gets or sets a value indicating whether the response history should be saved to the <see cref="HttpResponseMessage.RequestMessage"/> properties with the key of <see cref="HistoryPropertyKey"/>.
72+
/// </summary>
73+
public bool KeepResponseHistory { get; set; }
74+
75+
protected override async Task<HttpResponseMessage> SendAsync(
76+
HttpRequestMessage request,
77+
CancellationToken cancellationToken)
78+
{
79+
if(DisableInnerAutoRedirect)
80+
{
81+
// find the inner-most handler
82+
HttpMessageHandler innerHandler = InnerHandler;
83+
while(innerHandler is DelegatingHandler)
84+
{
85+
if(innerHandler is RedirectingHandler redirectingHandler)
86+
{
87+
redirectingHandler.AllowAutoRedirect = false;
88+
}
89+
90+
innerHandler = ((DelegatingHandler) innerHandler).InnerHandler;
91+
}
92+
93+
if(innerHandler is HttpClientHandler httpClientHandler)
94+
{
95+
httpClientHandler.AllowAutoRedirect = false;
96+
}
97+
}
98+
99+
// buffer the request body, to allow re-use in redirects
100+
HttpContent requestBody = null;
101+
if(AllowAutoRedirect && request.Content != null)
102+
{
103+
var buffer = await request.Content.ReadAsByteArrayAsync();
104+
requestBody = new ByteArrayContent(buffer);
105+
foreach(var header in request.Content.Headers)
106+
{
107+
requestBody.Headers.Add(header.Key, header.Value);
108+
}
109+
}
110+
111+
// make a copy of the request headers
112+
var requestHeaders = request
113+
.Headers
114+
.Select(p => new KeyValuePair<string, string[]>(p.Key, p.Value.ToArray()))
115+
.ToArray();
116+
117+
// send the initial request
118+
var response = await base.SendAsync(request, cancellationToken);
119+
var responses = new List<HttpResponseMessage>();
120+
121+
var redirectCount = 0;
122+
while(AllowAutoRedirect && redirectCount < MaxAutomaticRedirections
123+
&& TryGetRedirectLocation(response, out var locationString))
124+
{
125+
if(DownloadContentOnRedirect && response.Content != null)
126+
{
127+
await response.Content.ReadAsByteArrayAsync();
128+
}
129+
130+
Uri previousRequestUri = response.RequestMessage.RequestUri;
131+
132+
// Credit where credit is due: https://github.com/kennethreitz/requests/blob/master/requests/sessions.py
133+
// allow redirection without a scheme
134+
if(locationString.StartsWith("//"))
135+
{
136+
locationString = previousRequestUri.Scheme + ":" + locationString;
137+
}
138+
139+
var nextRequestUri = new Uri(locationString, UriKind.RelativeOrAbsolute);
140+
141+
// allow relative redirects
142+
if(!nextRequestUri.IsAbsoluteUri)
143+
{
144+
nextRequestUri = new Uri(previousRequestUri, nextRequestUri);
145+
}
146+
147+
// override previous method
148+
HttpMethod nextMethod = response.RequestMessage.Method;
149+
if(response.StatusCode == HttpStatusCode.Moved && nextMethod == HttpMethod.Post
150+
|| response.StatusCode == HttpStatusCode.Found && nextMethod != HttpMethod.Head
151+
|| response.StatusCode == HttpStatusCode.SeeOther && nextMethod != HttpMethod.Head)
152+
{
153+
nextMethod = HttpMethod.Get;
154+
requestBody = null;
155+
}
156+
157+
if(!s_keepRequestBodyRedirectStatusCodes.Contains(response.StatusCode))
158+
{
159+
requestBody = null;
160+
}
161+
162+
// build the next request
163+
var nextRequest = new HttpRequestMessage(nextMethod, nextRequestUri)
164+
{
165+
Content = requestBody,
166+
Version = request.Version
167+
};
168+
169+
foreach(var header in requestHeaders)
170+
{
171+
nextRequest.Headers.Add(header.Key, header.Value);
172+
}
173+
174+
foreach(var pair in request.Properties)
175+
{
176+
nextRequest.Properties.Add(pair.Key, pair.Value);
177+
}
178+
179+
// keep a history all responses
180+
if(KeepResponseHistory)
181+
{
182+
responses.Add(response);
183+
}
184+
185+
// send the next request
186+
response = await base.SendAsync(nextRequest, cancellationToken);
187+
188+
request = response.RequestMessage;
189+
redirectCount++;
190+
}
191+
192+
// save the history to the request message properties
193+
if(KeepResponseHistory && response.RequestMessage != null)
194+
{
195+
responses.Add(response);
196+
response.RequestMessage.Properties.Add(HistoryPropertyKey, responses);
197+
}
198+
199+
return response;
200+
}
201+
202+
private static bool TryGetRedirectLocation(HttpResponseMessage response, out string location)
203+
{
204+
if(s_redirectStatusCodes.Contains(response.StatusCode)
205+
&& response.Headers.TryGetValues("Location", out var locations)
206+
&& (locations = locations.ToArray()).Count() == 1
207+
&& !string.IsNullOrWhiteSpace(locations.First()))
208+
{
209+
location = locations.First().Trim();
210+
return true;
211+
}
212+
213+
location = null;
214+
return false;
215+
}
216+
}
217+
}

src/SqlStreamStore.HAL.Tests/SqlStreamStoreHalMiddlewareFixture.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ internal class SqlStreamStoreHalMiddlewareFixture : IDisposable
1717

1818
private readonly TestServer _server;
1919

20-
public SqlStreamStoreHalMiddlewareFixture()
20+
public SqlStreamStoreHalMiddlewareFixture(bool followRedirects = false)
2121
{
2222
StreamStore = new InMemoryStreamStore();
2323

@@ -26,7 +26,16 @@ public SqlStreamStoreHalMiddlewareFixture()
2626
.ConfigureServices(services => services.AddSingleton<IStartup>(new TestStartup(StreamStore)))
2727
.UseSetting(WebHostDefaults.ApplicationKey, "WHY"));
2828

29-
HttpClient = _server.CreateClient();
29+
var handler = _server.CreateHandler();
30+
if(followRedirects)
31+
{
32+
handler = new RedirectingHandler
33+
{
34+
InnerHandler = handler
35+
};
36+
}
37+
38+
HttpClient = new HttpClient(handler) { BaseAddress = new UriBuilder().Uri };
3039
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/hal+json"));
3140
}
3241

src/SqlStreamStore.HAL.Tests/TestStartup.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,5 @@ public TestStartup(IStreamStore streamStore)
1717
public IServiceProvider ConfigureServices(IServiceCollection services) => services.BuildServiceProvider();
1818

1919
public void Configure(IApplicationBuilder app) => app.UseSqlStreamStoreHal(_streamStore);
20-
2120
}
2221
}

src/SqlStreamStore.HAL/AppendStreamMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ private static MidFunc AppendStream(StreamResource stream) => async (context, ne
2727

2828
var response = await stream.Post(options, context.RequestAborted);
2929

30-
await context.WriteHalResponse(response);
30+
await context.WriteResponse(response);
3131
};
3232
}
3333
}

src/SqlStreamStore.HAL/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public static class Headers
99
public const string MessageId = "SSS-MessageId";
1010
public const string Location = "Location";
1111
public const string ETag = "ETag";
12+
public const string IfNoneMatch = "If-None-Match";
1213

1314
public static class ContentTypes
1415
{

src/SqlStreamStore.HAL/DeleteStreamMiddleware.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ private static MidFunc DeleteStream(StreamResource stream) => async (context, ne
3333

3434
var response = await stream.Delete(options, context.RequestAborted);
3535

36-
await context.WriteHalResponse(response);
36+
await context.WriteResponse(response);
3737
};
3838

3939
private static MidFunc DeleteStreamMessage(StreamMessageResource streamMessages) => async (context, next) =>
@@ -42,7 +42,7 @@ private static MidFunc DeleteStreamMessage(StreamMessageResource streamMessages)
4242

4343
var response = await streamMessages.DeleteMessage(options, context.RequestAborted);
4444

45-
await context.WriteHalResponse(response);
45+
await context.WriteResponse(response);
4646
};
4747
}
4848
}

src/SqlStreamStore.HAL/ExceptionHandlingMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder
7373

7474
var response = (exceptionHandler ?? s_defaultExceptionHandler)(ex);
7575

76-
await context.WriteHalResponse(response);
76+
await context.WriteResponse(response);
7777
}
7878
};
7979
}

0 commit comments

Comments
 (0)