From 1fcded8a89d02a744efc6f6ee4380ba7ab1a3923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Harrtell?= Date: Sat, 16 Mar 2019 14:57:34 +0100 Subject: [PATCH 1/2] Initial implementation to support bulk extension --- Saule/Constants.cs | 1 + Saule/Http/BulkExtRouteAttributeAttribute.cs | 71 ++++++++++++++++++++ Saule/Http/ReturnsResourceAttribute.cs | 2 +- Saule/Saule.csproj | 1 + Tests/Controllers/PeopleController.cs | 20 +++++- Tests/Integration/ContentNegotiationTests.cs | 34 ++++++++++ Tests/Properties/Resources.Designer.cs | 29 +++++++- Tests/Properties/Resources.resx | 21 ++++++ 8 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 Saule/Http/BulkExtRouteAttributeAttribute.cs diff --git a/Saule/Constants.cs b/Saule/Constants.cs index dc2982c..4931ebd 100644 --- a/Saule/Constants.cs +++ b/Saule/Constants.cs @@ -3,6 +3,7 @@ internal static class Constants { public const string MediaType = "application/vnd.api+json"; + public const string MediaTypeBulkExtension = "application/vnd.api+json; ext=bulk"; public static class PropertyNames { diff --git a/Saule/Http/BulkExtRouteAttributeAttribute.cs b/Saule/Http/BulkExtRouteAttributeAttribute.cs new file mode 100644 index 0000000..fad323b --- /dev/null +++ b/Saule/Http/BulkExtRouteAttributeAttribute.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Web.Http.Routing; + +namespace Saule.Http +{ + public class BulkExtRouteAttributeAttribute : RouteFactoryAttribute + { + public BulkExtRouteAttributeAttribute(string template, bool multiple) + : base(template) + { + Multiple = multiple; + } + + public override IDictionary Constraints + { + get + { + var constraints = new HttpRouteValueDictionary(); + if (Multiple) + { + constraints.Add("Content-Type", new ContentTypeConstraint(Constants.MediaTypeBulkExtension)); + } + else + { + constraints.Add("Content-Type", new ContentTypeConstraint(Constants.MediaType)); + } + + return constraints; + } + } + + public bool Multiple { get; private set; } + } + + internal class ContentTypeConstraint : IHttpRouteConstraint + { + public ContentTypeConstraint(string allowedMediaType) + { + AllowedMediaType = allowedMediaType; + } + + public string AllowedMediaType { get; private set; } + + public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary values, HttpRouteDirection routeDirection) + { + if (routeDirection == HttpRouteDirection.UriResolution) + { + return GetMediaHeader(request) == AllowedMediaType; + } + else + { + return true; + } + } + + private string GetMediaHeader(HttpRequestMessage request) + { + IEnumerable headerValues; + if (request.Content.Headers.TryGetValues("Content-Type", out headerValues) && headerValues.Count() == 1) + { + return headerValues.First(); + } + else + { + return "application/vnd.api+json"; + } + } + } +} diff --git a/Saule/Http/ReturnsResourceAttribute.cs b/Saule/Http/ReturnsResourceAttribute.cs index 4116acf..aade930 100644 --- a/Saule/Http/ReturnsResourceAttribute.cs +++ b/Saule/Http/ReturnsResourceAttribute.cs @@ -47,7 +47,7 @@ public override void OnActionExecuting(HttpActionContext actionContext) } var contentType = actionContext.Request.Content?.Headers?.ContentType; - if (contentType != null && contentType.Parameters.Any()) + if (contentType != null && contentType.Parameters.Where(p => p.Name != "ext").Any()) { // client is sending json api media type with parameters actionContext.Response = new HttpResponseMessage(HttpStatusCode.UnsupportedMediaType); diff --git a/Saule/Saule.csproj b/Saule/Saule.csproj index 67c68b1..c638fa6 100644 --- a/Saule/Saule.csproj +++ b/Saule/Saule.csproj @@ -69,6 +69,7 @@ + diff --git a/Tests/Controllers/PeopleController.cs b/Tests/Controllers/PeopleController.cs index 4218056..fb34164 100644 --- a/Tests/Controllers/PeopleController.cs +++ b/Tests/Controllers/PeopleController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Web.Http; using Saule.Http; @@ -123,7 +124,6 @@ public IEnumerable ManualTypedQueryAndPaginatePeople([FromUri] PersonFil return data; } - [HttpPost] [Route("people/{id}")] public Person PostPerson(string id, Person person) @@ -132,6 +132,22 @@ public Person PostPerson(string id, Person person) return person; } + [HttpPost] + [BulkExtRouteAttribute("people", multiple: false)] + public Person PostNewPerson(Person person) + { + person.Identifier = Guid.NewGuid().ToString(); + return person; + } + + [HttpPost] + [BulkExtRouteAttribute("people", multiple: true)] + public IEnumerable PostNewPeople(IEnumerable people) + { + var newPeople = people.Select(p => { p.Identifier = Guid.NewGuid().ToString(); return p; } ); + return newPeople; + } + [HttpGet] [Route("people")] public IEnumerable GetPeople() diff --git a/Tests/Integration/ContentNegotiationTests.cs b/Tests/Integration/ContentNegotiationTests.cs index 68caa57..698781e 100644 --- a/Tests/Integration/ContentNegotiationTests.cs +++ b/Tests/Integration/ContentNegotiationTests.cs @@ -18,6 +18,7 @@ public class ObsoleteSetup : IClassFixture private readonly ObsoleteSetupJsonApiServer _server; private readonly string _personContent = Properties.Resources.PersonResourceString; + private readonly string _peopleContent = Properties.Resources.PeopleResourceString; public ObsoleteSetup(ObsoleteSetupJsonApiServer server) { @@ -53,6 +54,39 @@ public async Task MustReturnJsonApiContentType(string path) Assert.Equal("application/vnd.api+json", result.Content.Headers.ContentType.MediaType); } + [Theory(DisplayName = "Servers MUST respond with '200 OK' to valid POST content")] + [InlineData(Paths.SingleResource)] + public async Task MustReturn200OKOnCreate(string path) + { + var target = _server.GetClient(); + var mediaType = new MediaTypeHeaderValue(Constants.MediaType); + + HttpContent content = new StringContent(_personContent); + content.Headers.ContentType = mediaType; + + var result = await target.PostAsync(path, content); + + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + } + + [Theory(DisplayName = "Servers MUST respond with '200 OK' to valid POST content for bulk extension")] + [InlineData(Paths.ResourceCollection)] + public async Task MustReturn200OKOnCreateBulk(string path) + { + var target = _server.GetClient(); + + var mediaType = new MediaTypeHeaderValue(Constants.MediaType); + mediaType.Parameters.Add(new NameValueHeaderValue("ext", "bulk")); + HttpContent content = new StringContent(_peopleContent); + content.Headers.ContentType = mediaType; + + var result = await target.PostAsync(path, content); + + var resultContent = await result.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + } + [Theory(DisplayName = "Servers MUST respond with '415 Not supported' to media type parameters in content-type header")] [InlineData("version", "1")] [InlineData("charset", "utf-8")] diff --git a/Tests/Properties/Resources.Designer.cs b/Tests/Properties/Resources.Designer.cs index 61b7a44..0c2c362 100644 --- a/Tests/Properties/Resources.Designer.cs +++ b/Tests/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Tests.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -60,6 +60,33 @@ internal class Resources { } } + /// + /// Looks up a localized string similar to { + /// "data": [{ + /// "type": "person", + /// "attributes": { + /// "first-name": "John", + /// "last-name": "Smith", + /// "age": 34, + /// "number-of-legs": 2 + /// } + /// }, { + /// "type": "person", + /// "attributes": { + /// "first-name": "Smith", + /// "last-name": "John", + /// "age": 33, + /// "number-of-legs": 2 + /// } + /// }] + ///}. + /// + internal static string PeopleResourceString { + get { + return ResourceManager.GetString("PeopleResourceString", resourceCulture); + } + } + /// /// Looks up a localized string similar to { /// "data": { diff --git a/Tests/Properties/Resources.resx b/Tests/Properties/Resources.resx index 9c77393..81ac958 100644 --- a/Tests/Properties/Resources.resx +++ b/Tests/Properties/Resources.resx @@ -117,6 +117,27 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + { + "data": [{ + "type": "person", + "attributes": { + "first-name": "John", + "last-name": "Smith", + "age": 34, + "number-of-legs": 2 + } + }, { + "type": "person", + "attributes": { + "first-name": "Smith", + "last-name": "John", + "age": 33, + "number-of-legs": 2 + } + }] +} + { "data": { From 0edb988bd02f5b6df342aa37c464e93de14673f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Harrtell?= Date: Sat, 16 Mar 2019 15:07:39 +0100 Subject: [PATCH 2/2] Refactor and supply required comments --- Saule/Http/BulkExtRouteAttributeAttribute.cs | 51 ++++++-------------- Saule/Http/ContentTypeConstraint.cs | 42 ++++++++++++++++ Saule/Saule.csproj | 1 + 3 files changed, 57 insertions(+), 37 deletions(-) create mode 100644 Saule/Http/ContentTypeConstraint.cs diff --git a/Saule/Http/BulkExtRouteAttributeAttribute.cs b/Saule/Http/BulkExtRouteAttributeAttribute.cs index fad323b..df2a4a7 100644 --- a/Saule/Http/BulkExtRouteAttributeAttribute.cs +++ b/Saule/Http/BulkExtRouteAttributeAttribute.cs @@ -1,18 +1,27 @@ using System.Collections.Generic; -using System.Linq; -using System.Net.Http; using System.Web.Http.Routing; namespace Saule.Http { + /// + /// Custom route attribute to support negotiating the same route depending on content type to support the bulk extension. + /// public class BulkExtRouteAttributeAttribute : RouteFactoryAttribute { + /// + /// Initializes a new instance of the class. + /// + /// Route name + /// Sets whether this route is standard JSON API or bulk extension enabled public BulkExtRouteAttributeAttribute(string template, bool multiple) : base(template) { Multiple = multiple; } + /// + /// Gets overriden constraints handling to select route based on the attribute. + /// public override IDictionary Constraints { get @@ -31,41 +40,9 @@ public BulkExtRouteAttributeAttribute(string template, bool multiple) } } + /// + /// Gets a value indicating whether this route is standard JSON API or bulk extension enabled + /// public bool Multiple { get; private set; } } - - internal class ContentTypeConstraint : IHttpRouteConstraint - { - public ContentTypeConstraint(string allowedMediaType) - { - AllowedMediaType = allowedMediaType; - } - - public string AllowedMediaType { get; private set; } - - public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary values, HttpRouteDirection routeDirection) - { - if (routeDirection == HttpRouteDirection.UriResolution) - { - return GetMediaHeader(request) == AllowedMediaType; - } - else - { - return true; - } - } - - private string GetMediaHeader(HttpRequestMessage request) - { - IEnumerable headerValues; - if (request.Content.Headers.TryGetValues("Content-Type", out headerValues) && headerValues.Count() == 1) - { - return headerValues.First(); - } - else - { - return "application/vnd.api+json"; - } - } - } } diff --git a/Saule/Http/ContentTypeConstraint.cs b/Saule/Http/ContentTypeConstraint.cs new file mode 100644 index 0000000..9a70df3 --- /dev/null +++ b/Saule/Http/ContentTypeConstraint.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Web.Http.Routing; + +namespace Saule.Http +{ + internal class ContentTypeConstraint : IHttpRouteConstraint + { + public ContentTypeConstraint(string allowedMediaType) + { + AllowedMediaType = allowedMediaType; + } + + public string AllowedMediaType { get; private set; } + + public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary values, HttpRouteDirection routeDirection) + { + if (routeDirection == HttpRouteDirection.UriResolution) + { + return GetMediaHeader(request) == AllowedMediaType; + } + else + { + return true; + } + } + + private string GetMediaHeader(HttpRequestMessage request) + { + IEnumerable headerValues; + if (request.Content.Headers.TryGetValues("Content-Type", out headerValues) && headerValues.Count() == 1) + { + return headerValues.First(); + } + else + { + return "application/vnd.api+json"; + } + } + } +} diff --git a/Saule/Saule.csproj b/Saule/Saule.csproj index c638fa6..6146d4f 100644 --- a/Saule/Saule.csproj +++ b/Saule/Saule.csproj @@ -64,6 +64,7 @@ +