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

Commit 8f79494

Browse files
redirect to canonical uri
if the query string parmaeters are out of order, issue a 308 redirect to the canonical uri
1 parent b55d677 commit 8f79494

10 files changed

Lines changed: 162 additions & 25 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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.Threading.Tasks;
8+
using Shouldly;
9+
using SqlStreamStore.HAL.Resources;
10+
using Xunit;
11+
12+
public class CanonicalUrlTests
13+
{
14+
private const string StreamId = "a-stream";
15+
private readonly SqlStreamStoreHalMiddlewareFixture _fixture;
16+
17+
public CanonicalUrlTests()
18+
{
19+
_fixture = new SqlStreamStoreHalMiddlewareFixture();
20+
}
21+
22+
private static IEnumerable<string> GetQueryStrings(bool forward, bool prefetch)
23+
{
24+
IEnumerable<string[]> GetPermutations(string[] items, int length)
25+
=> length == 1
26+
? items.Select(item => new[] { item })
27+
: GetPermutations(items, length - 1)
28+
.SelectMany(permutation => items.Where(item => !permutation.Contains(item)),
29+
(t1, t2) => t1.Concat(new[] { t2 }).ToArray())
30+
;
31+
32+
var d = $"d={(forward ? 'f' : 'b')}";
33+
var m = "m=20";
34+
var e = prefetch ? "e" : null;
35+
var p = "p=0";
36+
37+
var parameters = new[]
38+
{
39+
d, m, e, p
40+
};
41+
42+
return
43+
from set in GetPermutations(parameters, 4)
44+
select string.Join('&', set.Where(x => x != null));
45+
}
46+
47+
private static IEnumerable<(string, Uri)> NonCanonical()
48+
{
49+
var formatters = new Dictionary<bool, Func<string, int, long, bool, string>>
50+
{
51+
[true] = LinkFormatter.FormatForwardLink,
52+
[false] = LinkFormatter.FormatBackwardLink
53+
};
54+
55+
(string streamId, string path)[] streams =
56+
{
57+
(StreamId, $"/streams/{StreamId}"),
58+
(Constants.Streams.All, Constants.Streams.All)
59+
};
60+
61+
return
62+
from _ in streams
63+
from prefetch in new[] { true, false }
64+
from forward in new[] { true, false }
65+
let format = formatters[forward]
66+
let canonicalUri = format(_.streamId, 20, 0, prefetch)
67+
from queryString in GetQueryStrings(forward, prefetch)
68+
.Concat(new[] { $"d={(forward ? 'F' : 'B')}&M=20&P=0{(prefetch ? "&E" : string.Empty)}" })
69+
where $"{_.streamId}?{queryString}" != canonicalUri
70+
select ($"{_.path}?{queryString}", new Uri(
71+
canonicalUri,
72+
UriKind.Relative));
73+
}
74+
75+
public static IEnumerable<object[]> NonCanonicalUriCases()
76+
=> NonCanonical()
77+
.Select(x => new object[] { x.Item1, x.Item2 })
78+
.ToArray();
79+
80+
[Theory, MemberData(nameof(NonCanonicalUriCases))]
81+
public async Task non_canonical_uri(string requestUri, Uri expectedLocation)
82+
{
83+
using(var response = await _fixture.HttpClient.GetAsync(requestUri))
84+
{
85+
response.StatusCode.ShouldBe(HttpStatusCode.PermanentRedirect);
86+
response.Headers.Location.ShouldBe(expectedLocation);
87+
}
88+
}
89+
}
90+
}

src/SqlStreamStore.HAL.Tests/HeadersTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.Net.Http;
66
using System.Threading.Tasks;
77
using Shouldly;
8+
using SqlStreamStore.HAL.Resources;
9+
using SqlStreamStore.Streams;
810
using Xunit;
911

1012
public class HeadersTests : IDisposable
@@ -32,7 +34,10 @@ public async Task all_stream_head_link(HttpMethod method)
3234

3335
var position = await _fixture.StreamStore.ReadHeadPosition();
3436

35-
using(var response = await _fixture.HttpClient.SendAsync(new HttpRequestMessage(method, "/stream")))
37+
using(var response = await _fixture.HttpClient.SendAsync(
38+
new HttpRequestMessage(
39+
method,
40+
LinkFormatter.FormatBackwardLink("/stream", 20, Position.End, true))))
3641
{
3742
response.IsSuccessStatusCode.ShouldBeTrue();
3843

src/SqlStreamStore.HAL.Tests/SqlStreamStore.HAL.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<DebugType>portable</DebugType>
1414
<DebugSymbols>true</DebugSymbols>
1515
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
16+
<NoWarn>1591</NoWarn>
1617
</PropertyGroup>
1718
<ItemGroup>
1819
<ProjectReference Include="..\SqlStreamStore.HAL\SqlStreamStore.HAL.csproj" />

src/SqlStreamStore.HAL.Tests/StreamNavigationTests.cs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public static IEnumerable<object[]> GetNoMessagesPagingCases()
3333
[Theory, MemberData(nameof(GetNoMessagesPagingCases))]
3434
public async Task read_head_link_no_messages(string stream, string baseAddress, HttpStatusCode statusCode)
3535
{
36-
using(var response = await _fixture.HttpClient.GetAsync($"{baseAddress}{stream}"))
36+
using(var firstResponse = await _fixture.HttpClient.GetAsync($"{baseAddress}{stream}"))
37+
using(var response = await _fixture.HttpClient.GetAsync($"{baseAddress}{firstResponse.Headers.Location}"))
3738
{
3839
response.StatusCode.ShouldBe(statusCode);
3940

@@ -65,25 +66,31 @@ public async Task read_head_link_when_multiple_pages(string stream, string baseA
6566
{
6667
await _fixture.WriteNMessages("a-stream", 30);
6768

68-
using(var response = await _fixture.HttpClient.GetAsync($"{baseAddress}{stream}"))
69+
using(var firstResponse = await _fixture.HttpClient.GetAsync($"{baseAddress}{stream}"))
6970
{
70-
response.StatusCode.ShouldBe(HttpStatusCode.OK);
71+
firstResponse.StatusCode.ShouldBe(HttpStatusCode.PermanentRedirect);
7172

72-
var resource = await response.AsHal();
73+
using(var response =
74+
await _fixture.HttpClient.GetAsync($"{baseAddress}{firstResponse.Headers.Location}"))
75+
{
76+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
7377

74-
resource.ShouldLink(Constants.Relations.Self, $"{stream}?{LastLinkQuery}");
78+
var resource = await response.AsHal();
7579

76-
resource.ShouldLink(Constants.Relations.Last, $"{stream}?{LastLinkQuery}");
80+
resource.ShouldLink(Constants.Relations.Self, $"{stream}?{LastLinkQuery}");
7781

78-
resource.ShouldLink(Constants.Relations.Previous, $"{stream}?d=b&m=20&p=9");
82+
resource.ShouldLink(Constants.Relations.Last, $"{stream}?{LastLinkQuery}");
7983

80-
resource.ShouldLink(Constants.Relations.First, $"{stream}?{FirstLinkQuery}");
84+
resource.ShouldLink(Constants.Relations.Previous, $"{stream}?d=b&m=20&p=9");
8185

82-
resource.ShouldLink(Constants.Relations.Feed, $"{stream}?{LastLinkQuery}");
86+
resource.ShouldLink(Constants.Relations.First, $"{stream}?{FirstLinkQuery}");
8387

84-
if(!IsAllStream($"{baseAddress}{stream}"))
85-
{
86-
resource.ShouldLink(Constants.Relations.Metadata, $"{stream}/metadata");
88+
resource.ShouldLink(Constants.Relations.Feed, $"{stream}?{LastLinkQuery}");
89+
90+
if(!IsAllStream($"{baseAddress}{stream}"))
91+
{
92+
resource.ShouldLink(Constants.Relations.Metadata, $"{stream}/metadata");
93+
}
8794
}
8895
}
8996
}
@@ -149,7 +156,8 @@ public async Task read_stream_should_include_the_last_position_and_version()
149156

150157
var page = await _fixture.StreamStore.ReadStreamForwards("a-stream", StreamVersion.Start, 10, false);
151158

152-
using(var response = await _fixture.HttpClient.GetAsync("/streams/a-stream"))
159+
using(var firstResponse = await _fixture.HttpClient.GetAsync("/streams/a-stream"))
160+
using(var response = await _fixture.HttpClient.GetAsync($"/streams/{firstResponse.Headers.Location}"))
153161
{
154162
response.StatusCode.ShouldBe(HttpStatusCode.OK);
155163

src/SqlStreamStore.HAL/Constants.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public static class Headers
88
public const string HeadPosition = "SSS-HeadPosition";
99
public const string MessageId = "SSS-MessageId";
1010
public const string Location = "Location";
11-
11+
1212
public static class ContentTypes
1313
{
1414
public const string Json = "application/json";
@@ -43,7 +43,7 @@ public static class ReadDirection
4343
public const int Forwards = 1;
4444
public const int Backwards = -1;
4545
}
46-
46+
4747
public const int MaxCount = 20;
4848
}
4949
}

src/SqlStreamStore.HAL/HttpContextExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ namespace SqlStreamStore.HAL
22
{
33
using System;
44
using System.IO;
5-
using System.Linq;
65
using System.Net.Http;
76
using System.Net.Http.Headers;
87
using System.Threading.Tasks;

src/SqlStreamStore.HAL/Resources/AllStreamResource.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ public async Task<Response> Get(
3131
ReadAllStreamOperation operation,
3232
CancellationToken cancellationToken)
3333
{
34+
if(!operation.IsUriCanonical)
35+
{
36+
return new Response(new HALResponse(null), 308)
37+
{
38+
Headers = { [Constants.Headers.Location] = new[] { operation.Self } }
39+
};
40+
}
41+
3442
var page = await operation.Invoke(_streamStore, cancellationToken);
3543

3644
var streamMessages = page.Messages.OrderByDescending(m => m.Position).ToArray();

src/SqlStreamStore.HAL/Resources/ReadAllStreamOperation.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,29 @@ public ReadAllStreamOperation(HttpRequest request)
2727
{
2828
_maxCount = Constants.MaxCount;
2929
}
30+
31+
Self = ReadDirection == Constants.ReadDirection.Forwards
32+
? LinkFormatter.FormatForwardLink(
33+
Constants.Streams.All,
34+
MaxCount,
35+
FromPositionInclusive,
36+
EmbedPayload)
37+
: LinkFormatter.FormatBackwardLink(
38+
Constants.Streams.All,
39+
MaxCount,
40+
FromPositionInclusive,
41+
EmbedPayload);
42+
43+
IsUriCanonical = Self.Remove(0, Constants.Streams.All.Length)
44+
== request.QueryString.ToUriComponent();
3045
}
3146

3247
public long FromPositionInclusive => _fromPositionInclusive;
3348
public int MaxCount => _maxCount;
3449
public bool EmbedPayload { get; }
3550
public int ReadDirection { get; }
36-
37-
public string Self => ReadDirection == Constants.ReadDirection.Forwards
38-
? LinkFormatter.FormatForwardLink(Constants.Streams.All, MaxCount, FromPositionInclusive, EmbedPayload)
39-
: LinkFormatter.FormatBackwardLink(Constants.Streams.All, MaxCount, FromPositionInclusive, EmbedPayload);
51+
public string Self { get; }
52+
public bool IsUriCanonical { get; }
4053

4154
public Task<ReadAllPage> Invoke(IStreamStore streamStore, CancellationToken ct)
4255
=> ReadDirection == Constants.ReadDirection.Forwards

src/SqlStreamStore.HAL/Resources/ReadStreamOperation.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,22 @@ public ReadStreamOperation(HttpRequest request)
3131
{
3232
_maxCount = Constants.MaxCount;
3333
}
34+
35+
Self = ReadDirection == Constants.ReadDirection.Forwards
36+
? LinkFormatter.FormatForwardLink(StreamId, MaxCount, FromVersionInclusive, EmbedPayload)
37+
: LinkFormatter.FormatBackwardLink(StreamId, MaxCount, FromVersionInclusive, EmbedPayload);
38+
39+
IsUriCanonical = Self.Remove(0, StreamId.Length)
40+
== request.QueryString.ToUriComponent();
3441
}
3542

3643
public long FromVersionInclusive => _fromVersionInclusive;
3744
public int MaxCount => _maxCount;
3845
public bool EmbedPayload { get; }
3946
public int ReadDirection { get; }
4047
public string StreamId { get; }
41-
42-
public string Self => ReadDirection == Constants.ReadDirection.Forwards
43-
? LinkFormatter.FormatForwardLink(StreamId, MaxCount, FromVersionInclusive, EmbedPayload)
44-
: LinkFormatter.FormatBackwardLink(StreamId, MaxCount, FromVersionInclusive, EmbedPayload);
48+
public string Self { get; }
49+
public bool IsUriCanonical { get; }
4550

4651
public Task<ReadStreamPage> Invoke(IStreamStore streamStore, CancellationToken ct)
4752
=> ReadDirection == Constants.ReadDirection.Forwards

src/SqlStreamStore.HAL/Resources/StreamResource.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ public async Task<Response> Post(
5252

5353
public async Task<Response> Get(ReadStreamOperation operation, CancellationToken cancellationToken)
5454
{
55+
if(!operation.IsUriCanonical)
56+
{
57+
return new Response(new HALResponse(null), 308)
58+
{
59+
Headers = { [Constants.Headers.Location] = new[] { operation.Self } }
60+
};
61+
}
62+
5563
var page = await operation.Invoke(_streamStore, cancellationToken);
5664

5765
var streamMessages = page.Messages.OrderByDescending(m => m.Position).ToArray();

0 commit comments

Comments
 (0)