diff --git a/src/SqlStreamStore.HAL/AllStream/AllStreamResource.cs b/src/SqlStreamStore.HAL/AllStream/AllStreamResource.cs index 01f9edd..7c14057 100644 --- a/src/SqlStreamStore.HAL/AllStream/AllStreamResource.cs +++ b/src/SqlStreamStore.HAL/AllStream/AllStreamResource.cs @@ -57,6 +57,7 @@ public async Task Get( .FromOperation(operation) .Index() .Find() + .Browse() .AllStreamNavigation(page, operation)) .AddEmbeddedCollection( Constants.Relations.Message, diff --git a/src/SqlStreamStore.HAL/AllStreamMessage/AllStreamMessageResource.cs b/src/SqlStreamStore.HAL/AllStreamMessage/AllStreamMessageResource.cs index 1103f92..6d57920 100644 --- a/src/SqlStreamStore.HAL/AllStreamMessage/AllStreamMessageResource.cs +++ b/src/SqlStreamStore.HAL/AllStreamMessage/AllStreamMessageResource.cs @@ -29,6 +29,7 @@ public async Task Get( .FromOperation(operation) .Index() .Find() + .Browse() .Add( Constants.Relations.Message, $"stream/{message.Position}", @@ -62,7 +63,7 @@ public async Task Get( message.Type, payload, metadata = message.JsonMetadata - }).AddLinks(links)); + }).AddLinks(links)); } } } \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/Constants.cs b/src/SqlStreamStore.HAL/Constants.cs index d9cd6e1..d5b5d1a 100644 --- a/src/SqlStreamStore.HAL/Constants.cs +++ b/src/SqlStreamStore.HAL/Constants.cs @@ -60,6 +60,7 @@ public static class Relations public const string DeleteStream = StreamStorePrefix + ":delete-stream"; public const string DeleteMessage = StreamStorePrefix + ":delete-message"; public const string Find = StreamStorePrefix + ":find"; + public const string Browse = StreamStorePrefix + ":feed-browser"; } public static class Streams @@ -71,6 +72,7 @@ public static class Streams public static PathString AllStreamPath = new PathString($"/{All}"); public static PathString StreamsPath = new PathString($"/{Stream}"); public static PathString IndexPath = new PathString("/"); + public static PathString StreamBrowserPath = StreamsPath; } public static class ReadDirection diff --git a/src/SqlStreamStore.HAL/Index/IndexResource.cs b/src/SqlStreamStore.HAL/Index/IndexResource.cs index 3aaa3f1..bcdffe4 100644 --- a/src/SqlStreamStore.HAL/Index/IndexResource.cs +++ b/src/SqlStreamStore.HAL/Index/IndexResource.cs @@ -4,7 +4,6 @@ namespace SqlStreamStore.HAL.Index using System.Reflection; using Halcyon.HAL; using Microsoft.AspNetCore.Http; - using Newtonsoft.Json; using Newtonsoft.Json.Linq; internal class IndexResource : IResource @@ -43,8 +42,7 @@ private static string GetVersion(Type type) .FromPath(PathString.Empty) .Index().Self() .Find() + .Browse() .Add(Constants.Relations.Feed, Constants.Streams.All))); - - public override string ToString() => _data.ToString(Formatting.None); } } \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/LinksExtensions.cs b/src/SqlStreamStore.HAL/LinksExtensions.cs index 4a97c5f..30560ba 100644 --- a/src/SqlStreamStore.HAL/LinksExtensions.cs +++ b/src/SqlStreamStore.HAL/LinksExtensions.cs @@ -7,5 +7,8 @@ public static Links Index(this Links links) => public static Links Find(this Links links) => links.Add(Constants.Relations.Find, "streams/{streamId}", "Find a Stream"); + + public static Links Browse(this Links links) + => links.Add(Constants.Relations.Browse, "streams{?p,t,m}", "Browse Streams"); } } \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/SqlStreamStoreHalMiddleware.cs b/src/SqlStreamStore.HAL/SqlStreamStoreHalMiddleware.cs index 4703813..5939019 100644 --- a/src/SqlStreamStore.HAL/SqlStreamStoreHalMiddleware.cs +++ b/src/SqlStreamStore.HAL/SqlStreamStoreHalMiddleware.cs @@ -11,6 +11,7 @@ using SqlStreamStore.HAL.Docs; using SqlStreamStore.HAL.Index; using SqlStreamStore.HAL.Logging; + using SqlStreamStore.HAL.StreamBrowser; using SqlStreamStore.HAL.StreamMessage; using SqlStreamStore.HAL.StreamMetadata; using SqlStreamStore.HAL.Streams; @@ -62,6 +63,7 @@ public static IApplicationBuilder UseSqlStreamStoreHal( var index = new IndexResource(streamStore); var allStream = new AllStreamResource(streamStore, options.UseCanonicalUrls); var allStreamMessages = new AllStreamMessageResource(streamStore); + var streamBrowser = new StreamBrowserResource(streamStore); var streams = new StreamResource(streamStore); var streamMetadata = new StreamMetadataResource(streamStore); var streamMessages = new StreamMessageResource(streamStore); @@ -83,6 +85,7 @@ public static IApplicationBuilder UseSqlStreamStoreHal( .UseIndex(index) .UseAllStream(allStream) .UseAllStreamMessage(allStreamMessages) + .UseStreamBrowser(streamBrowser) .UseStreams(streams) .UseStreamMetadata(streamMetadata) .UseStreamMessages(streamMessages); diff --git a/src/SqlStreamStore.HAL/StreamBrowser/ListStreamsOperation.cs b/src/SqlStreamStore.HAL/StreamBrowser/ListStreamsOperation.cs new file mode 100644 index 0000000..396453e --- /dev/null +++ b/src/SqlStreamStore.HAL/StreamBrowser/ListStreamsOperation.cs @@ -0,0 +1,58 @@ +namespace SqlStreamStore.HAL.StreamBrowser +{ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using SqlStreamStore.Streams; + + internal class ListStreamsOperation : IStreamStoreOperation + { + public Pattern Pattern { get; } + public string ContinuationToken { get; } + public int MaxCount { get; } + public string PatternType { get; } + public PathString Path { get; } + + public ListStreamsOperation(HttpRequest request) + { + Path = request.Path; + if(request.Query.TryGetValueCaseInsensitive('t', out var patternType)) + { + PatternType = patternType; + } + + if(!request.Query.TryGetValueCaseInsensitive('p', out var pattern)) + { + Pattern = Pattern.Anything(); + } + else + { + switch(PatternType) + { + case "s": + Pattern = Pattern.StartsWith(pattern); + break; + case "e": + Pattern = Pattern.EndsWith(pattern); + break; + default: + Pattern = Pattern.Anything(); + break; + } + } + + if(request.Query.TryGetValueCaseInsensitive('c', out var continuationToken)) + { + ContinuationToken = continuationToken; + } + + MaxCount = request.Query.TryGetValueCaseInsensitive('m', out var m) + && int.TryParse(m, out var maxCount) + ? maxCount + : 100; + } + + public Task Invoke(IStreamStore streamStore, CancellationToken cancellationToken) + => streamStore.ListStreams(Pattern, MaxCount, ContinuationToken, cancellationToken); + } +} \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserLinkExtensions.cs b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserLinkExtensions.cs new file mode 100644 index 0000000..94bd36e --- /dev/null +++ b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserLinkExtensions.cs @@ -0,0 +1,37 @@ +namespace SqlStreamStore.HAL.StreamBrowser +{ + using SqlStreamStore.Streams; + + internal static class StreamBrowserLinkExtensions + { + public static Links StreamBrowserNavigation( + this Links links, + ListStreamsPage listStreamsPage, + ListStreamsOperation operation) + { + if(operation.ContinuationToken != null) + { + links.Add( + Constants.Relations.Next, + FormatLink(operation, listStreamsPage.ContinuationToken)); + } + + if(listStreamsPage.ContinuationToken != null) + { + links.Add( + Constants.Relations.Next, + FormatLink(operation, listStreamsPage.ContinuationToken)); + } + + + return links + .Add( + Constants.Relations.Browse, + FormatLink(operation, listStreamsPage.ContinuationToken)) + .Self(); + } + + private static string FormatLink(ListStreamsOperation operation, string continuationToken) => + $"streams?p={operation.Pattern.Value}&t={operation.PatternType}&c={continuationToken}&m={operation.MaxCount}"; + } +} \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserMiddleware.cs b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserMiddleware.cs new file mode 100644 index 0000000..7685c54 --- /dev/null +++ b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserMiddleware.cs @@ -0,0 +1,41 @@ +namespace SqlStreamStore.HAL.StreamBrowser +{ + using System; + using System.Net.Http; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using MidFunc = System.Func< + Microsoft.AspNetCore.Http.HttpContext, + System.Func, + System.Threading.Tasks.Task + >; + + internal static class StreamBrowserMiddleware + { + public static IApplicationBuilder UseStreamBrowser( + this IApplicationBuilder builder, + StreamBrowserResource streamBrowser) + => builder.MapWhen(IsMatch, Configure(streamBrowser)); + + private static bool IsMatch(HttpContext context) + => context.Request.Path.IsStreamBrowser(); + + public static bool IsStreamBrowser(this PathString requestPath) + => requestPath == Constants.Streams.StreamBrowserPath; + + private static Action Configure(StreamBrowserResource streamBrowser) + => builder => builder + .UseMiddlewareLogging(typeof(StreamBrowserMiddleware)) + .MapWhen(HttpMethod.Get, inner => inner.Use(BrowseStreams(streamBrowser))); + + private static MidFunc BrowseStreams(StreamBrowserResource streamBrowser) + => async (context, next) => + { + var response = await streamBrowser.Get( + new ListStreamsOperation(context.Request), + context.RequestAborted); + + await context.WriteResponse(response); + }; + } +} \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserResource.cs b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserResource.cs new file mode 100644 index 0000000..d65331f --- /dev/null +++ b/src/SqlStreamStore.HAL/StreamBrowser/StreamBrowserResource.cs @@ -0,0 +1,44 @@ +namespace SqlStreamStore.HAL.StreamBrowser +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Halcyon.HAL; + using SqlStreamStore.HAL.StreamBrowser; + + internal class StreamBrowserResource + { + private readonly IStreamStore _streamStore; + + public StreamBrowserResource(IStreamStore streamStore) + { + _streamStore = streamStore; + } + + public async Task Get(ListStreamsOperation operation, CancellationToken cancellationToken) + { + var listStreamsPage = await operation.Invoke(_streamStore, cancellationToken); + + return new HalJsonResponse(new HALResponse(new + { + listStreamsPage.ContinuationToken + }) + .AddLinks( + Links + .FromOperation(operation) + .Index() + .Find() + .StreamBrowserNavigation(listStreamsPage, operation)) + .AddEmbeddedCollection( + Constants.Relations.Feed, + Array.ConvertAll( + listStreamsPage.StreamIds, + streamId => new HALResponse(null) + .AddLinks( + Links + .FromOperation(operation) + .Add(Constants.Relations.Feed, $"{Constants.Streams.Stream}/{streamId}", streamId) + .Self())))); + } + } +} \ No newline at end of file diff --git a/src/SqlStreamStore.HAL/StreamMessage/StreamMessageResource.cs b/src/SqlStreamStore.HAL/StreamMessage/StreamMessageResource.cs index 87af4da..c72566d 100644 --- a/src/SqlStreamStore.HAL/StreamMessage/StreamMessageResource.cs +++ b/src/SqlStreamStore.HAL/StreamMessage/StreamMessageResource.cs @@ -31,6 +31,7 @@ public async Task Get( .FromPath(operation.Path) .Index() .Find() + .Browse() .StreamMessageNavigation(message, operation); if(message.MessageId == Guid.Empty) diff --git a/src/SqlStreamStore.HAL/StreamMetadata/StreamMetadataResource.cs b/src/SqlStreamStore.HAL/StreamMetadata/StreamMetadataResource.cs index 35d79f1..a532090 100644 --- a/src/SqlStreamStore.HAL/StreamMetadata/StreamMetadataResource.cs +++ b/src/SqlStreamStore.HAL/StreamMetadata/StreamMetadataResource.cs @@ -39,6 +39,7 @@ public async Task Get( .FromOperation(operation) .Index() .Find() + .Browse() .StreamMetadataNavigation(operation)) .AddEmbeddedResource( Constants.Relations.Metadata, diff --git a/src/SqlStreamStore.HAL/Streams/StreamResource.cs b/src/SqlStreamStore.HAL/Streams/StreamResource.cs index c16b673..1f59399 100644 --- a/src/SqlStreamStore.HAL/Streams/StreamResource.cs +++ b/src/SqlStreamStore.HAL/Streams/StreamResource.cs @@ -47,6 +47,7 @@ public async Task Post( .FromOperation(operation) .Index() .Find() + .Browse() .Add(Constants.Relations.Feed, $"streams/{operation.StreamId}").Self(); var response = new HalJsonResponse( @@ -98,6 +99,7 @@ public async Task Get(ReadStreamOperation operation, CancellationToken .FromOperation(operation) .Index() .Find() + .Browse() .StreamsNavigation(page, operation)) .AddEmbeddedResource( Constants.Relations.AppendToStream,