diff --git a/README.md b/README.md index 1bcea71739..cbd344b80f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,24 @@ public class Person : Identifiable { } ``` +You can use the non-generic `Identifiable` if your primary key is an integer: + +```csharp +public class Person : Identifiable +{ } +``` + +If you need to hang annotations or attributes on the `Id` property, you can override the virtual member: + +```csharp +public class Person : Identifiable +{ + [Key] + [Column("person_id")] + public override int Id { get; set; } +} +``` + #### Specifying Public Attributes If you want an attribute on your model to be publicly available, diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 073c6aa846..6a4723e77a 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,4 +1,3 @@ -using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -139,9 +138,13 @@ public virtual async Task PostAsync([FromBody] T entity) return UnprocessableEntity(); } + var stringId = entity.Id.ToString(); + if(stringId.Length > 0 && stringId != "0") + return Forbidden(); + await _entities.CreateAsync(entity); - return Created(HttpContext.Request.Path, entity); + return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); } [HttpPatch("{id}")] @@ -155,9 +158,41 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) var updatedEntity = await _entities.UpdateAsync(id, entity); + if(updatedEntity == null) return NotFound(); + return Ok(updatedEntity); } + [HttpPatch("{id}/relationships/{relationshipName}")] + public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + { + relationshipName = _jsonApiContext.ContextGraph + .GetRelationshipName(relationshipName.ToProperCase()); + + if (relationshipName == null) + { + _logger?.LogInformation($"Relationship name not specified returning 422"); + return UnprocessableEntity(); + } + + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + + if (entity == null) + return NotFound(); + + var relationship = _jsonApiContext.ContextGraph + .GetContextEntity(typeof(T)) + .Relationships + .FirstOrDefault(r => r.RelationshipName == relationshipName); + + var relationshipIds = relationships.Select(r=>r.Id); + + await _entities.UpdateRelationshipsAsync(entity, relationship, relationshipIds); + + return Ok(); + + } + [HttpDelete("{id}")] public virtual async Task DeleteAsync(TId id) { diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index eedd1444fa..b97b4e135a 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -11,5 +11,10 @@ protected IActionResult UnprocessableEntity() { return new StatusCodeResult(422); } + + protected IActionResult Forbidden() + { + return new StatusCodeResult(403); + } } } diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index f95b6ad8a8..95d8c1a0b2 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -105,7 +105,13 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) await _context.SaveChangesAsync(); - return oldEntity; + return oldEntity; + } + + public async Task UpdateRelationshipsAsync(object parent, Relationship relationship, IEnumerable relationshipIds) + { + var genericProcessor = GenericProcessorFactory.GetProcessor(relationship.BaseType, _context); + await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } public virtual async Task DeleteAsync(TId id) diff --git a/src/JsonApiDotNetCore/Data/IEntityRepository.cs b/src/JsonApiDotNetCore/Data/IEntityRepository.cs index a8b670e3d4..3eb449c8cf 100644 --- a/src/JsonApiDotNetCore/Data/IEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; @@ -33,6 +34,8 @@ public interface IEntityRepository Task UpdateAsync(TId id, TEntity entity); + Task UpdateRelationshipsAsync(object parent, Relationship relationship, IEnumerable relationshipIds); + Task DeleteAsync(TId id); } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 114f01cf3c..856ef4211d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -41,7 +41,9 @@ public Task ReadAsync(InputFormatterContext context) { var body = GetRequestBody(context.HttpContext.Request.Body); var jsonApiContext = GetService(context); - var model = JsonApiDeSerializer.Deserialize(body, jsonApiContext); + var model = jsonApiContext.IsRelationshipPath ? + JsonApiDeSerializer.DeserializeRelationship(body, jsonApiContext) : + JsonApiDeSerializer.Deserialize(body, jsonApiContext); if(model == null) logger?.LogError("An error occurred while de-serializing the payload"); diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs new file mode 100644 index 0000000000..669e931006 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs @@ -0,0 +1,39 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Internal +{ + public class GenericProcessor : IGenericProcessor where T : class, IIdentifiable + { + private readonly DbContext _context; + public GenericProcessor(DbContext context) + { + _context = context; + } + + public async Task UpdateRelationshipsAsync(object parent, Relationship relationship, IEnumerable relationshipIds) + { + var relationshipType = relationship.BaseType; + + // TODO: replace with relationship.IsMany + if(relationship.Type.GetInterfaces().Contains(typeof(IEnumerable))) + { + var entities = _context.GetDbSet().Where(x => relationshipIds.Contains(x.Id.ToString())).ToList(); + relationship.SetValue(parent, entities); + } + else + { + var entity = _context.GetDbSet().SingleOrDefault(x => relationshipIds.First() == x.Id.ToString()); + relationship.SetValue(parent, entity); + } + + await _context.SaveChangesAsync(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs new file mode 100644 index 0000000000..24a963599a --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// Used to generate a generic operations processor when the types + /// are not know until runtime. The typical use case would be for + /// accessing relationship data. + /// + public static class GenericProcessorFactory + { + public static IGenericProcessor GetProcessor(Type type, DbContext dbContext) + { + var repositoryType = typeof(GenericProcessor<>).MakeGenericType(type); + return (IGenericProcessor)Activator.CreateInstance(repositoryType, dbContext); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs new file mode 100644 index 0000000000..5b60dc0738 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace JsonApiDotNetCore.Internal +{ + public interface IGenericProcessor + { + Task UpdateRelationshipsAsync(object parent, Relationship relationship, IEnumerable relationshipIds); + } +} diff --git a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs new file mode 100644 index 0000000000..41d676f5da --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs @@ -0,0 +1,22 @@ +using System; +using System.Linq; + +namespace JsonApiDotNetCore.Internal +{ + public static class JsonApiExceptionFactory + { + public static JsonApiException GetException(Exception exception) + { + var exceptionType = exception.GetType().ToString().Split('.').Last(); + switch(exceptionType) + { + case "JsonApiException": + return (JsonApiException)exception; + case "InvalidCastException": + return new JsonApiException("409", exception.Message); + default: + return new JsonApiException("500", exception.Message); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Relationship.cs b/src/JsonApiDotNetCore/Internal/Relationship.cs index 4ef736e719..c477c2a248 100644 --- a/src/JsonApiDotNetCore/Internal/Relationship.cs +++ b/src/JsonApiDotNetCore/Internal/Relationship.cs @@ -1,10 +1,30 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Extensions; namespace JsonApiDotNetCore.Internal { public class Relationship { public Type Type { get; set; } + public Type BaseType { get { + return (Type.GetInterfaces().Contains(typeof(IEnumerable))) ? + Type.GenericTypeArguments[0] : + Type; + } } + public string RelationshipName { get; set; } + + public void SetValue(object entity, object newValue) + { + var propertyInfo = entity + .GetType() + .GetProperty(RelationshipName); + + propertyInfo.SetValue(entity, newValue); + } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs index 0a3153b36c..479a947e5e 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs @@ -19,11 +19,8 @@ public void OnException(ExceptionContext context) { _logger?.LogError(new EventId(), context.Exception, "An unhandled exception occurred during the request"); - var jsonApiException = context.Exception as JsonApiException; + var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - if(jsonApiException == null) - jsonApiException = new JsonApiException("500", context.Exception.Message); - var error = jsonApiException.GetError(); var result = new ObjectResult(error); result.StatusCode = Convert.ToInt16(error.Status); diff --git a/src/JsonApiDotNetCore/Models/Identifiable.cs b/src/JsonApiDotNetCore/Models/Identifiable.cs index bf4565fa10..ead65bef28 100644 --- a/src/JsonApiDotNetCore/Models/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/Identifiable.cs @@ -5,7 +5,7 @@ public class Identifiable : Identifiable public class Identifiable : IIdentifiable, IIdentifiable { - public T Id { get; set; } + public virtual T Id { get; set; } object IIdentifiable.Id { diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index c86fe70322..edb895a5db 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JsonApiDotNetCore.Serialization { @@ -15,12 +16,21 @@ public static class JsonApiDeSerializer public static object Deserialize(string requestBody, IJsonApiContext context) { var document = JsonConvert.DeserializeObject(requestBody); - var entity = DataToObject(document.Data, context); - return entity; } + public static object DeserializeRelationship(string requestBody, IJsonApiContext context) + { + var data = JToken.Parse(requestBody)["data"]; + + if(data is JArray) + return data.ToObject>(); + + return new List { data.ToObject() }; + } + + public static List DeserializeList(string requestBody, IJsonApiContext context) { var documents = JsonConvert.DeserializeObject(requestBody); diff --git a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs index 5818530338..1757109b71 100644 --- a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs @@ -15,6 +15,8 @@ public interface IJsonApiContext QuerySet QuerySet { get; set; } bool IsRelationshipData { get; set; } List IncludedRelationships { get; set; } + bool IsRelationshipPath { get; } PageManager PageManager { get; set; } + } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index 88cd045ea1..5ecd72872d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -27,12 +27,14 @@ public JsonApiContext( public string BasePath { get; set; } public QuerySet QuerySet { get; set; } public bool IsRelationshipData { get; set; } + public bool IsRelationshipPath { get; private set; } public List IncludedRelationships { get; set; } public PageManager PageManager { get; set; } public IJsonApiContext ApplyContext() { var context = _httpContextAccessor.HttpContext; + var path = context.Request.Path.Value.Split('/'); RequestEntity = ContextGraph.GetContextEntity(typeof(T)); @@ -45,7 +47,7 @@ public IJsonApiContext ApplyContext() var linkBuilder = new LinkBuilder(this); BasePath = linkBuilder.GetBasePath(context, RequestEntity.EntityName); PageManager = GetPageManager(); - + IsRelationshipPath = path[path.Length - 2] == "relationships"; return this; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs new file mode 100644 index 0000000000..027e0e29e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class CreatingDataTests + { + private DocsFixture _fixture; + private IJsonApiContext _jsonApiContext; + private Faker _todoItemFaker; + + public CreatingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _jsonApiContext = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Request_With_ClientGeneratedId_Returns_403() + { + // arrange + 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(); + var content = new + { + data = new + { + type = "todo-items", + id = "9999", + 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); + + // assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task ShouldReceiveLocationHeader_InResponse() + { + // arrange + 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(); + var content = new + { + data = new + { + type = "todo-items", + 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); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/api/v1/todo-items/{deserializedBody.Id}", response.Headers.Location.ToString()); + } + + [Fact] + public async Task Respond_409_ToIncorrectEntityType() + { + // arrange + 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(); + var content = new + { + data = new + { + type = "people", + 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); + + // assert + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs new file mode 100644 index 0000000000..888f1a262c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class DeletingDataTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _todoItemFaker; + + public DeletingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Respond_404_If_EntityDoesNotExist() + { + // arrange + var maxPersonId = _context.TodoItems.LastOrDefault()?.Id ?? 0; + var todoItem = _todoItemFaker.Generate(); + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var httpMethod = new HttpMethod("DELETE"); + var route = $"/api/v1/todo-items/{maxPersonId + 100}"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index e6883b68b4..9ddf5519cf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -106,6 +106,7 @@ public async Task GET_Included_Contains_SideloadedData_OneToMany() { // arrange _context.People.RemoveRange(_context.People); // ensure all people have todo-items + _context.TodoItems.RemoveRange(_context.TodoItems); var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); todoItem.Owner = person; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs index 8a9a33f14d..0ea3b5a0d2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs @@ -13,7 +13,6 @@ using JsonApiDotNetCoreExample.Data; using Bogus; using JsonApiDotNetCoreExample.Models; -using System.Linq; using System; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs new file mode 100644 index 0000000000..c87683fa2f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class UpdatingDataTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _todoItemFaker; + + public UpdatingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Respond_404_If_EntityDoesNotExist() + { + // arrange + var maxPersonId = _context.TodoItems.LastOrDefault()?.Id ?? 0; + var todoItem = _todoItemFaker.Generate(); + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new + { + type = "todo-items", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{maxPersonId + 100}"; + var request = new HttpRequestMessage(httpMethod, route); + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs new file mode 100644 index 0000000000..dfbd862232 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class UpdatingRelationshipsTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _personFaker; + private Faker _todoItemFaker; + + public UpdatingRelationshipsTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _personFaker = new Faker() + .RuleFor(t => t.FirstName, f => f.Name.FirstName()) + .RuleFor(t => t.LastName, f => f.Name.LastName()); + + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Can_Update_ToMany_Relationship_ThroughLink() + { + // arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new List + { + new { + type = "todo-items", + id = $"{todoItem.Id}" + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/people/{person.Id}/relationships/todo-items"; + var request = new HttpRequestMessage(httpMethod, route); + + 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 personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(personsTodoItems); + } + + [Fact] + public async Task Can_Update_ToOne_Relationship_ThroughLink() + { + // arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new + { + type = "person", + id = $"{person.Id}" + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{todoItem.Id}/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route); + + 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 todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(todoItemsOwner); + } + } +}