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

Commit 1217022

Browse files
Merge pull request #17 from thefringeninja/etags
Add ETag Support
2 parents 40165a4 + b24f9c6 commit 1217022

30 files changed

Lines changed: 549 additions & 64 deletions

src/SqlStreamStore.HAL.DevServer/SqlStreamStore.HAL.DevServer.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
<PropertyGroup>
33
<OutputType>Exe</OutputType>
44
<TargetFramework>netcoreapp2.1</TargetFramework>
5-
<LangVersion>latest</LangVersion>
65
<RuntimeIdentifiers>alpine.3.7-x64</RuntimeIdentifiers>
76
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
7+
<LangVersion>7.3</LangVersion>
88
</PropertyGroup>
99
<ItemGroup>
1010
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.1.1" />
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/SqlStreamStore.HAL.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<DebugSymbols>true</DebugSymbols>
1515
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
1616
<NoWarn>1591</NoWarn>
17+
<LangVersion>7.3</LangVersion>
1718
</PropertyGroup>
1819
<ItemGroup>
1920
<ProjectReference Include="..\SqlStreamStore.HAL\SqlStreamStore.HAL.csproj" />

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/StreamMessageTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{
33
using System;
44
using System.Net;
5+
using System.Net.Http.Headers;
56
using System.Threading.Tasks;
67
using Shouldly;
78
using Xunit;
@@ -20,12 +21,12 @@ public StreamMessageTests()
2021
[Fact]
2122
public async Task read_single_message_stream()
2223
{
23-
// position of event in all stream would be helpful here
2424
var writeResult = await _fixture.WriteNMessages("a-stream", 1);
2525

2626
using(var response = await _fixture.HttpClient.GetAsync("/streams/a-stream/0"))
2727
{
2828
response.StatusCode.ShouldBe(HttpStatusCode.OK);
29+
response.Headers.ETag.ShouldBe(new EntityTagHeaderValue($@"""{writeResult.CurrentVersion}"""));
2930
3031
var resource = await response.AsHal();
3132
@@ -56,6 +57,7 @@ public async Task read_single_message_does_not_exist_stream()
5657
using(var response = await _fixture.HttpClient.GetAsync("/streams/a-stream/0"))
5758
{
5859
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
60+
response.Headers.ETag.ShouldBeNull();
5961

6062
var resource = await response.AsHal();
6163

@@ -91,6 +93,7 @@ public async Task delete_single_message_by_version()
9193
using(var response = await _fixture.HttpClient.GetAsync("/streams/a-stream/0"))
9294
{
9395
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
96+
response.Headers.ETag.ShouldBeNull();
9497
}
9598
}
9699
}

src/SqlStreamStore.HAL.Tests/StreamMetadataTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ await _fixture.HttpClient.SendAsync(
3030
new HttpRequestMessage(HttpMethod.Get, $"/streams/{StreamId}/metadata")))
3131
{
3232
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
33+
response.Headers.ETag.ShouldBe(new EntityTagHeaderValue(@"""-1"""));
3334

3435
var resource = await response.AsHal();
3536

@@ -72,6 +73,7 @@ await _fixture.HttpClient.SendAsync(
7273
new HttpRequestMessage(HttpMethod.Get, $"/streams/{StreamId}/metadata")))
7374
{
7475
response.StatusCode.ShouldBe(HttpStatusCode.OK);
76+
response.Headers.ETag.ShouldBe(new EntityTagHeaderValue(@"""0"""));
7577

7678
var resource = await response.AsHal();
7779

0 commit comments

Comments
 (0)