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": {