From a48efe55dd9516d7748adf3c0cb2d8631ba606e7 Mon Sep 17 00:00:00 2001 From: Matthew Adams Date: Mon, 26 Sep 2022 15:57:40 +0100 Subject: [PATCH] Added a README and a test for the low-level extraction API. --- README.md | 236 +++++++++++++++++- .../packages.lock.json | 20 ++ .../UsageTests.cs | 83 ++++-- .../Corvus.UriTemplates/ParameterCache.cs | 170 +++++++++++++ 4 files changed, 488 insertions(+), 21 deletions(-) create mode 100644 Solutions/Corvus.UriTemplates/Corvus.UriTemplates/ParameterCache.cs diff --git a/README.md b/README.md index 594b9ed..d88b816 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,236 @@ # Corvus.UriTemplates -Low-allocation URI Template parsing and resolution, supporting the Tavis.UriTemplates API. +Low-allocation URI Template parsing and resolution, supporting the Tavis.UriTemplates API + +This is a netstandard2.1 and net7.0+ implementation of the [URI Template Spec RFC6570](http://tools.ietf.org/html/rfc6570). + +The library implements Level 4 compliance and is tested against test cases from [UriTemplate test suite](https://github.com/uri-templates/uritemplate-test). + +## Introduction + +This library provides tools for low-allocation URI Template parameter extraction (via `IUriTemplateParser`) and URI Template resolution (via `UriTemplateResolver`). + +We then implement a drop-in replacement for the API supported by [Tavis.UriTemplates](https://github.com/tavis-software/Tavis.UriTemplates), with lower allocations and higher performance. + +## Performance + +There is a standard benchmark testing basic parameter extraction and resolution for the original Tavis.UriTemplate, the updated Corvus.UriTemplates.TavisApi.UriTemplate and the underlying zero-allocation URI template parser. + +As you can see, there is a significant benefit to using the Corvus implementation, even without dropping down the low-level zero allocation API. + +### Apply parameters to a URI template to resolve a URI +| Method | Mean | Error | Ratio | Gen0 | Allocated | Alloc Ratio | +|---------------------- |---------:|------:|------:|-------:|----------:|------------:| +| ResolveUriTavis | 694.0 ns | NA | 1.00 | 0.4377 | 1832 B | 1.00 | +| ResolveUriCorvusTavis | 640.5 ns | NA | 0.92 | 0.0515 | 216 B | 0.12 | +| ResolveUriCorvus | 214.9 ns | NA | 0.31 | - | - | 0.00 | + +### Extract parameters from a URI by using a URI template +| Method | Mean | Error | Ratio | Gen0 | Allocated | Alloc Ratio | +|----------------------------- |---------:|------:|------:|-------:|----------:|------------:| +| ExtractParametersTavis | 980.6 ns | NA | 1.00 | 0.2613 | 1096 B | 1.00 | +| ExtractParametersCorvusTavis | 495.2 ns | NA | 0.50 | 0.1450 | 608 B | 0.55 | +| ExtractParametersCorvus | 174.6 ns | NA | 0.18 | - | - | 0.00 | + +## Parameter Extraction + +### Using the Tavis API + +```csharp +UriTemplate template = new("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId={parentRequestId}{&hash,callback}"); + +Uri uri = new ("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId=123232323&hash=23ADE34FAE&callback=http%3A%2F%2Fexample.com%2Fcallback"); + +IDictionary? result = template.GetParameters(uri); +``` + +### Using the low-allocation API directly + +The lowest-level access makes use of a callback, which is fed the parameters as they are found. + +If the `reset` flag is set, you should disregard any parameters that have previously been sent, and start again. (This is typically the case where a partial match fails, and is restarted.) + +In order to manage the cache/reset process for you, we provide a `ParameterCache` type. You can rent an instance, and use it to accumulate the results for you. You can then enumerate the result set, and return the resource that have been rented for you. + +```csharp +var state = ParameterCache.Rent(5); +IUriTemplateParser corvusTemplate = UriTemplateParserFactory.CreateParser("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId={parentRequestId}{&hash,callback}"); + +corvusTemplate!.ParseUri("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId=123232323&hash=23ADE34FAE&callback=http%3A%2F%2Fexample.com%2Fcallback", ParameterCache.HandleParameters, ref state); + +state.EnumerateParameters(HandleFinalParameterSet); + +state.Return(); + +void HandleFinalParameterSet(ReadOnlySpan name, ReadOnlySpan value) +{ + if (name.SequenceEqual("parentRequestId")) + { + Assert.True(value.SequenceEqual("123232323"), $"parentRequestId was {value}"); + count++; + } + else if (name.SequenceEqual("hash")) + { + Assert.True(value.SequenceEqual("23ADE34FAE"), $"hash was {value}"); + count++; + } + else if (name.SequenceEqual("callback")) + { + Assert.True(value.SequenceEqual("http%3A%2F%2Fexample.com%2Fcallback"), $"callback was {value}"); + count++; + } + else + { + Assert.True(false, $"Unexpected parameter: (name: '{name}', value: '{value}')"); + } +} +``` + + +## URI Resolution + +### Using the Tavis API + +Replacing a path segment parameter, + +```csharp +[Fact] +public void UpdatePathParameter() +{ + var url = new UriTemplate("http://example.org/{tenant}/customers") + .AddParameter("tenant", "acmé") + .Resolve(); + + Assert.Equal("http://example.org/acm%C3%A9/customers", url); +} +``` + +Setting query string parameters, + +```csharp +[Fact] +public void ShouldResolveUriTemplateWithNonStringParameter() +{ + var url = new UriTemplate("http://example.org/location{?lat,lng}") + .AddParameters(new { lat = 31.464, lng = 74.386 }) + .Resolve(); + + Assert.Equal("http://example.org/location?lat=31.464&lng=74.386", url); +} +``` + + +Resolving a URI when parameters are not set will simply remove the parameters, + +```csharp +[Fact] +public void SomeParametersFromAnObject() +{ + var url = new UriTemplate("http://example.org{/environment}{/version}/customers{?active,country}") + .AddParameters(new + { + version = "v2", + active = "true" + }) + .Resolve(); + + Assert.Equal("http://example.org/v2/customers?active=true", url); +} +``` + +You can even pass lists as parameters + +```csharp +[Fact] +public void ApplyParametersObjectWithAListofInts() +{ + var url = new UriTemplate("http://example.org/customers{?ids,order}") + .AddParameters(new + { + order = "up", + ids = new[] {21, 75, 21} + }) + .Resolve(); + + Assert.Equal("http://example.org/customers?ids=21,75,21&order=up", url); +} +``` + +And dictionaries, + +```csharp +[Fact] +public void ApplyDictionaryToQueryParameters() +{ + var url = new UriTemplate("http://example.org/foo{?coords*}") + .AddParameter("coords", new Dictionary + { + {"x", "1"}, + {"y", "2"}, + }) + .Resolve(); + + Assert.Equal("http://example.org/foo?x=1&y=2", url); +} +``` + +We also handle all the complex URI encoding rules automatically. + +```csharp +[Fact] +public void TestExtremeEncoding() +{ + var url = new UriTemplate("http://example.org/sparql{?query}") + .AddParameter("query", "PREFIX dc: SELECT ?book ?who WHERE { ?book dc:creator ?who }") + .Resolve(); + Assert.Equal("http://example.org/sparql?query=PREFIX%20dc%3A%20%3Chttp%3A%2F%2Fpurl.org%2Fdc%2Felements%2F1.1%2F%3E%20SELECT%20%3Fbook%20%3Fwho%20WHERE%20%7B%20%3Fbook%20dc%3Acreator%20%3Fwho%20%7D", url); +} +``` + +### Using the low-allocation API directly + +The low-allocation library provides a generic class `UriTemplateResolver` for URI template resolution. + +The `TParameterProvider` is a type which implements `ITemplateParameterProvider`, to process a parameter payload according to a variable specification. + +This allows you to process parameters as efficiently as possible, based on the types you need to support. + +The benchmarks contain an example built over the low-allocation [Corvus.JsonSchema.ExtendedTypes](https://github.com/corvus-dotnet/Corvus.JsonSchema) called `JsonTemplateParameterProvider` that takes a parameter set based on a JSON object, supporting all JSON element types as parameter values. + +```csharp +object? nullState = default; +JsonUriTemplateResolver.TryResolveResult(UriTemplate.AsSpan(), false, JsonValues, HandleResult, ref nullState); +static void HandleResult(ReadOnlySpan resolvedTemplate, ref object? state) +{ + Do what you want with the resolved template! +} +``` + +There are also overloads of `TryResolveResult` which will write to an `IBufferWriter` instead of providing the `ReadOnlySpan` to a callback. + + +## Build and test + +As well as having a set of regular usage tests, this library also executes tests based on a standard test suite. This test suite is pulled in as a Git Submodule, therefore when cloning this repo, you will need use the `--recursive` switch. + +The `./uritemplate-test` folder is a submodule pointing to that test suite repo. + +When cloning this repository it is important to clone submodules, because test projects in this repository depend on that submodule being present. If you've already cloned the project, and haven't yet got the submodules, run this command: + +``` +git submodule update --init --recursive +``` + +Note that git pull does not automatically update submodules, so if git pull reports that any submodules have changed, you can use the preceding command again, used to update the existing submodule reference. + +When updating to newer versions of the test suite, we can update the submodule reference thus: + +``` +cd uritemplate-test +git fetch +git merge origin/master +cd .. +git commit - "Updated to latest URI Template Test Suite" +``` + +(Or you can use `git submodule update --remote` instead of cding into the submodule folder and updating from there.) + diff --git a/Solutions/Corvus.UriTemplate.Benchmarking/packages.lock.json b/Solutions/Corvus.UriTemplate.Benchmarking/packages.lock.json index d4010b9..3158952 100644 --- a/Solutions/Corvus.UriTemplate.Benchmarking/packages.lock.json +++ b/Solutions/Corvus.UriTemplate.Benchmarking/packages.lock.json @@ -58,6 +58,21 @@ "Microsoft.SourceLink.GitHub": "1.1.1" } }, + "Roslynator.Analyzers": { + "type": "Direct", + "requested": "[4.1.1, )", + "resolved": "4.1.1", + "contentHash": "3cPVlrB1PytlO1ztZZBOExDKQWpMZgI15ZDa0BqLu0l6xv+xIRfEpqjNRcpvUy3aLxWTkPgSKZbbaO+VoFEJ1g==" + }, + "StyleCop.Analyzers": { + "type": "Direct", + "requested": "[1.2.0-beta.435, )", + "resolved": "1.2.0-beta.435", + "contentHash": "TADk7vdGXtfTnYCV7GyleaaRTQjfoSfZXprQrVMm7cSJtJbFc1QIbWPyLvrgrfGdfHbGmUPvaN4ODKNxg2jgPQ==", + "dependencies": { + "StyleCop.Analyzers.Unstable": "1.2.0.435" + } + }, "Tavis.UriTemplates": { "type": "Direct", "requested": "[1.1.1, )", @@ -368,6 +383,11 @@ "Microsoft.NETCore.Targets": "1.0.1" } }, + "StyleCop.Analyzers.Unstable": { + "type": "Transitive", + "resolved": "1.2.0.435", + "contentHash": "ouwPWZxbOV3SmCZxIRqHvljkSzkCyi1tDoMzQtDb/bRP8ctASV/iRJr+A2Gdj0QLaLmWnqTWDrH82/iP+X80Lg==" + }, "System.AppContext": { "type": "Transitive", "resolved": "4.1.0", diff --git a/Solutions/Corvus.UriTemplate.TavisApi.Tests/UsageTests.cs b/Solutions/Corvus.UriTemplate.TavisApi.Tests/UsageTests.cs index 2970b5d..90f4814 100644 --- a/Solutions/Corvus.UriTemplate.TavisApi.Tests/UsageTests.cs +++ b/Solutions/Corvus.UriTemplate.TavisApi.Tests/UsageTests.cs @@ -1,12 +1,54 @@ using System; using System.Collections.Generic; +using Corvus.UriTemplates; using Corvus.UriTemplates.TavisApi; using Xunit; namespace UriTemplateTests { public class UsageTests - { + { + [Fact] + public void ShouldRetrieveParameters() + { + var state = ParameterCache.Rent(5); + IUriTemplateParser corvusTemplate = UriTemplateParserFactory.CreateParser("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId={parentRequestId}{&hash,callback}"); + + corvusTemplate!.ParseUri("http://example.com/Glimpse.axd?n=glimpse_ajax&parentRequestId=123232323&hash=23ADE34FAE&callback=http%3A%2F%2Fexample.com%2Fcallback", ParameterCache.HandleParameters, ref state); + + int count = 0; + + state.EnumerateParameters(Callback); + + Assert.Equal(3, count); + + state.Return(); + + void Callback(ReadOnlySpan name, ReadOnlySpan value) + { + if (name.SequenceEqual("parentRequestId")) + { + Assert.True(value.SequenceEqual("123232323"), $"parentRequestId was {value}"); + count++; + } + else if (name.SequenceEqual("hash")) + { + Assert.True(value.SequenceEqual("23ADE34FAE"), $"hash was {value}"); + count++; + } + else if (name.SequenceEqual("callback")) + { + Assert.True(value.SequenceEqual("http%3A%2F%2Fexample.com%2Fcallback"), $"callback was {value}"); + count++; + } + else + { + Assert.True(false, $"Unexpected parameter: (name: '{name}', value: '{value}')"); + } + } + } + + [Fact] public void ShouldAllowUriTemplateWithPathSegmentParameter() { @@ -37,7 +79,7 @@ public void ShouldResolveUriTemplateWithNonStringParameter() template.SetParameter("bar", "yo"); template.SetParameter("lat", lat); template.SetParameter("lng", lng); - + var uriString = template.Resolve(); Assert.Equal("http://example.org/foo/yo/baz?lat=31.464&lng=74.386", uriString); } @@ -89,8 +131,8 @@ public void ShouldAllowUriTemplateToRemoveParameter() var uriString = template.Resolve(); Assert.Equal("http://example.org/foo?baz=yuck", uriString); } - - + + [Fact] public void ShouldAllowUriTemplateWithQueryParamsWithOneValue() { @@ -117,7 +159,7 @@ public void QueryParametersFromDictionary() var template = new UriTemplate("http://example.org/customers{?query*}"); template.SetParameter("query", new Dictionary() { - {"active","true"}, + {"active","true"}, {"Country","Brazil"} }); var uriString = template.Resolve(); @@ -198,7 +240,7 @@ public void Query_param_with_exploded_array() { UriTemplate template = new UriTemplate("/foo/{foo}/baz{?haz*}"); template.SetParameter("foo", "1234"); - template.SetParameter("haz", new string[] { "foo","bar" }); + template.SetParameter("haz", new string[] { "foo", "bar" }); string uri = template.Resolve(); @@ -222,11 +264,11 @@ public void Query_param_with_empty_array() { UriTemplate template = new UriTemplate("/foo/{foo}/baz{?haz*}"); template.SetParameter("foo", "1234"); - template.SetParameter("haz", new string[] {}); + template.SetParameter("haz", new string[] { }); string uri = template.Resolve(); - Assert.Equal("/foo/1234/baz",uri); + Assert.Equal("/foo/1234/baz", uri); } [Fact] @@ -246,14 +288,14 @@ public void ReservedCharacterExpansion() { UriTemplate template = new UriTemplate("https://foo.com/{?format}"); template.SetParameter("format", "application/vnd.foo+xml"); - + var result = template.Resolve(); - Assert.Equal("https://foo.com/?format=application%2Fvnd.foo%2Bxml",result); - + Assert.Equal("https://foo.com/?format=application%2Fvnd.foo%2Bxml", result); + } - [Fact(Skip = "Unit tests should not require internet access!!")] + [Fact(Skip = "Unit tests should not require internet access!!")] public void PreserveReservedCharacterExpansion() { UriTemplate template = new UriTemplate("https://foo.com/?format={+format}"); @@ -262,7 +304,7 @@ public void PreserveReservedCharacterExpansion() var result = template.Resolve(); Assert.Equal("https://foo.com/?format=application/vnd.foo+xml", result); - + } [Fact] @@ -272,7 +314,7 @@ public void ShouldSupportUnicodeCharacters() template.SetParameter("Stra%C3%9Fe", "Grüner Weg"); var result = template.Resolve(); - + Assert.Equal("/lookup?Stra%C3%9Fe=Gr%C3%BCner%20Weg", result); @@ -314,7 +356,7 @@ public void EncodingTest1() { var url = new UriTemplate("/1/search/auto/{folder}{?query}") - .AddParameter("folder","My Documents") + .AddParameter("folder", "My Documents") .AddParameter("query", "draft 2013") .Resolve(); @@ -342,7 +384,7 @@ public void EncodingTest2() // If you truly want to make multiple path segments then do this var url3 = new UriTemplate("{/greeting*}") - .AddParameter("greeting", new List {"hello","world"}) + .AddParameter("greeting", new List { "hello", "world" }) .Resolve(); Assert.Equal("/hello/world", url3); @@ -362,7 +404,7 @@ public void EncodingTest3() // There are different ways that lists can be included in query params // Just as a comma delimited list var url = new UriTemplate("/docs/salary.csv{?columns}") - .AddParameter("columns", new List {1,2}) + .AddParameter("columns", new List { 1, 2 }) .Resolve(); Assert.Equal("/docs/salary.csv?columns=1,2", url); @@ -379,7 +421,7 @@ public void EncodingTest3() public void EncodingTest4() { var url = new UriTemplate("/emails{?params*}") - .AddParameter("params", new Dictionary + .AddParameter("params", new Dictionary { {"from[name]","Don"}, {"from[date]","1998-03-24"}, @@ -410,13 +452,14 @@ public void InvalidSpace() try { var url = new UriTemplate("/feeds/events{? fromId").Resolve(); - } catch (Exception e) + } + catch (Exception e) { ex = e; } Assert.NotNull(ex); } - + } } \ No newline at end of file diff --git a/Solutions/Corvus.UriTemplates/Corvus.UriTemplates/ParameterCache.cs b/Solutions/Corvus.UriTemplates/Corvus.UriTemplates/ParameterCache.cs new file mode 100644 index 0000000..304df15 --- /dev/null +++ b/Solutions/Corvus.UriTemplates/Corvus.UriTemplates/ParameterCache.cs @@ -0,0 +1,170 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Buffers; +using CommunityToolkit.HighPerformance; + +namespace Corvus.UriTemplates; + +/// +/// A cache for parameters extracted from a URI template. +/// +public struct ParameterCache +{ + private readonly int bufferIncrement; + private CacheEntry[] items; + private int written; + private bool returned = false; + + private ParameterCache(int initialCapacity) + { + this.bufferIncrement = initialCapacity; + this.items = ArrayPool.Shared.Rent(initialCapacity); + this.written = 0; + } + + /// + /// A callback for enumerating parameters from the cache. + /// + /// The name of the parameter. + /// The value of the parameter. + public delegate void ParameterCacheCallback(ReadOnlySpan name, ReadOnlySpan value); + + /// + /// Rent an instance of a parameter cache. + /// + /// The initial capacity of the cache. + /// An instance of a parameter cache. + /// When you have finished with the cache, call to relinquish any internal resources. + public static ParameterCache Rent(int initialCapacity) + { + return new(initialCapacity); + } + + /// + /// A parameter handler for . + /// + /// Indicates whether to reset the parameter cache, ignoring any parameters that have been seen. + /// The name of the parameter. + /// The value of the parameter. + /// The parameter cache. + public static void HandleParameters(bool reset, ReadOnlySpan name, ReadOnlySpan value, ref ParameterCache state) + { + if (!reset) + { + state.Add(name, value); + } + else + { + state.Reset(); + } + } + + /// + /// Reset the items written. + /// + public void Reset() + { + this.ResetItems(); + this.written = 0; + } + + /// + /// Enumerate the parameters in the cache. + /// + /// The callback to recieve the enumerated parameters. + public void EnumerateParameters(ParameterCacheCallback callback) + { + if (this.items is not null) + { + for (int i = 0; i < this.written; ++i) + { + CacheEntry item = this.items[i]; + callback(item.Name.AsSpan(0, item.NameLength), item.Value.AsSpan(0, item.ValueLength)); + } + } + } + + /// + /// Add a parameter to the cache. + /// + /// The name of the parameter to add. + /// The value of the parameter to add. + public void Add(ReadOnlySpan name, ReadOnlySpan value) + { + char[] nameArray = name.Length > 0 ? ArrayPool.Shared.Rent(name.Length) : Array.Empty(); + name.CopyTo(nameArray); + char[] valueArray = value.Length > 0 ? ArrayPool.Shared.Rent(value.Length) : Array.Empty(); + value.CopyTo(valueArray); + + if (this.written == this.items.Length) + { + ArrayPool.Shared.Resize(ref this.items, this.items.Length + this.bufferIncrement); + } + + this.items[this.written] = (nameArray, name.Length, valueArray, value.Length); + this.written++; + } + + /// + /// Return the resources used by the cache. + /// + public void Return() + { + if (!this.returned) + { + this.ResetItems(); + ArrayPool.Shared.Return(this.items); + this.returned = true; + } + } + + private void ResetItems() + { + for (int i = 0; i < this.written; ++i) + { + CacheEntry item = this.items[i]; + if (item.Name.Length > 0) + { + item.Name.AsSpan().Clear(); + ArrayPool.Shared.Return(item.Name); + } + + if (item.Value.Length > 0) + { + item.Value.AsSpan().Clear(); + ArrayPool.Shared.Return(item.Value); + } + } + } + + private readonly struct CacheEntry + { + public CacheEntry(char[] name, int nameLength, char[] value, int valueLength) + { + this.Name = name; + this.NameLength = nameLength; + this.Value = value; + this.ValueLength = valueLength; + } + + public char[] Name { get; } + + public int NameLength { get; } + + public char[] Value { get; } + + public int ValueLength { get; } + + public static implicit operator (char[] Name, int NameLength, char[] Value, int ValueLength)(CacheEntry value) + { + return (value.Name, value.NameLength, value.Value, value.ValueLength); + } + + public static implicit operator CacheEntry((char[] Name, int NameLength, char[] Value, int ValueLength) value) + { + return new CacheEntry(value.Name, value.NameLength, value.Value, value.ValueLength); + } + } +} \ No newline at end of file