diff --git a/README.md b/README.md index d1d41e9849..4d38f0a0c1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ JsonApiDotnetCore provides a framework for building [json:api](http://jsonapi.or - [Non-Integer Type Keys](#non-integer-type-keys) - [Routing](#routing) - [Namespacing and Versioning URLs](#namespacing-and-versioning-urls) + - [Disable Convention](#disable-convention) - [Defining Custom Data Access Methods](#defining-custom-data-access-methods) - [Pagination](#pagination) - [Filtering](#filtering) @@ -244,6 +245,24 @@ services.AddJsonApi( opt => opt.Namespace = "api/v1"); ``` +#### Disable Convention + +You can disable the dasherized convention and specify your own template +by using the `DisableRoutingConvention` Attribute: + +```csharp +[DisableRoutingConvention] +public class CamelCasedModelsController : JsonApiController +{ + public CamelCasedModelsController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } +} +``` + ### Defining Custom Data Access Methods By default, data retrieval is distributed across 3 layers: diff --git a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs new file mode 100644 index 0000000000..a0812007e3 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs @@ -0,0 +1,7 @@ +using System; + +namespace JsonApiDotNetCore.Controllers +{ + public class DisableRoutingConventionAttribute : Attribute + { } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs index 9fb23afab4..a1385581db 100644 --- a/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs @@ -1,7 +1,9 @@ // REF: https://github.com/aspnet/Entropy/blob/dev/samples/Mvc.CustomRoutingConvention/NameSpaceRoutingConvention.cs // REF: https://github.com/aspnet/Mvc/issues/5691 +using System.Reflection; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Extensions; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace JsonApiDotNetCore.Internal @@ -17,21 +19,36 @@ public DasherizedRoutingConvention(string nspace) public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) - { - if (IsJsonApiController(controller)) + { + var template = string.Empty; + + if (IsDasherizedJsonApiController(controller)) + template = $"{_namespace}/{controller.ControllerName.Dasherize()}"; + else + template = GetTemplate(controller); + + controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel() { - var template = $"{_namespace}/{controller.ControllerName.Dasherize()}"; - controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel() - { - Template = template - }; - } + Template = template + }; } } - private bool IsJsonApiController(ControllerModel controller) + private bool IsDasherizedJsonApiController(ControllerModel controller) { - return controller.ControllerType.IsSubclassOf(typeof(JsonApiControllerMixin)); + var type = controller.ControllerType; + var notDisabled = type.GetCustomAttribute() == null; + return notDisabled && type.IsSubclassOf(typeof(JsonApiControllerMixin)); + } + + private string GetTemplate(ControllerModel controller) + { + var type = controller.ControllerType; + var routeAttr = type.GetCustomAttribute(); + if(routeAttr != null) + return ((RouteAttribute)routeAttr).Template; + + return controller.ControllerName; } } } diff --git a/src/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs b/src/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs new file mode 100644 index 0000000000..1a1c3bcf70 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [DisableRoutingConvention] + public class CamelCasedModelsController : JsonApiController + { + public CamelCasedModelsController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } +} diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index ab427445ee..977cb4f806 100644 --- a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; @@ -8,6 +9,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [DisableRoutingConvention] [Route("custom/route/todo-items")] public class TodoItemsCustomController : CustomJsonApiController { diff --git a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs index a54c2a93e8..ccdf8e8d9f 100644 --- a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,8 +1,6 @@ using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using System; namespace JsonApiDotNetCoreExample.Data { @@ -33,5 +31,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Resource("todo-collections")] public DbSet TodoItemCollections { get; set; } + + [Resource("camelCasedModels")] + public DbSet CamelCasedModels { get; set; } } } diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.Designer.cs b/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.Designer.cs new file mode 100755 index 0000000000..e43d2afc1f --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.Designer.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using JsonApiDotNetCoreExample.Data; + +namespace JsonApiDotNetCoreExample.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20170426232509_AddCamelCasedModel")] + partial class AddCamelCasedModel + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "1.1.1"); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.CamelCasedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("CompoundAttr"); + + b.HasKey("Id"); + + b.ToTable("CamelCasedModels"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("FirstName"); + + b.Property("LastName"); + + b.HasKey("Id"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AchievedDate"); + + b.Property("AssigneeId"); + + b.Property("CollectionId"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description"); + + b.Property("GuidProperty"); + + b.Property("Ordinal"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("CollectionId"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Name"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItemCollections"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Assignee") + .WithMany("AssignedTodoItems") + .HasForeignKey("AssigneeId"); + + b.HasOne("JsonApiDotNetCoreExample.Models.TodoItemCollection", "Collection") + .WithMany("TodoItems") + .HasForeignKey("CollectionId"); + + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItems") + .HasForeignKey("OwnerId"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItemCollections") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + }); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.cs b/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.cs new file mode 100755 index 0000000000..fb6afef816 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170426232509_AddCamelCasedModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCoreExample.Migrations +{ + public partial class AddCamelCasedModel : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CamelCasedModels", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn), + CompoundAttr = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CamelCasedModels", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CamelCasedModels"); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs b/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs index f662ace4cc..57d636f9ea 100755 --- a/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs @@ -16,6 +16,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) .HasAnnotation("ProductVersion", "1.1.1"); + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.CamelCasedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("CompoundAttr"); + + b.HasKey("Id"); + + b.ToTable("CamelCasedModels"); + }); + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.Person", b => { b.Property("Id") diff --git a/src/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs b/src/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs new file mode 100644 index 0000000000..7adf628f38 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs @@ -0,0 +1,10 @@ +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExample.Models +{ + public class CamelCasedModel : Identifiable + { + [Attr("compoundAttr")] + public string CompoundAttr { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs new file mode 100644 index 0000000000..eacf716ae9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs @@ -0,0 +1,181 @@ +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.Models; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Xunit; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Serialization; +using System.Linq; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Hosting; +using System; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public class CamelCasedModelsControllerTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private IJsonApiContext _jsonApiContext; + private Faker _faker; + + public CamelCasedModelsControllerTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _jsonApiContext = fixture.GetService(); + _faker = new Faker() + .RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence()); + } + + [Fact] + public async Task Can_Get_CamelCasedModels() + { + // Arrange + var model = _faker.Generate(); + _context.CamelCasedModels.Add(model); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = "/camelCasedModels"; + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetService() + .DeserializeList(body); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + Assert.True(deserializedBody.Count > 0); + } + + [Fact] + public async Task Can_Get_CamelCasedModels_ById() + { + // Arrange + var model = _faker.Generate(); + _context.CamelCasedModels.Add(model); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/camelCasedModels/{model.Id}"; + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = (CamelCasedModel)_fixture.GetService() + .Deserialize(body); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(deserializedBody); + Assert.Equal(model.Id, deserializedBody.Id); + } + + [Fact] + public async Task Can_Post_CamelCasedModels() + { + // Arrange + var model = _faker.Generate(); + var content = new + { + data = new + { + type = "camelCasedModels", + attributes = new Dictionary() + { + { "compoundAttr", model.CompoundAttr } + } + } + }; + var httpMethod = new HttpMethod("POST"); + var route = $"/camelCasedModels"; + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + 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 body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(body); + Assert.NotEmpty(body); + + var deserializedBody = (CamelCasedModel)_fixture.GetService() + .Deserialize(body); + Assert.Equal(model.CompoundAttr, deserializedBody.CompoundAttr); + } + + [Fact] + public async Task Can_Patch_CamelCasedModels() + { + // Arrange + var model = _faker.Generate(); + _context.CamelCasedModels.Add(model); + _context.SaveChanges(); + + var newModel = _faker.Generate(); + var content = new + { + data = new + { + type = "camelCasedModels", + attributes = new Dictionary() + { + { "compoundAttr", newModel.CompoundAttr } + } + } + }; + var httpMethod = new HttpMethod("PATCH"); + var route = $"/camelCasedModels/{model.Id}"; + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + 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 body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.NotEmpty(body); + + var deserializedBody = (CamelCasedModel)_fixture.GetService() + .Deserialize(body); + Assert.Equal(newModel.CompoundAttr, deserializedBody.CompoundAttr); + } + } +} \ No newline at end of file