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..df2a4a7 --- /dev/null +++ b/Saule/Http/BulkExtRouteAttributeAttribute.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +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 + { + 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; + } + } + + /// + /// Gets a value indicating whether this route is standard JSON API or bulk extension enabled + /// + public bool Multiple { get; private set; } + } +} 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/Http/ReturnsResourceAttribute.cs b/Saule/Http/ReturnsResourceAttribute.cs index bfe382f..1196736 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 c720ae5..9dcabef 100644 --- a/Saule/Saule.csproj +++ b/Saule/Saule.csproj @@ -62,11 +62,13 @@ + + 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 06668a6..0c2c362 100644 --- a/Tests/Properties/Resources.Designer.cs +++ b/Tests/Properties/Resources.Designer.cs @@ -60,6 +60,33 @@ internal 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": {