diff --git a/asset/getseq_net.ico b/asset/getseq_net.ico deleted file mode 100644 index 34b3b9f..0000000 Binary files a/asset/getseq_net.ico and /dev/null differ diff --git a/example/SeqQuery/App.config b/example/SeqQuery/App.config deleted file mode 100644 index 8277a73..0000000 --- a/example/SeqQuery/App.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/SeqQuery/Program.cs b/example/SeqQuery/Program.cs index c80ac00..9a793a7 100644 --- a/example/SeqQuery/Program.cs +++ b/example/SeqQuery/Program.cs @@ -32,7 +32,7 @@ static void Main(string[] args) var server = arguments[""].ToString(); var query = Normalize(arguments[""]); var apiKey = Normalize(arguments["--apikey"]); - var @from = Normalize(arguments["--from"]); + var from = Normalize(arguments["--from"]); var to = Normalize(arguments["--to"]); await Run(server, apiKey, query, from, to); diff --git a/example/SeqQuery/SeqQuery.csproj b/example/SeqQuery/SeqQuery.csproj index 40bca25..ea3a4f4 100644 --- a/example/SeqQuery/SeqQuery.csproj +++ b/example/SeqQuery/SeqQuery.csproj @@ -6,10 +6,6 @@ Exe - - - - diff --git a/example/SeqQuery/getseq_net.ico b/example/SeqQuery/getseq_net.ico deleted file mode 100644 index 34b3b9f..0000000 Binary files a/example/SeqQuery/getseq_net.ico and /dev/null differ diff --git a/example/SeqTail/App.config b/example/SeqTail/App.config deleted file mode 100644 index 8277a73..0000000 --- a/example/SeqTail/App.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/SeqTail/Program.cs b/example/SeqTail/Program.cs index 162fdd1..0d98f85 100644 --- a/example/SeqTail/Program.cs +++ b/example/SeqTail/Program.cs @@ -46,7 +46,7 @@ static void Main(string[] args) Console.WriteLine("Tailing, press Ctrl+C to exit."); Console.CancelKeyPress += (s,a) => cancel.Cancel(); - var run = Task.Run(() => Run(server, apiKey, filter, cancel)); + var run = Task.Run(() => Run(server, apiKey, filter, cancel), cancel.Token); run.GetAwaiter().GetResult(); } @@ -78,8 +78,8 @@ static async Task Run(string server, string apiKey, string filter, CancellationT using (var stream = await connection.Events.StreamAsync(filter: strict)) { var subscription = stream - .Select(jObject => LogEventReader.ReadFromJObject(jObject)) - .Subscribe(evt => Log.Write(evt), () => cancel.Cancel()); + .Select(LogEventReader.ReadFromJObject) + .Subscribe(Log.Write, cancel.Cancel); cancel.Token.WaitHandle.WaitOne(); subscription.Dispose(); diff --git a/example/SeqTail/SeqTail.csproj b/example/SeqTail/SeqTail.csproj index 3ceeb51..ac34313 100644 --- a/example/SeqTail/SeqTail.csproj +++ b/example/SeqTail/SeqTail.csproj @@ -6,10 +6,6 @@ Exe - - - - diff --git a/example/SeqTail/getseq_net.ico b/example/SeqTail/getseq_net.ico deleted file mode 100644 index 34b3b9f..0000000 Binary files a/example/SeqTail/getseq_net.ico and /dev/null differ diff --git a/example/SignalCopy/App.config b/example/SignalCopy/App.config deleted file mode 100644 index 8277a73..0000000 --- a/example/SignalCopy/App.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/SignalCopy/SignalCopy.csproj b/example/SignalCopy/SignalCopy.csproj index 3f1644b..9b9700e 100644 --- a/example/SignalCopy/SignalCopy.csproj +++ b/example/SignalCopy/SignalCopy.csproj @@ -6,10 +6,6 @@ Exe - - - - diff --git a/example/SignalCopy/getseq_net.ico b/example/SignalCopy/getseq_net.ico deleted file mode 100644 index 34b3b9f..0000000 Binary files a/example/SignalCopy/getseq_net.ico and /dev/null differ diff --git a/seq-api.sln b/seq-api.sln index 9abcb17..0c8feca 100644 --- a/seq-api.sln +++ b/seq-api.sln @@ -1,17 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26403.3 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29230.47 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B36BE973-6D5B-4DAA-BF42-F1CF11D6F18E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "example", "example", "{1C66E116-DC21-4C8F-833E-A4C2DDF58487}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "asset", "asset", "{233C7E7D-26DE-452A-A415-44F99CBA18E7}" - ProjectSection(SolutionItems) = preProject - asset\getseq_net.ico = asset\getseq_net.ico - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "global", "global", "{7DCED657-7E83-4E6F-AB14-5753E044304D}" ProjectSection(SolutionItems) = preProject appveyor.yml = appveyor.yml @@ -69,4 +64,7 @@ Global {42CEBFBA-208F-40F1-AC95-13F05F6D5412} = {1C66E116-DC21-4C8F-833E-A4C2DDF58487} {E8CDDE17-8E29-4EB4-A4BB-38BCE346A752} = {1C66E116-DC21-4C8F-833E-A4C2DDF58487} EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {20BAB483-FB94-4373-8E4C-0F846B6DBFFC} + EndGlobalSection EndGlobal diff --git a/seq-api.sln.DotSettings b/seq-api.sln.DotSettings index d50635d..6e98e3d 100644 --- a/seq-api.sln.DotSettings +++ b/seq-api.sln.DotSettings @@ -1,2 +1,16 @@  - AD \ No newline at end of file + AD + True + True + True + True + True + True + True + True + True + True + True + True + True + diff --git a/src/Seq.Api/Client/SeqApiClient.cs b/src/Seq.Api/Client/SeqApiClient.cs index 3afd2c7..f505450 100644 --- a/src/Seq.Api/Client/SeqApiClient.cs +++ b/src/Seq.Api/Client/SeqApiClient.cs @@ -1,7 +1,20 @@ -using System; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -12,23 +25,24 @@ using Seq.Api.Model; using Seq.Api.Model.Root; using Seq.Api.Serialization; -using Tavis.UriTemplates; using System.Threading; using Seq.Api.Streams; using System.Net.WebSockets; namespace Seq.Api.Client { + /// + /// A low-level client that provides navigation over the linked resource structure of the Seq HTTP API. + /// public class SeqApiClient : IDisposable { readonly string _apiKey; // Future versions of Seq may not completely support v1 features, however // providing this as an Accept header will ensure what compatibility is available - // can be utilised. + // can be utilized. const string SeqApiV7MediaType = "application/vnd.datalust.seq.v7+json"; - readonly HttpClient _httpClient; readonly CookieContainer _cookies = new CookieContainer(); readonly JsonSerializer _serializer = JsonSerializer.Create( new JsonSerializerSettings @@ -36,6 +50,12 @@ public class SeqApiClient : IDisposable Converters = { new StringEnumConverter(), new LinkCollectionConverter() } }); + /// + /// Construct a . + /// + /// The base URL of the Seq server. + /// An API key to use when making requests to the server, if required. + /// Whether default credentials will be sent with HTTP requests; the default is true. public SeqApiClient(string serverUrl, string apiKey = null, bool useDefaultCredentials = true) { ServerUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); @@ -53,44 +73,108 @@ public SeqApiClient(string serverUrl, string apiKey = null, bool useDefaultCrede if (!baseAddress.EndsWith("/")) baseAddress += "/"; - _httpClient = new HttpClient(handler) { BaseAddress = new Uri(baseAddress) }; + HttpClient = new HttpClient(handler) { BaseAddress = new Uri(baseAddress) }; + HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(SeqApiV7MediaType)); + + if (_apiKey != null) + HttpClient.DefaultRequestHeaders.Add("X-Seq-ApiKey", _apiKey); } + /// + /// The base URL of the Seq server. + /// public string ServerUrl { get; } - public HttpClient HttpClient => _httpClient; - + /// + /// The HTTP client used when making requests to the Seq server. The HTTP client is configured with the server's base address, collects any cookies + /// sent with responses from the API, and will send the appropriate Accept and X-Seq-ApiKey headers by default. + /// + public HttpClient HttpClient { get; } + + /// + /// Get the root entity describing the server and providing links to other resources available from the API. + /// + /// A supporting cancellation. + /// The root entity. public Task GetRootAsync(CancellationToken cancellationToken = default) { return HttpGetAsync("api", cancellationToken); } + /// + /// Issue a GET request returning a by following the from . + /// + /// The entity type into which the response should be deserialized. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// The response entity. public Task GetAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); return HttpGetAsync(linkUri, cancellationToken); } + /// + /// Issue a GET request returning a string by following the from . + /// + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// The response as a string. public Task GetStringAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); return HttpGetStringAsync(linkUri, cancellationToken); } + /// + /// Issue a GET request returning a list of by following the from . + /// + /// The entity type into which the response items should be deserialized. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// The response entities. public Task> ListAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); return HttpGetAsync>(linkUri, cancellationToken); } + /// + /// Issue a POST request accepting a serialized to a from . + /// + /// The entity type that will be serialized into the request payload. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// A task that signals request completion. public async Task PostAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); var request = new HttpRequestMessage(HttpMethod.Post, linkUri) { Content = MakeJsonContent(content) }; var stream = await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); - new StreamReader(stream).ReadToEnd(); + using (var reader = new StreamReader(stream)) + reader.ReadToEnd(); } + /// + /// Issue a POST request accepting a serialized and returning a by following from . + /// + /// The entity type that will be serialized into the request payload. + /// The entity type into which the response should be deserialized. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// The response entity. public async Task PostAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); @@ -99,14 +183,35 @@ public async Task PostAsync(ILinked entity, strin return _serializer.Deserialize(new JsonTextReader(new StreamReader(stream))); } + /// + /// Issue a POST request accepting a serialized and returning a string by following from . + /// + /// The entity type that will be serialized into the request payload. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// The response string. public async Task PostReadStringAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); var request = new HttpRequestMessage(HttpMethod.Post, linkUri) { Content = MakeJsonContent(content) }; var stream = await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); - return await new StreamReader(stream).ReadToEndAsync(); + using (var reader = new StreamReader(stream)) + return await reader.ReadToEndAsync(); } + /// + /// Issue a POST request accepting a serialized and returning a stream by following from . + /// + /// The entity type that will be serialized into the request payload. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// The response stream. public async Task PostReadStreamAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); @@ -114,22 +219,55 @@ public async Task PostReadStreamAsync(ILinked entity, string li return await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); } + /// + /// Issue a PUT request accepting a serialized to a from . + /// + /// The entity type that will be serialized into the request payload. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// A task that signals request completion. public async Task PutAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); var request = new HttpRequestMessage(HttpMethod.Put, linkUri) { Content = MakeJsonContent(content) }; var stream = await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); - new StreamReader(stream).ReadToEnd(); + using (var reader = new StreamReader(stream)) + reader.ReadToEnd(); } + /// + /// Issue a DELETE request accepting a serialized to a from . + /// + /// The entity type that will be serialized into the request payload. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// A task that signals request completion. public async Task DeleteAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); var request = new HttpRequestMessage(HttpMethod.Delete, linkUri) { Content = MakeJsonContent(content) }; var stream = await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); - new StreamReader(stream).ReadToEnd(); + using (var reader = new StreamReader(stream)) + reader.ReadToEnd(); } + /// + /// Issue a DELETE request accepting a serialized to a from . + /// + /// The entity type that will be serialized into the request payload. + /// The entity type into which the response should be deserialized. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// The request content. + /// A supporting cancellation. + /// The response entity. public async Task DeleteAsync(ILinked entity, string link, TEntity content, IDictionary parameters = null, CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); @@ -138,11 +276,28 @@ public async Task DeleteAsync(ILinked entity, str return _serializer.Deserialize(new JsonTextReader(new StreamReader(stream))); } + /// + /// Connect to a websocket at the address specified by following from . + /// + /// The type of the values received over the websocket. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// A stream of values from the websocket. public async Task> StreamAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) { return await WebSocketStreamAsync(entity, link, parameters, reader => _serializer.Deserialize(new JsonTextReader(reader)), cancellationToken); } + /// + /// Connect to a websocket at the address specified by following from . + /// + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// A stream of raw messages from the websocket. public async Task> StreamTextAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) { return await WebSocketStreamAsync(entity, link, parameters, reader => reader.ReadToEnd(), cancellationToken); @@ -173,17 +328,13 @@ async Task HttpGetStringAsync(string url, CancellationToken cancellation { var request = new HttpRequestMessage(HttpMethod.Get, url); var stream = await HttpSendAsync(request, cancellationToken).ConfigureAwait(false); - return await new StreamReader(stream).ReadToEndAsync(); + using (var reader = new StreamReader(stream)) + return await reader.ReadToEndAsync(); } async Task HttpSendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) { - if (_apiKey != null) - request.Headers.Add("X-Seq-ApiKey", _apiKey); - - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(SeqApiV7MediaType)); - - var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var response = await HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); if (response.IsSuccessStatusCode) @@ -200,7 +351,7 @@ async Task HttpSendAsync(HttpRequestMessage request, CancellationToken c if (payload != null && payload.TryGetValue("Error", out var error) && error != null) throw new SeqApiException($"{(int)response.StatusCode} - {error}", response.StatusCode); - throw new SeqApiException($"The Seq request failed ({(int)response.StatusCode}).", response.StatusCode); + throw new SeqApiException($"The Seq request failed ({(int)response.StatusCode}/{response.StatusCode}).", response.StatusCode); } HttpContent MakeJsonContent(object content) @@ -212,34 +363,16 @@ HttpContent MakeJsonContent(object content) static string ResolveLink(ILinked entity, string link, IDictionary parameters = null) { - Link linkItem; - if (!entity.Links.TryGetValue(link, out linkItem)) + if (!entity.Links.TryGetValue(link, out var linkItem)) throw new NotSupportedException($"The requested link `{link}` isn't available on entity `{entity}`."); - var expression = linkItem.GetUri(); - var template = new UriTemplate(expression); - if (parameters != null) - { - var missing = parameters.Select(p => p.Key).Except(template.GetParameterNames()).ToArray(); - if (missing.Any()) - throw new ArgumentException($"The URI template `{expression}` does not contain parameter: `{string.Join("`, `", missing)}`."); - - foreach (var parameter in parameters) - { - var value = parameter.Value is DateTime time - ? time.ToString("O") - : parameter.Value; - - template.SetParameter(parameter.Key, value); - } - } - - return template.Resolve(); + return linkItem.GetUri(parameters); } + /// > public void Dispose() { - _httpClient.Dispose(); + HttpClient.Dispose(); } } } \ No newline at end of file diff --git a/src/Seq.Api/Client/SeqApiException.cs b/src/Seq.Api/Client/SeqApiException.cs index 71d5966..fc53da9 100644 --- a/src/Seq.Api/Client/SeqApiException.cs +++ b/src/Seq.Api/Client/SeqApiException.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Net; namespace Seq.Api.Client diff --git a/src/Seq.Api/Model/Entity.cs b/src/Seq.Api/Model/Entity.cs index b31f7c3..7d3a98f 100644 --- a/src/Seq.Api/Model/Entity.cs +++ b/src/Seq.Api/Model/Entity.cs @@ -1,14 +1,42 @@ -namespace Seq.Api.Model +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Seq.Api.Model { + /// + /// A uniquely-identifiable resource available from the Seq HTTP API. + /// public abstract class Entity : ILinked { + /// + /// Construct an . + /// protected Entity() { Links = new LinkCollection(); } + /// + /// The entity's unique identifier. This will be null if a newly-instantiated + /// has not yet been sent to the server. + /// public string Id { get; set; } + /// + /// A collection of outbound links from the entity. This will be empty if the entity + /// was instantiated locally and not received from the API. + /// public LinkCollection Links { get; set; } } } diff --git a/src/Seq.Api/Model/ILinked.cs b/src/Seq.Api/Model/ILinked.cs index df7dc62..ae30187 100644 --- a/src/Seq.Api/Model/ILinked.cs +++ b/src/Seq.Api/Model/ILinked.cs @@ -1,7 +1,27 @@ -namespace Seq.Api.Model +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Seq.Api.Model { + /// + /// A resource that carries outbound links. + /// public interface ILinked { + /// + /// Links associated with the resource. + /// LinkCollection Links { get; } } } \ No newline at end of file diff --git a/src/Seq.Api/Model/Link.cs b/src/Seq.Api/Model/Link.cs index b3a77d2..0513d68 100644 --- a/src/Seq.Api/Model/Link.cs +++ b/src/Seq.Api/Model/Link.cs @@ -1,50 +1,81 @@ -using System; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using Tavis.UriTemplates; +// ReSharper disable PossibleMultipleEnumeration + namespace Seq.Api.Model { - public class Link + /// + /// A hypermedia link. A link is an RFC 6570 URI template that can be + /// parameterized in order to produce a complete URI (if the template contains no + /// parameters then it may also be a literal URI). + /// + public struct Link { - readonly bool _isLiteral; - readonly string _href; - readonly IDictionary _parameters; - - public Link(string href, bool isLiteral = true) - : this(href, new Dictionary()) - { - _isLiteral = isLiteral; - } + /// + /// An empty link. + /// + public static Link Empty { get; } = default; - public Link(string href, IDictionary parameters) + /// + /// Construct a link. + /// + /// The URI template. + public Link(string template) { - if (href == null) throw new ArgumentNullException(nameof(href)); - if (parameters == null) throw new ArgumentNullException(nameof(parameters)); - _href = href; - _parameters = parameters; + Template = template ?? throw new ArgumentNullException(nameof(template)); } - public Link(string href, object parameters) - : this(href, parameters - .GetType() - .GetTypeInfo() - .DeclaredProperties - .ToDictionary(p => p.Name, p => p.GetValue(parameters))) - { - } + /// + /// Get the unprocessed URI template. + /// + public string Template { get; } - public string GetUri() + /// + /// Parameterize the link to construct a URI. + /// + /// Parameters to substitute into the template, if any. + /// A constructed URI. + /// This method ensures that templates containing parameters cannot be accidentally + /// used as URIs. + public string GetUri(IDictionary parameters = null) { - if (_isLiteral) return _href; + if (Template == null) throw new InvalidOperationException("Attempted to process an empty URI template."); - var template = new UriTemplate(_href); - foreach (var parameter in _parameters) + var template = new UriTemplate(Template); + if (parameters != null) { - template.SetParameter(parameter.Key, parameter.Value); + var missing = parameters.Select(p => p.Key).Except(template.GetParameterNames()); + if (missing.Any()) + throw new ArgumentException($"The URI template `{Template}` does not contain parameter: `{string.Join("`, `", missing)}`."); + + foreach (var parameter in parameters) + { + var value = parameter.Value is DateTime time + ? time.ToString("O") + : parameter.Value; + + template.SetParameter(parameter.Key, value); + } } + return template.Resolve(); } } -} \ No newline at end of file +} diff --git a/src/Seq.Api/Model/LinkCollection.cs b/src/Seq.Api/Model/LinkCollection.cs index e4afbf3..aebab05 100644 --- a/src/Seq.Api/Model/LinkCollection.cs +++ b/src/Seq.Api/Model/LinkCollection.cs @@ -1,18 +1,30 @@ -using System; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Collections.Generic; using Newtonsoft.Json; using Seq.Api.Serialization; namespace Seq.Api.Model { + /// + /// A collection of s indexed by case-insensitive name. + /// [JsonConverter(typeof(LinkCollectionConverter))] public class LinkCollection : Dictionary { public LinkCollection() : base(StringComparer.OrdinalIgnoreCase) { } - - public string GetUri(string name) - { - return this[name].GetUri(); - } } } diff --git a/src/Seq.Api/Model/ResourceGroup.cs b/src/Seq.Api/Model/ResourceGroup.cs index f634ddd..3837ca8 100644 --- a/src/Seq.Api/Model/ResourceGroup.cs +++ b/src/Seq.Api/Model/ResourceGroup.cs @@ -1,12 +1,34 @@ -namespace Seq.Api.Model +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Seq.Api.Model { + /// + /// A resource group is a logical partitioning of the API, usually + /// associated with a particular type of entity. + /// public class ResourceGroup : ILinked { + /// + /// Construct a . + /// public ResourceGroup() { Links = new LinkCollection(); } + /// public LinkCollection Links { get; set; } } } diff --git a/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs b/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs index cca68e7..c8b736c 100644 --- a/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs +++ b/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs @@ -34,6 +34,8 @@ public async Task FindAsync( if (id == null) throw new ArgumentNullException(nameof(id)); var parameters = new Dictionary {{"id", id}}; + if (render) parameters.Add("render", true); + if (permalinkId != null) parameters.Add("permalinkId", permalinkId); return await GroupGetAsync("Item", parameters, cancellationToken).ConfigureAwait(false); } diff --git a/src/Seq.Api/Seq.Api.csproj b/src/Seq.Api/Seq.Api.csproj index 677b359..11f7d45 100644 --- a/src/Seq.Api/Seq.Api.csproj +++ b/src/Seq.Api/Seq.Api.csproj @@ -1,24 +1,21 @@ - + Client library for the Seq HTTP API. - 5.1.0 + 5.1.1 Datalust;Contributors netstandard2.0 $(NoWarn);CS1591 true true - Seq.Api - Seq.Api seq Copyright © 2014-2019 Datalust Pty Ltd and Contributors https://datalust.co/images/seq-nuget.png https://github.com/datalust/seq-api - http://www.apache.org/licenses/LICENSE-2.0 - Seq.Api + Apache-2.0 7.3 - \ No newline at end of file + diff --git a/src/Seq.Api/SeqConnection.cs b/src/Seq.Api/SeqConnection.cs index 6d3e722..900968b 100644 --- a/src/Seq.Api/SeqConnection.cs +++ b/src/Seq.Api/SeqConnection.cs @@ -1,5 +1,20 @@ -using System; -using System.Collections.Concurrent; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Net; using System.Threading; using System.Threading.Tasks; using Seq.Api.Client; @@ -9,67 +24,201 @@ namespace Seq.Api { + /// + /// Exposes high-level (typed) interactions with the Seq API through various resource groups. + /// public class SeqConnection : ISeqConnection { - readonly ConcurrentDictionary> _resourceGroups = new ConcurrentDictionary>(); - readonly Lazy> _root; - + readonly object _sync = new object(); + readonly Dictionary> _resourceGroups = new Dictionary>(); + Task _root; + + /// + /// Construct a . + /// + /// The base URL of the Seq server. + /// An API key to use when making requests to the server, if required. + /// Whether default credentials will be sent with HTTP requests; the default is true. public SeqConnection(string serverUrl, string apiKey = null, bool useDefaultCredentials = true) { if (serverUrl == null) throw new ArgumentNullException(nameof(serverUrl)); Client = new SeqApiClient(serverUrl, apiKey, useDefaultCredentials); - - _root = new Lazy>(() => Client.GetRootAsync()); } + + /// + /// Access the lower-level that can be used for resource-oriented navigation through + /// the HTTP API. + /// + public SeqApiClient Client { get; } + /// + /// Perform operations on API keys. + /// public ApiKeysResourceGroup ApiKeys => new ApiKeysResourceGroup(this); + /// + /// Perform operations on Seq app instances. + /// public AppInstancesResourceGroup AppInstances => new AppInstancesResourceGroup(this); + /// + /// Perform operations on Seq app packages. + /// public AppsResourceGroup Apps => new AppsResourceGroup(this); + /// + /// Perform operations on backups. + /// public BackupsResourceGroup Backups => new BackupsResourceGroup(this); + /// + /// Perform operations on dashboards. + /// public DashboardsResourceGroup Dashboards => new DashboardsResourceGroup(this); + /// + /// Execute SQL-style queries against the API. + /// public DataResourceGroup Data => new DataResourceGroup(this); + /// + /// Access server diagnostics. + /// public DiagnosticsResourceGroup Diagnostics => new DiagnosticsResourceGroup(this); + /// + /// Read and subscribe to events from the event store. + /// public EventsResourceGroup Events => new EventsResourceGroup(this); + /// + /// Perform operations on queries and filter expressions. + /// public ExpressionsResourceGroup Expressions => new ExpressionsResourceGroup(this); + /// + /// Perform operations on NuGet feeds. + /// public FeedsResourceGroup Feeds => new FeedsResourceGroup(this); + /// + /// Perform operations on the Seq license certificate. + /// public LicensesResourceGroup Licenses => new LicensesResourceGroup(this); + /// + /// Perform operations on permalinked events. + /// public PermalinksResourceGroup Permalinks => new PermalinksResourceGroup(this); + /// + /// Perform operations on retention policies. + /// public RetentionPoliciesResourceGroup RetentionPolicies => new RetentionPoliciesResourceGroup(this); + /// + /// Perform operations on system settings. + /// public SettingsResourceGroup Settings => new SettingsResourceGroup(this); + /// + /// Perform operations on signals. + /// public SignalsResourceGroup Signals => new SignalsResourceGroup(this); + /// + /// Perform operations on saved SQL queries. + /// public SqlQueriesResourceGroup SqlQueries => new SqlQueriesResourceGroup(this); + /// + /// Perform operations on known available Seq versions. + /// public UpdatesResourceGroup Updates => new UpdatesResourceGroup(this); + /// + /// Perform operations on users. + /// public UsersResourceGroup Users => new UsersResourceGroup(this); + /// + /// Perform operations on workspaces. + /// public WorkspacesResourceGroup Workspaces => new WorkspacesResourceGroup(this); - public async Task LoadResourceGroupAsync(string name, CancellationToken cancellationToken = default) + /// + /// Check that the Seq API is available. If the initial attempt fails (i.e. the server is starting up), + /// the method will try every 100 milliseconds until is reached. + /// + /// The maximum amount of time to retry until giving up. + /// A task that will complete if the API could be reached, or fault otherwise. + public async Task EnsureConnected(TimeSpan timeout) { - return await _resourceGroups.GetOrAdd(name, s => ResourceGroupFactory(s, cancellationToken)).ConfigureAwait(false); + var started = DateTime.UtcNow; + // Fractional milliseconds are lost here, but that's fine. + var wait = TimeSpan.FromMilliseconds(Math.Min(100, timeout.TotalMilliseconds)); + var deadline = started.Add(timeout); + while (!await ConnectAsync(DateTime.UtcNow > deadline)) + { + await Task.Delay(wait); + } + } + + async Task ConnectAsync(bool throwOnFailure) + { + HttpStatusCode statusCode; + + try + { + statusCode = (await Client.HttpClient.GetAsync("api")).StatusCode; + } + catch + { + if (throwOnFailure) + throw; + + return false; + } + + if (statusCode == HttpStatusCode.OK) + return true; + + if (!throwOnFailure) + return false; + + throw new SeqApiException($"Could not connect to the Seq API endpoint: {(int)statusCode}/{statusCode}.", statusCode); } - async Task ResourceGroupFactory(string name, CancellationToken cancellationToken = default) + async Task ISeqConnection.LoadResourceGroupAsync(string name, CancellationToken cancellationToken) { - return await Client.GetAsync(await _root.Value, name + "Resources", cancellationToken: cancellationToken).ConfigureAwait(false); + Task loadRoot; + lock (_sync) + { + if (_root == null || _root.IsFaulted || _root.IsCanceled) + _root = Client.GetRootAsync(cancellationToken); + + loadRoot = _root; + } + + var rootEntity = await loadRoot.ConfigureAwait(false); + + Task loadGroup; + lock (_sync) + { + // ReSharper disable once InvertIf + if (!_resourceGroups.TryGetValue(name, out loadGroup) || loadGroup.IsFaulted || loadGroup.IsCanceled) + { + loadGroup = GetResourceGroup(rootEntity, name, cancellationToken); + _resourceGroups.Add(name, loadGroup); + } + } + + return await loadGroup.ConfigureAwait(false); } - public SeqApiClient Client { get; } + async Task GetResourceGroup(RootEntity root, string name, CancellationToken cancellationToken) + { + return await Client.GetAsync(root, name + "Resources", cancellationToken: cancellationToken).ConfigureAwait(false); + } } } diff --git a/src/Seq.Api/Serialization/LinkCollectionConverter.cs b/src/Seq.Api/Serialization/LinkCollectionConverter.cs index 01dc797..c560bed 100644 --- a/src/Seq.Api/Serialization/LinkCollectionConverter.cs +++ b/src/Seq.Api/Serialization/LinkCollectionConverter.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2014-2019 Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; @@ -6,12 +20,12 @@ namespace Seq.Api.Serialization { - public class LinkCollectionConverter : JsonConverter + class LinkCollectionConverter : JsonConverter { public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { var lc = (LinkCollection)value; - var dictionary = lc.ToDictionary(kv => kv.Key, kv => kv.Value.GetUri()); + var dictionary = lc.ToDictionary(kv => kv.Key, kv => kv.Value.Template); serializer.Serialize(writer, dictionary); } diff --git a/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs b/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs index e4380e3..f7f510b 100644 --- a/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs +++ b/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs @@ -12,6 +12,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + using System; namespace Seq.Api.Streams @@ -25,10 +26,8 @@ sealed class Unsubscriber : IDisposable public Unsubscriber(ObservableStream sink, IObserver observer) { - if (sink == null) throw new ArgumentNullException(nameof(sink)); - if (observer == null) throw new ArgumentNullException(nameof(observer)); - _stream = sink; - _observer = observer; + _stream = sink ?? throw new ArgumentNullException(nameof(sink)); + _observer = observer ?? throw new ArgumentNullException(nameof(observer)); } public void Dispose() diff --git a/src/Seq.Api/Streams/ObservableStream.cs b/src/Seq.Api/Streams/ObservableStream.cs index 0ddc419..2af4853 100644 --- a/src/Seq.Api/Streams/ObservableStream.cs +++ b/src/Seq.Api/Streams/ObservableStream.cs @@ -24,8 +24,10 @@ namespace Seq.Api.Streams { - // Some questionable synchronization in this one; probably better to take the Rx dependency - // and use the Rx support classes here. + /// + /// A stream of values read from a . + /// + /// The type of the values. public sealed partial class ObservableStream : IObservable, IDisposable { readonly object _syncRoot = new object(); @@ -42,6 +44,7 @@ internal ObservableStream(ClientWebSocket socket, Func deserializ _socket = socket ?? throw new ArgumentNullException(nameof(socket)); } + /// public IDisposable Subscribe(IObserver observer) { if (observer == null) throw new ArgumentNullException(nameof(observer)); @@ -182,6 +185,7 @@ static void OnError(IEnumerable exceptions) throw new AggregateException("At least one observer failed to accept the event", exceptions); } + /// public void Dispose() { lock (_syncRoot) diff --git a/test/Seq.Api.Tests/LinkTests.cs b/test/Seq.Api.Tests/LinkTests.cs new file mode 100644 index 0000000..ae6a636 --- /dev/null +++ b/test/Seq.Api.Tests/LinkTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using Seq.Api.Model; +using Xunit; + +namespace Seq.Api.Tests +{ + public class LinkTests + { + [Fact] + public void ALinkWithNoParametersIsLiteral() + { + const string uri = "https://example.com"; + var link = new Link(uri); + var constructed = link.GetUri(); + Assert.Equal(uri, constructed); + } + + [Fact] + public void AParameterizedLinkCanBeConstructed() + { + const string template = "https://example.com/{name}"; + var link = new Link(template); + var constructed = link.GetUri(new Dictionary {["name"] = "test"}); + Assert.Equal("https://example.com/test", constructed); + } + + [Fact] + public void InvalidParametersAreDetected() + { + const string template = "https://example.com"; + var link = new Link(template); + Assert.Throws(() => link.GetUri(new Dictionary {["name"] = "test"})); + } + } +}