diff --git a/README.md b/README.md index 14b88c90b4..f7b068f988 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ JsonApiDotnetCore provides a framework for building [json:api](http://jsonapi.or - [Filtering](#filtering) - [Sorting](#sorting) - [Meta](#meta) + - [Client Generated Ids](#client-generated-ids) - [Tests](#tests) ## Comprehensive Demo @@ -342,6 +343,20 @@ public class Person : Identifiable, IHasMeta } ``` +### Client Generated Ids + +By default, the server will respond with a `403 Forbidden` HTTP Status Code if a `POST` request is +received with a client generated id. However, this can be allowed by setting the `AllowClientGeneratedIds` +flag in the options: + +```csharp +services.AddJsonApi(opt => +{ + opt.AllowClientGeneratedIds = true; + // .. +}); +``` + ## Tests I am using DotNetCoreDocs to generate sample requests and documentation. diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 8285921fa2..3993823d98 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -5,5 +5,6 @@ public class JsonApiOptions public string Namespace { get; set; } public int DefaultPageSize { get; set; } public bool IncludeTotalRecordCount { get; set; } + public bool AllowClientGeneratedIds { get; set; } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index ee08975a2d..044338f36c 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -138,7 +138,7 @@ public virtual async Task PostAsync([FromBody] T entity) return UnprocessableEntity(); } - if (!string.IsNullOrEmpty(entity.StringId)) + if (!_jsonApiContext.Options.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) return Forbidden(); await _entities.CreateAsync(entity); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index 4ab19e04d8..25e2e3d6fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -17,6 +17,8 @@ using System.Collections.Generic; using System.Linq; using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCoreExampleTests.Startups; +using System; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -37,7 +39,7 @@ public CreatingDataTests(DocsFixture fixture) } [Fact] - public async Task Can_Create_Guid_Identifiable_Entities() + public async Task Can_Create_Guid_Identifiable_Entity() { // arrange var builder = new WebHostBuilder() @@ -74,7 +76,7 @@ public async Task Can_Create_Guid_Identifiable_Entities() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - + // act var response = await client.SendAsync(request); @@ -83,9 +85,10 @@ public async Task Can_Create_Guid_Identifiable_Entities() } [Fact] - public async Task Request_With_ClientGeneratedId_Returns_403() + public async Task Cannot_Create_Entity_With_Client_Generate_Id() { // arrange + var context = _fixture.GetService(); var builder = new WebHostBuilder() .UseStartup(); var httpMethod = new HttpMethod("POST"); @@ -94,12 +97,13 @@ public async Task Request_With_ClientGeneratedId_Returns_403() var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var todoItem = _todoItemFaker.Generate(); + const int clientDefinedId = 9999; var content = new { data = new { type = "todo-items", - id = "9999", + id = $"{clientDefinedId}", attributes = new { description = todoItem.Description, @@ -107,9 +111,10 @@ public async Task Request_With_ClientGeneratedId_Returns_403() } } }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - + // act var response = await client.SendAsync(request); @@ -117,6 +122,99 @@ public async Task Request_With_ClientGeneratedId_Returns_403() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task Can_Create_Entity_With_Client_Defined_Id_If_Configured() + { + // arrange + var context = _fixture.GetService(); + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todo-items"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var todoItem = _todoItemFaker.Generate(); + const int clientDefinedId = 9999; + var content = new + { + data = new + { + type = "todo-items", + id = $"{clientDefinedId}", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(clientDefinedId, deserializedBody.Id); + } + + + [Fact] + public async Task Can_Create_Guid_Identifiable_Entity_With_Client_Defined_Id_If_Configured() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var context = _fixture.GetService(); + + var owner = new JsonApiDotNetCoreExample.Models.Person(); + context.People.Add(owner); + await context.SaveChangesAsync(); + + var route = "/api/v1/todo-item-collections"; + var request = new HttpRequestMessage(httpMethod, route); + var clientDefinedId = Guid.NewGuid(); + var content = new + { + data = new + { + type = "todo-item-collections", + id = $"{clientDefinedId}", + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = owner.Id.ToString() + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItemCollection)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(clientDefinedId, deserializedBody.Id); + } + [Fact] public async Task Can_Create_And_Set_HasMany_Relationships() { @@ -167,14 +265,14 @@ public async Task Can_Create_And_Set_HasMany_Relationships() request.Content = new StringContent(JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - + // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); var deserializedBody = (TodoItemCollection)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); var newId = deserializedBody.Id; var contextCollection = context.TodoItemCollections - .Include(c=> c.Owner) + .Include(c => c.Owner) .Include(c => c.TodoItems) .SingleOrDefault(c => c.Id == newId); @@ -210,7 +308,7 @@ public async Task ShouldReceiveLocationHeader_InResponse() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - + // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); @@ -247,7 +345,7 @@ public async Task Respond_409_ToIncorrectEntityType() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - + // act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/ClientGeneratedIdsStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/ClientGeneratedIdsStartup.cs new file mode 100644 index 0000000000..30a8dca6fa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/ClientGeneratedIdsStartup.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Extensions; +using DotNetCoreDocs.Configuration; +using System; +using JsonApiDotNetCoreExample; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public class ClientGeneratedIdsStartup : Startup + { + public ClientGeneratedIdsStartup(IHostingEnvironment env) + : base (env) + { } + + public override IServiceProvider ConfigureServices(IServiceCollection services) + { + var loggerFactory = new LoggerFactory(); + + loggerFactory + .AddConsole(LogLevel.Trace); + + services.AddSingleton(loggerFactory); + + services.AddDbContext(options => + { + options.UseNpgsql(GetDbConnectionString()); + }, ServiceLifetime.Transient); + + services.AddJsonApi(opt => + { + opt.Namespace = "api/v1"; + opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; + opt.AllowClientGeneratedIds = true; + }); + + services.AddDocumentationConfiguration(Config); + + return services.BuildServiceProvider(); + } + } +}