diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs index 651e8f2422..d291fa77c0 100644 --- a/benchmarks/DependencyFactory.cs +++ b/benchmarks/DependencyFactory.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; +using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace Benchmarks @@ -12,7 +13,7 @@ internal static class DependencyFactory { public static IResourceGraph CreateResourceGraph(IJsonApiOptions options) { - IResourceGraphBuilder builder = new ResourceGraphBuilder(options); + IResourceGraphBuilder builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); builder.AddResource(BenchmarkResourcePublicNames.Type); return builder.Build(); } diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 3c041b08ce..b75bacd347 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; @@ -37,7 +39,7 @@ public JsonApiDeserializerBenchmarks() IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); var targetedFields = new TargetedFields(); - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, targetedFields); + _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new DefaultResourceFactory(new ServiceContainer()), targetedFields); } [Benchmark] diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index c9babb5d76..dd1af5aff2 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -1,7 +1,6 @@ using System; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Query; diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 3c5cff596c..952edd6642 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -7,7 +7,8 @@ public sealed class Person : Identifiable { [Attr] public string Name { get; set; } + [HasMany] - public List
Articles { get; set; } + public ICollection
Articles { get; set; } } } diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs index b92ffc5fe3..0a394d9f9b 100644 --- a/src/Examples/GettingStarted/Startup.cs +++ b/src/Examples/GettingStarted/Startup.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore; namespace GettingStarted { diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index d2268ac09e..0fe73da0e1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -1,12 +1,14 @@ -using JsonApiDotNetCore.Configuration; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - public sealed class PassportsController : JsonApiController + public sealed class PassportsController : BaseJsonApiController { public PassportsController( IJsonApiOptions jsonApiOptions, @@ -14,5 +16,35 @@ public PassportsController( IResourceService resourceService) : base(jsonApiOptions, loggerFactory, resourceService) { } + + [HttpGet] + public override async Task GetAsync() => await base.GetAsync(); + + [HttpGet("{id}")] + public async Task GetAsync(string id) + { + int idValue = HexadecimalObfuscationCodec.Decode(id); + return await base.GetAsync(idValue); + } + + [HttpPatch("{id}")] + public async Task PatchAsync(string id, [FromBody] Passport entity) + { + int idValue = HexadecimalObfuscationCodec.Decode(id); + return await base.PatchAsync(idValue, entity); + } + + [HttpPost] + public override async Task PostAsync([FromBody] Passport entity) + { + return await base.PostAsync(entity); + } + + [HttpDelete("{id}")] + public async Task DeleteAsync(string id) + { + int idValue = HexadecimalObfuscationCodec.Decode(id); + return await base.DeleteAsync(idValue); + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs index 29bb211cfd..9487df1c3b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs @@ -11,5 +11,25 @@ public IActionResult Get() var result = new[] { "value" }; return Ok(result); } + + [HttpPost] + public IActionResult Post(string name) + { + var result = "Hello, " + name; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + var result = "Hello, " + name; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Deleted"); + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 2d42d1f475..c299c2bdb7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,10 +1,14 @@ +using System; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCoreExample.Data { public sealed class AppDbContext : DbContext { + public ISystemClock SystemClock { get; } + public DbSet TodoItems { get; set; } public DbSet Passports { get; set; } public DbSet People { get; set; } @@ -12,19 +16,20 @@ public sealed class AppDbContext : DbContext public DbSet KebabCasedModels { get; set; } public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } - public DbSet NonJsonApiResources { get; set; } public DbSet Users { get; set; } - public DbSet SuperUsers { get; set; } public DbSet PersonRoles { get; set; } public DbSet ArticleTags { get; set; } - public DbSet IdentifiableArticleTags { get; set; } public DbSet Tags { get; set; } - public DbSet ThrowingResources { get; set; } - public AppDbContext(DbContextOptions options) : base(options) { } + public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) + { + SystemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(); + modelBuilder.Entity().HasBaseType(); modelBuilder.Entity() @@ -66,6 +71,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(p => p.PassportId) .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .HasMany(passport => passport.GrantedVisas) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasOne(p => p.OneToOnePerson) .WithOne(p => p.OneToOneTodoItem) diff --git a/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs b/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs new file mode 100644 index 0000000000..27fa9256c0 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/HexadecimalObfuscationCodec.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace JsonApiDotNetCoreExample +{ + public static class HexadecimalObfuscationCodec + { + public static int Decode(string value) + { + if (string.IsNullOrEmpty(value)) + { + return 0; + } + + if (!value.StartsWith("x")) + { + throw new InvalidOperationException("Invalid obfuscated id."); + } + + string stringValue = FromHexString(value.Substring(1)); + return int.Parse(stringValue); + } + + private static string FromHexString(string hexString) + { + List bytes = new List(hexString.Length / 2); + for (int index = 0; index < hexString.Length; index += 2) + { + var hexChar = hexString.Substring(index, 2); + byte bt = byte.Parse(hexChar, NumberStyles.HexNumber); + bytes.Add(bt); + } + + var chars = Encoding.ASCII.GetChars(bytes.ToArray()); + return new string(chars); + } + + public static string Encode(object value) + { + if (value is int intValue && intValue == 0) + { + return string.Empty; + } + + string stringValue = value.ToString(); + return 'x' + ToHexString(stringValue); + } + + private static string ToHexString(string value) + { + var builder = new StringBuilder(); + + foreach (byte bt in Encoding.ASCII.GetBytes(value)) + { + builder.Append(bt.ToString("X2")); + } + + return builder.ToString(); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index 7a4a3ca56b..01b0d1e352 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -15,13 +15,12 @@ public sealed class Article : Identifiable [NotMapped] [HasManyThrough(nameof(ArticleTags))] - public List Tags { get; set; } - public List ArticleTags { get; set; } - + public ISet Tags { get; set; } + public ISet ArticleTags { get; set; } [NotMapped] [HasManyThrough(nameof(IdentifiableArticleTags))] - public List IdentifiableTags { get; set; } - public List IdentifiableArticleTags { get; set; } + public ICollection IdentifiableTags { get; set; } + public ICollection IdentifiableArticleTags { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs index c1f8ebbf82..57232e3b34 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,4 +1,6 @@ +using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models { @@ -9,8 +11,12 @@ public sealed class ArticleTag public int TagId { get; set; } public Tag Tag { get; set; } - } + public ArticleTag(AppDbContext appDbContext) + { + if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext)); + } + } public class IdentifiableArticleTag : Identifiable { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs index 0696b037e3..27b817ca9c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -9,7 +9,7 @@ public sealed class Author : Identifiable public string Name { get; set; } [HasMany] - public List
Articles { get; set; } + public IList
Articles { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 24b2dd3011..b9c37f447c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -3,13 +3,26 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { public class Passport : Identifiable { + private readonly ISystemClock _systemClock; private int? _socialSecurityNumber; + protected override string GetStringId(object value) + { + return HexadecimalObfuscationCodec.Encode(value); + } + + protected override int GetTypedId(string value) + { + return HexadecimalObfuscationCodec.Decode(value); + } + [Attr] public int? SocialSecurityNumber { @@ -18,7 +31,7 @@ public int? SocialSecurityNumber { if (value != _socialSecurityNumber) { - LastSocialSecurityNumberChange = DateTime.Now; + LastSocialSecurityNumberChange = _systemClock.UtcNow.LocalDateTime; _socialSecurityNumber = value; } } @@ -54,14 +67,16 @@ public string BirthCountryName [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] [NotMapped] - public string GrantedVisaCountries - { - get => GrantedVisas == null ? null : string.Join(", ", GrantedVisas.Select(v => v.TargetCountry.Name)); - // The setter is required only for deserialization in unit tests. - set { } - } + public string GrantedVisaCountries => GrantedVisas == null || !GrantedVisas.Any() + ? null + : string.Join(", ", GrantedVisas.Select(v => v.TargetCountry.Name)); [EagerLoad] public ICollection GrantedVisas { get; set; } + + public Passport(AppDbContext appDbContext) + { + _systemClock = appDbContext.SystemClock; + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index cde361fbb9..6c479a5db1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -44,13 +44,13 @@ public string FirstName public Gender Gender { get; set; } [HasMany] - public List TodoItems { get; set; } + public ISet TodoItems { get; set; } [HasMany] - public List AssignedTodoItems { get; set; } + public ISet AssignedTodoItems { get; set; } [HasMany] - public List todoCollections { get; set; } + public HashSet todoCollections { get; set; } [HasOne] public PersonRole Role { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 86ceed40d4..e63df2b9ad 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,5 +1,7 @@ +using System; using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models { @@ -8,5 +10,10 @@ public class Tag : Identifiable [Attr] [RegularExpression(@"^\W$")] public string Name { get; set; } + + public Tag(AppDbContext appDbContext) + { + if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext)); + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 5c1487e842..000e60d238 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -62,7 +62,7 @@ public string AlwaysChangingValue public int? OneToOnePersonId { get; set; } [HasMany] - public List StakeHolders { get; set; } + public ISet StakeHolders { get; set; } [HasOne] public TodoItemCollection Collection { get; set; } @@ -80,6 +80,6 @@ public string AlwaysChangingValue public TodoItem ParentTodo { get; set; } [HasMany] - public List ChildrenTodos { get; set; } + public IList ChildrenTodos { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index 3b33a0fc1f..edb6e98692 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -11,7 +11,7 @@ public sealed class TodoItemCollection : Identifiable public string Name { get; set; } [HasMany] - public List TodoItems { get; set; } + public ISet TodoItems { get; set; } [HasOne] public Person Owner { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index 6c285140c6..89018b997b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -1,10 +1,13 @@ using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { public class User : Identifiable { + private readonly ISystemClock _systemClock; private string _password; [Attr] public string Username { get; set; } @@ -18,16 +21,25 @@ public string Password if (value != _password) { _password = value; - LastPasswordChange = DateTime.Now; + LastPasswordChange = _systemClock.UtcNow.LocalDateTime; } } } [Attr] public DateTime LastPasswordChange { get; set; } + + public User(AppDbContext appDbContext) + { + _systemClock = appDbContext.SystemClock; + } } public sealed class SuperUser : User { [Attr] public int SecurityLevel { get; set; } + + public SuperUser(AppDbContext appDbContext) : base(appDbContext) + { + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index e341150b04..3a35f05165 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.RequestServices; namespace JsonApiDotNetCoreExample.Services @@ -21,8 +22,9 @@ public CustomArticleService( IResourceRepository repository, IResourceContextProvider provider, IResourceChangeTracker
resourceChangeTracker, + IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) { } public override async Task
GetAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs index c6d8b20804..816827345c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreExample /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. /// - public sealed class KebabCaseStartup : Startup + public sealed class KebabCaseStartup : TestStartup { public KebabCaseStartup(IWebHostEnvironment env) : base(env) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs index f074be15bc..026550950c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreExample /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. /// - public sealed class MetaStartup : Startup + public sealed class MetaStartup : TestStartup { public MetaStartup(IWebHostEnvironment env) : base(env) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs index 837515b333..56026e96d9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. /// - public sealed class NoDefaultPageSizeStartup : Startup + public sealed class NoDefaultPageSizeStartup : TestStartup { public NoDefaultPageSizeStartup(IWebHostEnvironment env) : base(env) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index ac26cbf82b..7ac33389fb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -4,11 +4,12 @@ using Microsoft.Extensions.DependencyInjection; using JsonApiDotNetCoreExample.Data; using Microsoft.EntityFrameworkCore; -using JsonApiDotNetCore.Extensions; using System; +using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Query; using JsonApiDotNetCoreExample.Services; +using Microsoft.AspNetCore.Authentication; using Newtonsoft.Json.Converters; namespace JsonApiDotNetCoreExample @@ -29,6 +30,8 @@ public Startup(IWebHostEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { + ConfigureClock(services); + services.AddScoped(); services.AddScoped(sp => sp.GetService()); @@ -39,10 +42,15 @@ public virtual void ConfigureServices(IServiceCollection services) .EnableSensitiveDataLogging() .UseNpgsql(GetDbConnectionString(), innerOptions => innerOptions.SetPostgresVersion(new Version(9,6))); }, ServiceLifetime.Transient) - .AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); + .AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); // once all tests have been moved to WebApplicationFactory format we can get rid of this line below - services.AddClientSerialization(); + services.AddClientSerialization(); + } + + protected virtual void ConfigureClock(IServiceCollection services) + { + services.AddSingleton(); } protected virtual void ConfigureJsonApiOptions(JsonApiOptions options) diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs new file mode 100644 index 0000000000..0d976b7ebd --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExample +{ + public class TestStartup : Startup + { + public TestStartup(IWebHostEnvironment env) : base(env) + { + } + + protected override void ConfigureClock(IServiceCollection services) + { + services.AddSingleton(); + } + + private class AlwaysChangingSystemClock : ISystemClock + { + private DateTimeOffset _utcNow; + + public DateTimeOffset UtcNow + { + get + { + var utcNow = _utcNow; + _utcNow = _utcNow.AddSeconds(1); + return utcNow; + } + } + + public AlwaysChangingSystemClock() + : this(new DateTimeOffset(new DateTime(2000, 1, 1))) + { + } + + public AlwaysChangingSystemClock(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs index c61b7aad22..2805c984b7 100644 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ b/src/Examples/NoEntityFrameworkExample/Startup.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs index 6940d2f94d..d02f29dc38 100644 --- a/src/Examples/ReportsExample/Startup.cs +++ b/src/Examples/ReportsExample/Startup.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs index be6885f8e3..143c05650d 100644 --- a/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs @@ -33,12 +33,12 @@ public interface IResourceGraphBuilder /// /// Add a Json:Api resource /// - /// The resource model type + /// The resource model type /// The resource model identifier type /// /// The pluralized name that should be exposed by the API. /// If nothing is specified, the configured name formatter will be used. /// - IResourceGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName = null); + IResourceGraphBuilder AddResource(Type resourceType, Type idType = null, string pluralizedTypeName = null); } } diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index 53a3301c39..1cba573b06 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -29,12 +29,12 @@ namespace JsonApiDotNetCore.Builders /// A utility class that builds a JsonApi application. It registers all required services /// and allows the user to override parts of the startup configuration. /// - public class JsonApiApplicationBuilder + internal sealed class JsonApiApplicationBuilder { private readonly JsonApiOptions _options = new JsonApiOptions(); - internal IResourceGraphBuilder _resourceGraphBuilder; - internal bool _usesDbContext; - internal readonly IServiceCollection _services; + private IResourceGraphBuilder _resourceGraphBuilder; + private Type _dbContextType; + private readonly IServiceCollection _services; private IServiceDiscoveryFacade _serviceDiscoveryFacade; private readonly IMvcCoreBuilder _mvcBuilder; @@ -47,7 +47,10 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv /// /// Executes the action provided by the user to configure /// - public void ConfigureJsonApiOptions(Action configureOptions) => configureOptions(_options); + public void ConfigureJsonApiOptions(Action options) + { + options?.Invoke(_options); + } /// /// Configures built-in .NET Core MVC (things like middleware, routing). Most of this configuration can be adjusted for the developers' need. @@ -55,13 +58,17 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv /// , , , /// and . /// - public void ConfigureMvc() + public void ConfigureMvc(Type dbContextType) { RegisterJsonApiStartupServices(); var intermediateProvider = _services.BuildServiceProvider(); _resourceGraphBuilder = intermediateProvider.GetRequiredService(); _serviceDiscoveryFacade = intermediateProvider.GetRequiredService(); + _dbContextType = dbContextType; + + AddResourceTypesFromDbContext(intermediateProvider); + var exceptionFilterProvider = intermediateProvider.GetRequiredService(); var typeMatchFilterProvider = intermediateProvider.GetRequiredService(); var routingConvention = intermediateProvider.GetRequiredService(); @@ -85,21 +92,33 @@ public void ConfigureMvc() _services.AddSingleton(routingConvention); } + private void AddResourceTypesFromDbContext(ServiceProvider intermediateProvider) + { + if (_dbContextType != null) + { + var dbContext = (DbContext) intermediateProvider.GetRequiredService(_dbContextType); + + foreach (var entityType in dbContext.Model.GetEntityTypes()) + { + _resourceGraphBuilder.AddResource(entityType.ClrType); + } + } + } + /// /// Executes auto-discovery of JADNC services. /// public void AutoDiscover(Action autoDiscover) { - autoDiscover(_serviceDiscoveryFacade); + autoDiscover?.Invoke(_serviceDiscoveryFacade); } /// /// Executes the action provided by the user to configure the resources using /// - /// - public void ConfigureResources(Action resourceGraphBuilder) + public void ConfigureResources(Action resources) { - resourceGraphBuilder(_resourceGraphBuilder); + resources?.Invoke(_resourceGraphBuilder); } /// @@ -109,7 +128,12 @@ public void ConfigureServices() { var resourceGraph = _resourceGraphBuilder.Build(); - if (!_usesDbContext) + if (_dbContextType != null) + { + var contextResolverType = typeof(DbContextResolver<>).MakeGenericType(_dbContextType); + _services.AddScoped(typeof(IDbContextResolver), contextResolverType); + } + else { _services.AddScoped(); _services.AddSingleton(new DbContextOptionsBuilder().Options); @@ -164,6 +188,7 @@ public void ConfigureServices() _services.AddScoped(); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); _services.AddScoped(); + _services.AddScoped(); AddServerSerialization(); AddQueryParameterServices(); diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs index ad1516bc2e..7536b791eb 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -18,20 +17,20 @@ namespace JsonApiDotNetCore.Builders public class ResourceGraphBuilder : IResourceGraphBuilder { private readonly IJsonApiOptions _options; + private readonly ILogger _logger; private readonly List _resources = new List(); - private readonly List _validationResults = new List(); - public ResourceGraphBuilder(IJsonApiOptions options) + public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) { _options = options; + _logger = loggerFactory.CreateLogger(); } /// public IResourceGraph Build() { _resources.ForEach(SetResourceLinksOptions); - var resourceGraph = new ResourceGraph(_resources, _validationResults); - return resourceGraph; + return new ResourceGraph(_resources); } private void SetResourceLinksOptions(ResourceContext resourceContext) @@ -56,22 +55,25 @@ public IResourceGraphBuilder AddResource(string pluralizedTypeNa /// public IResourceGraphBuilder AddResource(Type resourceType, Type idType = null, string pluralizedTypeName = null) { - AssertEntityIsNotAlreadyDefined(resourceType); - if (resourceType.Implements()) + if (_resources.All(e => e.ResourceType != resourceType)) { - pluralizedTypeName ??= FormatResourceName(resourceType); - idType ??= TypeLocator.GetIdType(resourceType); - _resources.Add(GetEntity(pluralizedTypeName, resourceType, idType)); - } - else - { - _validationResults.Add(new ValidationResult(LogLevel.Warning, $"{resourceType} does not implement 'IIdentifiable<>'. ")); + if (resourceType.IsOrImplementsInterface(typeof(IIdentifiable))) + { + pluralizedTypeName ??= FormatResourceName(resourceType); + idType ??= TypeLocator.GetIdType(resourceType); + var resourceContext = CreateResourceContext(pluralizedTypeName, resourceType, idType); + _resources.Add(resourceContext); + } + else + { + _logger.LogWarning($"Entity '{resourceType}' does not implement '{nameof(IIdentifiable)}'."); + } } return this; } - private ResourceContext GetEntity(string pluralizedTypeName, Type entityType, Type idType) => new ResourceContext + private ResourceContext CreateResourceContext(string pluralizedTypeName, Type entityType, Type idType) => new ResourceContext { ResourceName = pluralizedTypeName, ResourceType = entityType, @@ -130,58 +132,73 @@ protected virtual List GetRelationships(Type entityType) var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); if (attribute == null) continue; + attribute.PropertyInfo = prop; attribute.PublicRelationshipName ??= FormatPropertyName(prop); - attribute.InternalRelationshipName = prop.Name; attribute.RightType = GetRelationshipType(attribute, prop); attribute.LeftType = entityType; attributes.Add(attribute); if (attribute is HasManyThroughAttribute hasManyThroughAttribute) { - var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.InternalThroughName); + var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.ThroughPropertyName); if (throughProperty == null) - throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Type does not contain a property named '{hasManyThroughAttribute.InternalThroughName}'."); - - if (throughProperty.PropertyType.Implements() == false) - throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}.{throughProperty.Name}'. Property type does not implement IList."); + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); - // assumption: the property should be a generic collection, e.g. List - if (throughProperty.PropertyType.IsGenericType == false) - throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Expected through entity to be a generic type, such as List<{prop.PropertyType}>."); + var throughType = TryGetThroughType(throughProperty); + if (throughType == null) + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); - // Article → List + // ICollection hasManyThroughAttribute.ThroughProperty = throughProperty; // ArticleTag - hasManyThroughAttribute.ThroughType = throughProperty.PropertyType.GetGenericArguments()[0]; + hasManyThroughAttribute.ThroughType = throughType; - var throughProperties = hasManyThroughAttribute.ThroughType.GetProperties(); + var throughProperties = throughType.GetProperties(); // ArticleTag.Article hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == entityType) - ?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {entityType}"); + ?? throw new JsonApiSetupException($"{throughType} does not contain a navigation property to type {entityType}"); // ArticleTag.ArticleId var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name); hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName) - ?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}"); + ?? throw new JsonApiSetupException($"{throughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}"); - // Article → ArticleTag.Tag + // ArticleTag.Tag hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) - ?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); + ?? throw new JsonApiSetupException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); // ArticleTag.TagId var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name); hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) - ?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); + ?? throw new JsonApiSetupException($"{throughType} does not contain a relationship id property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); } } return attributes; } + private static Type TryGetThroughType(PropertyInfo throughProperty) + { + if (throughProperty.PropertyType.IsGenericType) + { + var typeArguments = throughProperty.PropertyType.GetGenericArguments(); + if (typeArguments.Length == 1) + { + var constructedThroughType = typeof(ICollection<>).MakeGenericType(typeArguments[0]); + if (throughProperty.PropertyType.IsOrImplementsInterface(constructedThroughType)) + { + return typeArguments[0]; + } + } + } + + return null; + } + protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) => - relation.IsHasMany ? prop.PropertyType.GetGenericArguments()[0] : prop.PropertyType; + relation is HasOneAttribute ? prop.PropertyType : prop.PropertyType.GetGenericArguments()[0]; private List GetEagerLoads(Type entityType, int recursionDepth = 0) { @@ -218,12 +235,6 @@ private static Type TypeOrElementType(Type type) private Type GetResourceDefinitionType(Type entityType) => typeof(ResourceDefinition<>).MakeGenericType(entityType); - private void AssertEntityIsNotAlreadyDefined(Type entityType) - { - if (_resources.Any(e => e.ResourceType == entityType)) - throw new InvalidOperationException($"Cannot add entity type {entityType} to context resourceGraph, there is already an entity of that type configured."); - } - private string FormatResourceName(Type resourceType) { var formatter = new ResourceNameFormatter(_options); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 8396ccff22..6b10f89944 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; @@ -16,7 +15,7 @@ protected IActionResult Error(Error error) protected IActionResult Error(IEnumerable errors) { - var document = new ErrorDocument(errors.ToList()); + var document = new ErrorDocument(errors); return new ObjectResult(document) { diff --git a/src/JsonApiDotNetCore/Data/DbContextResolver.cs b/src/JsonApiDotNetCore/Data/DbContextResolver.cs index 628f44e2ca..a485ae22be 100644 --- a/src/JsonApiDotNetCore/Data/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Data/DbContextResolver.cs @@ -2,12 +2,12 @@ namespace JsonApiDotNetCore.Data { - public sealed class DbContextResolver : IDbContextResolver - where TContext : DbContext + public sealed class DbContextResolver : IDbContextResolver + where TDbContext : DbContext { - private readonly TContext _context; + private readonly TDbContext _context; - public DbContextResolver(TContext context) + public DbContextResolver(TDbContext context) { _context = context; } diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs index db23c4e7ed..7f53bd2124 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Internal.Query; @@ -27,6 +28,7 @@ public class DefaultResourceRepository : IResourceRepository _dbSet; private readonly IResourceGraph _resourceGraph; private readonly IGenericServiceFactory _genericServiceFactory; + private readonly IResourceFactory _resourceFactory; private readonly ILogger> _logger; public DefaultResourceRepository( @@ -34,11 +36,13 @@ public DefaultResourceRepository( IDbContextResolver contextResolver, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, + IResourceFactory resourceFactory, ILoggerFactory loggerFactory) { _targetedFields = targetedFields; _resourceGraph = resourceGraph; _genericServiceFactory = genericServiceFactory; + _resourceFactory = resourceFactory; _context = contextResolver.GetContext(); _dbSet = _context.Set(); _logger = loggerFactory.CreateLogger>(); @@ -62,14 +66,11 @@ public virtual IQueryable Get(TId id) } /// - public virtual IQueryable Select(IQueryable entities, IEnumerable fields = null) + public virtual IQueryable Select(IQueryable entities, IEnumerable propertyNames = null) { - _logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(fields)})."); + _logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(propertyNames)})."); - if (fields != null && fields.Any()) - return entities.Select(fields); - - return entities; + return entities.Select(propertyNames, _resourceFactory); } /// @@ -94,7 +95,7 @@ public virtual IQueryable Sort(IQueryable entities, SortQu } /// - public virtual async Task CreateAsync(TResource entity) + public virtual async Task CreateAsync(TResource entity) { _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); @@ -108,16 +109,16 @@ public virtual async Task CreateAsync(TResource entity) // was already tracked) than the one assigned to the to-be-created entity. // Alternatively, even if we don't have to reassign anything because of already tracked // entities, we still need to assign the "through" entities in the case of many-to-many. - relationshipAttr.SetValue(entity, trackedRelationshipValue); + relationshipAttr.SetValue(entity, trackedRelationshipValue, _resourceFactory); } _dbSet.Add(entity); await _context.SaveChangesAsync(); + FlushFromCache(entity); + // this ensures relationships get reloaded from the database if they have // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 DetachRelationships(entity); - - return entity; } /// @@ -145,14 +146,14 @@ private void LoadInverseRelationships(object trackedRelationshipValue, Relations } else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) { - foreach (IIdentifiable relationshipValue in (IList)trackedRelationshipValue) + foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) _context.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); } } private bool IsHasOneRelationship(string internalRelationshipName, Type type) { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.InternalRelationshipName == internalRelationshipName); + var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.PropertyInfo.Name == internalRelationshipName); if (relationshipAttr != null) { if (relationshipAttr is HasOneAttribute) @@ -162,7 +163,7 @@ private bool IsHasOneRelationship(string internalRelationshipName, Type type) } // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. // In this case we use reflection to figure out what kind of relationship is pointing back. - return !type.GetProperty(internalRelationshipName).PropertyType.Inherits(typeof(IEnumerable)); + return !type.GetProperty(internalRelationshipName).PropertyType.IsOrImplementsInterface(typeof(IEnumerable)); } private void DetachRelationships(TResource entity) @@ -175,13 +176,13 @@ private void DetachRelationships(TResource entity) if (value is IEnumerable collection) { - foreach (IIdentifiable single in collection.ToList()) + foreach (IIdentifiable single in collection) _context.Entry(single).State = EntityState.Detached; // detaching has many relationships is not sufficient to // trigger a full reload of relationships: the navigation // property actually needs to be nulled out, otherwise // EF will still add duplicate instances to the collection - relationship.SetValue(entity, null); + relationship.SetValue(entity, null, _resourceFactory); } else { @@ -215,7 +216,7 @@ public virtual async Task UpdateAsync(TResource requestEntity, TResource databas LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); // assigns the updated relationship to the database entity //AssignRelationshipValue(databaseEntity, trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(databaseEntity, trackedRelationshipValue); + relationshipAttr.SetValue(databaseEntity, trackedRelationshipValue, _resourceFactory); } await _context.SaveChangesAsync(); @@ -247,20 +248,21 @@ private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAtt } // helper method used in GetTrackedRelationshipValue. See comments below. - private IList GetTrackedManyRelationshipValue(IEnumerable relationshipValueList, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) + private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValueList, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) { if (relationshipValueList == null) return null; bool newWasAlreadyAttached = false; var trackedPointerCollection = relationshipValueList.Select(pointer => - { // convert each element in the value list to relationshipAttr.DependentType. + { + // convert each element in the value list to relationshipAttr.DependentType. var tracked = AttachOrGetTracked(pointer); if (tracked != null) newWasAlreadyAttached = true; return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType); }) - .ToList() - .Cast(relationshipAttr.RightType); + .CopyToTypedCollection(relationshipAttr.PropertyInfo.PropertyType); + if (newWasAlreadyAttached) wasAlreadyAttached = true; - return (IList)trackedPointerCollection; + return trackedPointerCollection; } // helper method used in GetTrackedRelationshipValue. See comments there. @@ -424,11 +426,11 @@ protected void LoadCurrentRelationships(TResource oldEntity, RelationshipAttribu { if (relationshipAttribute is HasManyThroughAttribute throughAttribute) { - _context.Entry(oldEntity).Collection(throughAttribute.InternalThroughName).Load(); + _context.Entry(oldEntity).Collection(throughAttribute.ThroughProperty.Name).Load(); } else if (relationshipAttribute is HasManyAttribute hasManyAttribute) { - _context.Entry(oldEntity).Collection(hasManyAttribute.InternalRelationshipName).Load(); + _context.Entry(oldEntity).Collection(hasManyAttribute.PropertyInfo.Name).Load(); } } @@ -470,8 +472,9 @@ public DefaultResourceRepository( IDbContextResolver contextResolver, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, + IResourceFactory resourceFactory, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs index 63867c6bea..ad0af9ebd8 100644 --- a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs @@ -26,7 +26,7 @@ public interface IResourceReadRepository /// /// Apply fields to the provided queryable /// - IQueryable Select(IQueryable entities, IEnumerable fields); + IQueryable Select(IQueryable entities, IEnumerable propertyNames); /// /// Include a relationship in the query /// diff --git a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs index ad4c78ea60..54d43367dd 100644 --- a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs @@ -12,7 +12,7 @@ public interface IResourceWriteRepository public interface IResourceWriteRepository where TResource : class, IIdentifiable { - Task CreateAsync(TResource entity); + Task CreateAsync(TResource entity); Task UpdateAsync(TResource requestEntity, TResource databaseEntity); diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs new file mode 100644 index 0000000000..72c91a0191 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when the resource id in the request body does not match the id in the current endpoint URL. + /// + public sealed class ResourceIdMismatchException : JsonApiException + { + public ResourceIdMismatchException(string bodyId, string endpointId, string requestPath) + : base(new Error(HttpStatusCode.Conflict) + { + Title = "Resource id mismatch between request body and endpoint URL.", + Detail = $"Expected resource id '{endpointId}' in PATCH request body at endpoint '{requestPath}', instead of '{bodyId}'." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..9c177053de --- /dev/null +++ b/src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,61 @@ +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore +{ + public static class ApplicationBuilderExtensions + { + /// + /// Validates the resource graph and optionally registers the JsonApiDotNetCore middleware. + /// + /// + /// The can be used to skip any middleware registration, in which case the developer + /// is responsible for registering required middleware. + /// + /// + /// Indicates to not register any middleware. This enables callers to take full control of middleware registration order. + /// Indicates if 'app.UseAuthentication()' should be called. Ignored when is set to true. + /// Indicates if 'app.UseAuthorization()' should be called. Ignored when is set to true. + /// + /// The next example illustrates how to manually register middleware. + /// + /// app.UseJsonApi(skipRegisterMiddleware: true); + /// app.UseRouting(); + /// app.UseMiddleware{JsonApiMiddleware}(); + /// app.UseEndpoints(endpoints => endpoints.MapControllers()); + /// + /// + public static void UseJsonApi(this IApplicationBuilder app, bool skipRegisterMiddleware = false, bool useAuthentication = false, bool useAuthorization = false) + { + using (var scope = app.ApplicationServices.CreateScope()) + { + var inverseRelationshipResolver = scope.ServiceProvider.GetService(); + inverseRelationshipResolver?.Resolve(); + } + + if (!skipRegisterMiddleware) + { + // An endpoint is selected and set on the HttpContext if a match is found + app.UseRouting(); + + if (useAuthentication) + { + app.UseAuthentication(); + } + + if (useAuthorization) + { + app.UseAuthorization(); + } + + // middleware to run after routing occurs. + app.UseMiddleware(); + + // Executes the endpoints that was selected by routing. + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs index bbcb635b81..c97e039ff4 100644 --- a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs @@ -11,30 +11,11 @@ namespace JsonApiDotNetCore.Extensions { public static class DbContextExtensions { - /// - /// Get the DbSet when the model type is unknown until runtime - /// - public static IQueryable Set(this DbContext context, Type t) - => (IQueryable)context - .GetType() - .GetMethod("Set") - .MakeGenericMethod(t) // TODO: will caching help runtime performance? - .Invoke(context, null); - - /// - /// Determines whether or not EF is already tracking an entity of the same Type and Id - /// - public static bool EntityIsTracked(this DbContext context, IIdentifiable entity) - { - return GetTrackedEntity(context, entity) != null; - } - - /// /// Determines whether or not EF is already tracking an entity of the same Type and Id /// and returns that entity. /// - public static IIdentifiable GetTrackedEntity(this DbContext context, IIdentifiable entity) + internal static IIdentifiable GetTrackedEntity(this DbContext context, IIdentifiable entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); @@ -49,7 +30,6 @@ public static IIdentifiable GetTrackedEntity(this DbContext context, IIdentifiab return (IIdentifiable)trackedEntries?.Entity; } - /// /// Gets the current transaction or creates a new one. /// If a transaction already exists, commit, rollback and dispose diff --git a/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs b/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs deleted file mode 100644 index f47c864a8e..0000000000 --- a/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Reflection; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Graph; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Data; - -namespace JsonApiDotNetCore.Extensions.EntityFrameworkCore -{ - - /// - /// Extensions for configuring JsonApiDotNetCore with EF Core - /// - public static class IResourceGraphBuilderExtensions - { - /// - /// Add all the models that are part of the provided - /// that also implement - /// - /// The implementation type. - public static IResourceGraphBuilder AddDbContext(this IResourceGraphBuilder resourceGraphBuilder) where TDbContext : DbContext - { - var builder = (ResourceGraphBuilder)resourceGraphBuilder; - var contextType = typeof(TDbContext); - var contextProperties = contextType.GetProperties(); - foreach (var property in contextProperties) - { - var dbSetType = property.PropertyType; - if (dbSetType.IsGenericType - && dbSetType.GetGenericTypeDefinition() == typeof(DbSet<>)) - { - var resourceType = dbSetType.GetGenericArguments()[0]; - builder.AddResource(resourceType, pluralizedTypeName: GetResourceNameFromDbSetProperty(property, resourceType)); - } - } - return resourceGraphBuilder; - } - - private static string GetResourceNameFromDbSetProperty(PropertyInfo property, Type resourceType) - { - // this check is actually duplicated in the ResourceNameFormatter - // however, we perform it here so that we allow class attributes to be prioritized over - // the DbSet attribute. Eventually, the DbSet attribute should be deprecated. - // - // check the class definition first - // [Resource("models"] public class Model : Identifiable { /* ... */ } - if (resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute classResourceAttribute) - return classResourceAttribute.ResourceName; - - // check the DbContext member next - // [Resource("models")] public DbSet Models { get; set; } - if (property.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute resourceAttribute) - return resourceAttribute.ResourceName; - - return null; - } - } - - /// - /// Extensions for configuring JsonApiDotNetCore with EF Core - /// - public static class IServiceCollectionExtensions - { - /// - /// Enabling JsonApiDotNetCore using the EF Core DbContext to build the ResourceGraph. - /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, - Action options = null, - Action discovery = null, - Action resources = null, - IMvcCoreBuilder mvcBuilder = null) - where TDbContext : DbContext - { - var application = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); - if (options != null) - application.ConfigureJsonApiOptions(options); - application.ConfigureMvc(); - if (discovery != null) - application.AutoDiscover(discovery); - application.ConfigureResources(resources); - application.ConfigureServices(); - return services; - } - } - - /// - /// Extensions for configuring JsonApiDotNetCore with EF Core - /// - public static class JsonApiApplicationBuildExtensions - { - /// - /// Executes the action provided by the user to configure the resources using . - /// Additionally, inspects the EF core database context for models that implement IIdentifiable. - /// - public static void ConfigureResources(this JsonApiApplicationBuilder builder, Action resourceGraphBuilder) where TContext : DbContext - { - builder._resourceGraphBuilder.AddDbContext(); - builder._usesDbContext = true; - builder._services.AddScoped>(); - resourceGraphBuilder?.Invoke(builder._resourceGraphBuilder); - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/IEnumerableExtensions.cs b/src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs similarity index 88% rename from src/JsonApiDotNetCore/Extensions/IEnumerableExtensions.cs rename to src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs index b0748f3eeb..9e4bc7a8b6 100644 --- a/src/JsonApiDotNetCore/Extensions/IEnumerableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Query; namespace JsonApiDotNetCore.Extensions { - public static class IEnumerableExtensions + public static class EnumerableExtensions { /// /// gets the first element of type if it exists and casts the result to that. diff --git a/src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000000..c6a4a8988c --- /dev/null +++ b/src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Extensions +{ + public static class HttpContextExtensions + { + public static bool IsJsonApiRequest(this HttpContext httpContext) + { + string value = httpContext.Items["IsJsonApiRequest"] as string; + return value == bool.TrueString; + } + + internal static void SetJsonApiRequest(this HttpContext httpContext) + { + httpContext.Items["IsJsonApiRequest"] = bool.TrueString; + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs deleted file mode 100644 index 0bb6411b20..0000000000 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ /dev/null @@ -1,77 +0,0 @@ -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Middleware; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCore.Extensions -{ - // ReSharper disable once InconsistentNaming - public static class IApplicationBuilderExtensions - { - /// - /// Runs several internal JsonApiDotNetCore services to ensure proper configuration and registers required middlewares. - /// The can be used to skip any middleware registration, in which case the developer - /// is responsible for registering middleware that are required for JsonApiDotNetCore. - /// - /// - /// Indicates if JsonApiDotNetCore should skip middleware registration. This enables a user to take full control of middleware registration. - /// Indicates if .NET Core authentication middleware should be registered. Ignored when is set to true. - /// Indicates if .NET Core authentication middleware should be registered. Ignored when is set to true. - /// - /// This example illustrate which required middlewares should be registered when using the option. - /// - /// app.UseJsonApi(skipRegisterMiddleware: true); - /// // JADNC requires routing - /// app.UseRouting(); - /// // JADNC requires CurrentRequestMiddleware - /// app.UseMiddleware{CurrentRequestMiddleware}(); - /// // JANDC requires the endpoint feature enabled as follows - /// app.UseEndpoints(endpoints => endpoints.MapControllers()); - /// - /// - public static void UseJsonApi(this IApplicationBuilder app, bool skipRegisterMiddleware = false, bool useAuthentication = false, bool useAuthorization = false) - { - LogResourceGraphValidations(app); - using (var scope = app.ApplicationServices.CreateScope()) - { - var inverseRelationshipResolver = scope.ServiceProvider.GetService(); - inverseRelationshipResolver?.Resolve(); - } - - if (!skipRegisterMiddleware) { - // An endpoint is selected and set on the HttpContext if a match is found - app.UseRouting(); - - if (useAuthentication) - { - app.UseAuthentication(); - } - - if (useAuthorization) - { - app.UseAuthorization(); - } - - // middleware to run after routing occurs. - app.UseMiddleware(); - - // Executes the endpoints that was selected by routing. - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } - - private static void LogResourceGraphValidations(IApplicationBuilder app) - { - var logger = app.ApplicationServices.GetService(typeof(ILogger)) as ILogger; - var resourceGraph = app.ApplicationServices.GetService(typeof(IResourceGraph)) as ResourceGraph; - - if (logger != null) - { - resourceGraph?.ValidationResults.ForEach((v) => logger.Log(v.LogLevel, null, v.Message)); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs similarity index 90% rename from src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs rename to src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs index 53bc8cc3ae..30fb7d378a 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs @@ -11,8 +11,7 @@ namespace JsonApiDotNetCore.Extensions { - // ReSharper disable once InconsistentNaming - public static class IQueryableExtensions + public static class QueryableExtensions { private static MethodInfo _containsMethod; private static MethodInfo ContainsMethod @@ -64,8 +63,10 @@ public static IQueryable Filter(this IQueryable sourc return CallGenericWhereMethod(source, filterQuery); } - public static IQueryable Select(this IQueryable source, IEnumerable columns) - => CallGenericSelectMethod(source, columns.Select(attr => attr.PropertyInfo.Name).ToList()); + public static IQueryable Select(this IQueryable source, IEnumerable columns, IResourceFactory resourceFactory) + { + return columns == null || !columns.Any() ? source : CallGenericSelectMethod(source, columns, resourceFactory); + } public static IOrderedQueryable Sort(this IQueryable source, SortQueryContext sortQuery) { @@ -198,15 +199,15 @@ private static IQueryable CallGenericWhereContainsMethod(IQuer MemberExpression member; if (filter.IsAttributeOfRelationship) { - var relation = Expression.PropertyOrField(entity, filter.Relationship.InternalRelationshipName); + var relation = Expression.PropertyOrField(entity, filter.Relationship.PropertyInfo.Name); member = Expression.Property(relation, filter.Attribute.PropertyInfo.Name); } else member = Expression.Property(entity, filter.Attribute.PropertyInfo.Name); var method = ContainsMethod.MakeGenericMethod(member.Type); - var list = TypeHelper.CreateListFor(member.Type); + foreach (var value in propertyValues) { object targetType; @@ -262,16 +263,16 @@ private static IQueryable CallGenericWhereMethod(IQueryable CallGenericSelectMethod(IQueryable source, List columns) + private static IQueryable CallGenericSelectMethod(IQueryable source, IEnumerable columns, IResourceFactory resourceFactory) { var sourceType = typeof(TSource); var parameter = Expression.Parameter(source.ElementType, "x"); - var sourceProperties = new List(); + var sourceProperties = new HashSet(); // Store all property names to it's own related property (name as key) - var nestedTypesAndProperties = new Dictionary>(); + var nestedTypesAndProperties = new Dictionary>(); foreach (var column in columns) { var props = column.Split('.'); if (props.Length > 1) // Nested property { if (nestedTypesAndProperties.TryGetValue(props[0], out var properties) == false) - nestedTypesAndProperties.Add(props[0], new List { nameof(Identifiable.Id), props[1] }); + nestedTypesAndProperties.Add(props[0], new HashSet { nameof(Identifiable.Id), props[1] }); else properties.Add(props[1]); } else + { sourceProperties.Add(props[0]); + } } // Bind attributes on TSource @@ -374,15 +377,14 @@ private static IQueryable CallGenericSelectMethod(IQueryable Expression.Bind( - singleType.GetProperty(prop), Expression.PropertyOrField(nestedParameter, prop))).ToList(); + collectionElementType.GetProperty(prop), Expression.PropertyOrField(nestedParameter, prop))).ToList(); // { new Item() } - var newNestedExp = Expression.New(singleType); + var newNestedExp = resourceFactory.CreateNewExpression(collectionElementType); var initNestedExp = Expression.MemberInit(newNestedExp, nestedBindings); // { y => new Item() {Id = y.Id, Name = y.Name}} var body = Expression.Lambda(initNestedExp, nestedParameter); @@ -392,15 +394,15 @@ private static IQueryable CallGenericSelectMethod(IQueryable new Item() {Id = y.Id, Name = y.Name}).ToList() } - bindExpression = Expression.Call( - typeof(Enumerable), - "ToList", - new[] { singleType }, - selectMethod); + var enumerableOfElementType = typeof(IEnumerable<>).MakeGenericType(collectionElementType); + var typedCollection = nestedPropertyType.ToConcreteCollectionType(); + var typedCollectionConstructor = typedCollection.GetConstructor(new[] {enumerableOfElementType}); + + // { new HashSet(x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name})) } + bindExpression = Expression.New(typedCollectionConstructor, selectMethod); } // [HasOne] attribute else @@ -415,7 +417,7 @@ private static IQueryable CallGenericSelectMethod(IQueryable CallGenericSelectMethod(IQueryable(Expression.Call( diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs similarity index 67% rename from src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs rename to src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs index c09214f120..1fb4a3fb8d 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs @@ -2,41 +2,60 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Serialization.Client; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Internal.Contracts; +using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Extensions +namespace JsonApiDotNetCore { - // ReSharper disable once InconsistentNaming - public static class IServiceCollectionExtensions + public static class ServiceCollectionExtensions { /// - /// Enabling JsonApiDotNetCore using manual declaration to build the ResourceGraph. + /// Configures JsonApiDotNetCore by registering resources manually. /// public static IServiceCollection AddJsonApi(this IServiceCollection services, - Action options = null, - Action discovery = null, - Action resources = null, - IMvcCoreBuilder mvcBuilder = null) + Action options = null, + Action discovery = null, + Action resources = null, + IMvcCoreBuilder mvcBuilder = null) + { + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, null); + return services; + } + + /// + /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. + /// + public static IServiceCollection AddJsonApi(this IServiceCollection services, + Action options = null, + Action discovery = null, + Action resources = null, + IMvcCoreBuilder mvcBuilder = null) + where TDbContext : DbContext { - var application = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); - if (options != null) - application.ConfigureJsonApiOptions(options); - application.ConfigureMvc(); - if (discovery != null) - application.AutoDiscover(discovery); - if (resources != null) - application.ConfigureResources(resources); - application.ConfigureServices(); + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, typeof(TDbContext)); return services; } + private static void SetupApplicationBuilder(IServiceCollection services, Action options, + Action discovery, + Action resources, IMvcCoreBuilder mvcBuilder, Type dbContextType) + { + var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); + + applicationBuilder.ConfigureJsonApiOptions(options); + applicationBuilder.ConfigureMvc(dbContextType); + applicationBuilder.AutoDiscover(discovery); + applicationBuilder.ConfigureResources(resources); + applicationBuilder.ConfigureServices(); + } + /// /// Enables client serializers for sending requests and receiving responses /// in json:api format. Internally only used for testing. @@ -72,7 +91,7 @@ public static IServiceCollection AddResourceService(this IServiceCollection s { // A shorthand interface is one where the id type is omitted // e.g. IResourceService is the shorthand for IResourceService - var isShorthandInterface = (openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1); + var isShorthandInterface = openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1; if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) continue; // we can't create a shorthand for id types other than int diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 82fc618aa4..e8ea2ebdf9 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -3,7 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; +using System.Reflection; namespace JsonApiDotNetCore.Extensions { @@ -13,91 +13,88 @@ internal static class TypeExtensions /// Extension to use the LINQ cast method in a non-generic way: /// /// Type targetType = typeof(TResource) - /// ((IList)myList).Cast(targetType). + /// ((IList)myList).CopyToList(targetType). /// /// - public static IEnumerable Cast(this IEnumerable source, Type type) + public static IList CopyToList(this IEnumerable copyFrom, Type elementType, Converter elementConverter = null) { - if (source == null) throw new ArgumentNullException(nameof(source)); - if (type == null) throw new ArgumentNullException(nameof(type)); + Type collectionType = typeof(List<>).MakeGenericType(elementType); - var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(type)); - foreach (var item in source.Cast()) + if (elementConverter != null) { - list.Add(TypeHelper.ConvertType(item, type)); + var converted = copyFrom.Cast().Select(element => elementConverter(element)); + return (IList) CopyToTypedCollection(converted, collectionType); } - return list; + + return (IList)CopyToTypedCollection(copyFrom, collectionType); } /// - /// Creates a List{TInterface} where TInterface is the generic for type specified by t + /// Creates a collection instance based on the specified collection type and copies the specified elements into it. /// - public static IEnumerable GetEmptyCollection(this Type t) + /// Source to copy from. + /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). + /// + public static IEnumerable CopyToTypedCollection(this IEnumerable source, Type collectionType) { - if (t == null) throw new ArgumentNullException(nameof(t)); + if (source == null) throw new ArgumentNullException(nameof(source)); + if (collectionType == null) throw new ArgumentNullException(nameof(collectionType)); - var listType = typeof(List<>).MakeGenericType(t); - var list = (IEnumerable)CreateNewInstance(listType); - return list; - } + var concreteCollectionType = collectionType.ToConcreteCollectionType(); + dynamic concreteCollectionInstance = TypeHelper.CreateInstance(concreteCollectionType); - public static string GetResourceStringId(TId id) where TResource : class, IIdentifiable - { - var tempResource = typeof(TResource).New(); - tempResource.Id = id; - return tempResource.StringId; - } + foreach (var item in source) + { + concreteCollectionInstance.Add((dynamic) item); + } - public static object New(this Type t) - { - return New(t); + return concreteCollectionInstance; } /// - /// Creates a new instance of type t, casting it to the specified type. + /// Whether the specified source type implements or equals the specified interface. /// - public static T New(this Type t) - { - if (t == null) throw new ArgumentNullException(nameof(t)); - - var instance = (T)CreateNewInstance(t); - return instance; - } - - private static object CreateNewInstance(Type type) + public static bool IsOrImplementsInterface(this Type source, Type interfaceType) { - try + if (interfaceType == null) { - return Activator.CreateInstance(type); + throw new ArgumentNullException(nameof(interfaceType)); } - catch (Exception exception) + + if (source == null) { - throw new InvalidOperationException($"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); + return false; } + + return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); } - /// - /// Whether or not a type implements an interface. - /// - public static bool Implements(this Type concreteType) - => Implements(concreteType, typeof(T)); + public static bool HasSingleConstructorWithoutParameters(this Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); - /// - /// Whether or not a type implements an interface. - /// - private static bool Implements(this Type concreteType, Type interfaceType) - => interfaceType?.IsAssignableFrom(concreteType) == true; + return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; + } - /// - /// Whether or not a type inherits a base type. - /// - public static bool Inherits(this Type concreteType) - => Inherits(concreteType, typeof(T)); + public static ConstructorInfo GetLongestConstructor(this Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); - /// - /// Whether or not a type inherits a base type. - /// - public static bool Inherits(this Type concreteType, Type interfaceType) - => interfaceType?.IsAssignableFrom(concreteType) == true; + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; + + for (int index = 1; index < constructors.Length; index++) + { + var constructor = constructors[index]; + int length = constructor.GetParameters().Length; + if (length > maxParameterLength) + { + bestMatch = constructor; + maxParameterLength = length; + } + } + + return bestMatch; + } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 681b22dbf4..815f5a75d2 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -13,9 +13,7 @@ public bool CanRead(InputFormatterContext context) if (context == null) throw new ArgumentNullException(nameof(context)); - var contentTypeString = context.HttpContext.Request.ContentType; - - return contentTypeString == HeaderConstants.ContentType; + return context.HttpContext.IsJsonApiRequest(); } public async Task ReadAsync(InputFormatterContext context) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index 8638201ff6..def858fd3a 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -13,10 +13,9 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) if (context == null) throw new ArgumentNullException(nameof(context)); - var contentTypeString = context.HttpContext.Request.ContentType; - - return string.IsNullOrEmpty(contentTypeString) || contentTypeString == HeaderConstants.ContentType; + return context.HttpContext.IsJsonApiRequest(); } + public async Task WriteAsync(OutputFormatterWriteContext context) { var writer = context.HttpContext.RequestServices.GetService(); diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 6e0241b295..3cab898881 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Http.Extensions; @@ -15,12 +16,15 @@ namespace JsonApiDotNetCore.Formatters public class JsonApiReader : IJsonApiReader { private readonly IJsonApiDeserializer _deserializer; + private readonly ICurrentRequest _currentRequest; private readonly ILogger _logger; public JsonApiReader(IJsonApiDeserializer deserializer, - ILoggerFactory loggerFactory) + ICurrentRequest currentRequest, + ILoggerFactory loggerFactory) { _deserializer = deserializer; + _currentRequest = currentRequest; _logger = loggerFactory.CreateLogger(); } @@ -57,46 +61,56 @@ public async Task ReadAsync(InputFormatterContext context) if (context.HttpContext.Request.Method == "PATCH") { - var hasMissingId = model is IList list ? CheckForId(list) : CheckForId(model); + bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); if (hasMissingId) { throw new InvalidRequestBodyException("Payload must include id attribute.", null, body); } + + if (!_currentRequest.IsRelationshipPath && TryGetId(model, out var bodyId) && bodyId != _currentRequest.BaseId) + { + throw new ResourceIdMismatchException(bodyId, _currentRequest.BaseId, context.HttpContext.Request.GetDisplayUrl()); + } } return await InputFormatterResult.SuccessAsync(model); } /// Checks if the deserialized payload has an ID included - private bool CheckForId(object model) + private bool HasMissingId(object model) { - if (model == null) return false; - if (model is ResourceObject ro) - { - if (string.IsNullOrEmpty(ro.Id)) return true; - } - else if (model is IIdentifiable identifiable) + return TryGetId(model, out string id) && string.IsNullOrEmpty(id); + } + + /// Checks if all elements in the deserialized payload have an ID included + private bool HasMissingId(IEnumerable models) + { + foreach (var model in models) { - if (string.IsNullOrEmpty(identifiable.StringId)) return true; + if (TryGetId(model, out string id) && string.IsNullOrEmpty(id)) + { + return true; + } } + return false; } - /// Checks if the elements in the deserialized payload have an ID included - private bool CheckForId(IList modelList) + private static bool TryGetId(object model, out string id) { - foreach (var model in modelList) + if (model is ResourceObject resourceObject) { - if (model == null) continue; - if (model is ResourceObject ro) - { - if (string.IsNullOrEmpty(ro.Id)) return true; - } - else if (model is IIdentifiable identifiable) - { - if (string.IsNullOrEmpty(identifiable.StringId)) return true; - } + id = resourceObject.Id; + return true; } + + if (model is IIdentifiable identifiable) + { + id = identifiable.StringId; + return true; + } + + id = null; return false; } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 5685d87e21..61ae579320 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -5,7 +5,6 @@ using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization.Server; @@ -50,7 +49,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) } else { - response.ContentType = HeaderConstants.ContentType; + response.ContentType = HeaderConstants.MediaType; try { responseContent = SerializeResponse(context.Object, (HttpStatusCode)response.StatusCode); diff --git a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs index aac9824c56..99da83ab25 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs @@ -13,6 +13,6 @@ public ResourceDescriptor(Type resourceType, Type idType) public Type ResourceType { get; } public Type IdType { get; } - internal static ResourceDescriptor Empty { get; } = new ResourceDescriptor(null, null); + internal static readonly ResourceDescriptor Empty = new ResourceDescriptor(null, null); } } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index 0a2e34ab6c..7d3488f984 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -45,6 +45,7 @@ public class ServiceDiscoveryFacade : IServiceDiscoveryFacade typeof(IResourceReadRepository<>), typeof(IResourceReadRepository<,>) }; + private readonly IServiceCollection _services; private readonly IResourceGraphBuilder _resourceGraphBuilder; private readonly IdentifiableTypeCache _typeCache = new IdentifiableTypeCache(); @@ -56,14 +57,13 @@ public ServiceDiscoveryFacade(IServiceCollection services, IResourceGraphBuilder } /// - /// Add resources, services and repository implementations to the container. + /// Adds resource, service and repository implementations to the container. /// public ServiceDiscoveryFacade AddCurrentAssembly() => AddAssembly(Assembly.GetCallingAssembly()); /// - /// Add resources, services and repository implementations to the container. + /// Adds resource, service and repository implementations defined in the specified assembly to the container. /// - /// The assembly to search for resources in. public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { AddDbContextResolvers(assembly); @@ -78,20 +78,6 @@ public ServiceDiscoveryFacade AddAssembly(Assembly assembly) return this; } - public IEnumerable FindDerivedTypes(Type baseType) - { - return baseType.Assembly.GetTypes().Where(t => - { - if (t.BaseType != null) - { - return baseType.IsSubclassOf(t); - } - return false; - - }); - } - - private void AddDbContextResolvers(Assembly assembly) { var dbContextTypes = TypeLocator.GetDerivedTypes(assembly, typeof(DbContext)); @@ -102,23 +88,11 @@ private void AddDbContextResolvers(Assembly assembly) } } - /// - /// Adds resources to the resourceGraph and registers types on the container. - /// - /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddResources(Assembly assembly) - { - var resourceDescriptors = _typeCache.GetIdentifiableTypes(assembly); - foreach (var resourceDescriptor in resourceDescriptors) - AddResource(assembly, resourceDescriptor); - - return this; - } - private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor) { RegisterResourceDefinition(assembly, resourceDescriptor); - AddResourceToGraph(resourceDescriptor); + + _resourceGraphBuilder.AddResource(resourceDescriptor.ResourceType, resourceDescriptor.IdType); } private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) @@ -137,25 +111,6 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id } } - private void AddResourceToGraph(ResourceDescriptor identifiable) - { - _resourceGraphBuilder.AddResource(identifiable.ResourceType, identifiable.IdType); - } - - /// - /// Add implementations to container. - /// - /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddServices(Assembly assembly) - { - var resourceDescriptors = _typeCache.GetIdentifiableTypes(assembly); - foreach (var resourceDescriptor in resourceDescriptors) - { - AddServices(assembly, resourceDescriptor); - } - return this; - } - private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) { foreach (var serviceInterface in ServiceInterfaces) @@ -164,21 +119,6 @@ private void AddServices(Assembly assembly, ResourceDescriptor resourceDescripto } } - /// - /// Add implementations to container. - /// - /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddRepositories(Assembly assembly) - { - var resourceDescriptors = _typeCache.GetIdentifiableTypes(assembly); - foreach (var resourceDescriptor in resourceDescriptors) - { - AddRepositories(assembly, resourceDescriptor); - } - - return this; - } - private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { foreach (var serviceInterface in RepositoryInterfaces) @@ -195,10 +135,7 @@ private void RegisterServiceImplementations(Assembly assembly, Type interfaceTyp } var genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 ? new[] { resourceDescriptor.ResourceType, resourceDescriptor.IdType } : new[] { resourceDescriptor.ResourceType }; var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); - //if(service.implementation?.Name == "CustomArticleService" && genericArguments[0].Name != "Article") - //{ - // service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); - //} + if (service.implementation != null) { _services.AddScoped(service.registrationInterface, service.implementation); diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs index a7444db227..5b0d9e9a58 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -13,9 +13,8 @@ namespace JsonApiDotNetCore.Graph internal static class TypeLocator { /// - /// Determine whether or not this is a json:api resource by checking if it implements . - /// Returns the status and the resultant id type, either `(true, Type)` OR `(false, null)` - /// + /// Determine whether or not this is a json:api resource by checking if it implements . + /// public static Type GetIdType(Type resourceType) { var identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); @@ -30,7 +29,7 @@ public static Type GetIdType(Type resourceType) /// internal static bool TryGetResourceDescriptor(Type type, out ResourceDescriptor descriptor) { - if (type.Implements()) + if (type.IsOrImplementsInterface(typeof(IIdentifiable))) { descriptor = new ResourceDescriptor(type, GetIdType(type)); return true; diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs index fd2a5565b2..62f2d3c16a 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs @@ -75,8 +75,12 @@ public IEnumerable> GetDiffs() { var propertyInfo = TypeHelper.ParseNavigationExpression(navigationAction); var propertyType = propertyInfo.PropertyType; - if (propertyType.Inherits(typeof(IEnumerable))) propertyType = TypeHelper.GetTypeOfList(propertyType); - if (propertyType.Implements()) + if (propertyType.IsOrImplementsInterface(typeof(IEnumerable))) + { + propertyType = TypeHelper.TryGetCollectionElementType(propertyType); + } + + if (propertyType.IsOrImplementsInterface(typeof(IIdentifiable))) { // the navigation action references a relationship. Redirect the call to the relationship dictionary. return base.GetAffected(navigationAction); diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs index 22a0dc9956..1af718f817 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs @@ -76,15 +76,15 @@ public IResourceHookContainer GetResourceHookContainer(Res public IEnumerable LoadDbValues(LeftType entityTypeForRepository, IEnumerable entities, ResourceHook hook, params RelationshipAttribute[] relationshipsToNextLayer) { - var idType = TypeHelper.GetIdentifierType(entityTypeForRepository); + var idType = TypeHelper.GetIdType(entityTypeForRepository); var parameterizedGetWhere = GetType() .GetMethod(nameof(GetWhereAndInclude), BindingFlags.NonPublic | BindingFlags.Instance) .MakeGenericMethod(entityTypeForRepository, idType); var cast = ((IEnumerable)entities).Cast(); - var ids = cast.Select(e => e.StringId).Cast(idType); + var ids = cast.Select(TypeHelper.GetResourceTypedId).CopyToList(idType); var values = (IEnumerable)parameterizedGetWhere.Invoke(this, new object[] { ids, relationshipsToNextLayer }); if (values == null) return null; - return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(entityTypeForRepository), values.Cast(entityTypeForRepository)); + return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(entityTypeForRepository), values.CopyToList(entityTypeForRepository)); } public HashSet LoadDbValues(IEnumerable entities, ResourceHook hook, params RelationshipAttribute[] relationships) where TResource : class, IIdentifiable @@ -144,7 +144,6 @@ private IResourceReadRepository GetRepository() return _genericProcessorFactory.Get>(typeof(IResourceReadRepository<,>), typeof(TResource), typeof(TId)); } - public Dictionary LoadImplicitlyAffected( Dictionary leftEntitiesByRelation, IEnumerable existingRightEntities = null) @@ -159,17 +158,20 @@ public Dictionary LoadImplicitlyAffected( foreach (IIdentifiable ip in includedLefts) { - IList dbRightEntityList; + IList dbRightEntityList = TypeHelper.CreateListFor(relationship.RightType); var relationshipValue = relationship.GetValue(ip); if (!(relationshipValue is IEnumerable)) { - dbRightEntityList = TypeHelper.CreateListFor(relationship.RightType); if (relationshipValue != null) dbRightEntityList.Add(relationshipValue); } else { - dbRightEntityList = (IList)relationshipValue; + foreach (var item in (IEnumerable) relationshipValue) + { + dbRightEntityList.Add(item); + } } + var dbRightEntityListCast = dbRightEntityList.Cast().ToList(); if (existingRightEntities != null) dbRightEntityListCast = dbRightEntityListCast.Except(existingRightEntities.Cast(), _comparer).ToList(); diff --git a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs index a40e93a343..5ff77c35aa 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs @@ -94,7 +94,7 @@ public Dictionary> GetByRelationship(T public HashSet GetAffected(Expression> navigationAction) { var property = TypeHelper.ParseNavigationExpression(navigationAction); - return this.Where(p => p.Key.InternalRelationshipName == property.Name).Select(p => p.Value).SingleOrDefault(); + return this.Where(p => p.Key.PropertyInfo.Name == property.Name).Select(p => p.Value).SingleOrDefault(); } } } diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs index 54ac1c5053..a1b5bf71b8 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs @@ -22,19 +22,22 @@ internal sealed class ResourceHookExecutor : IResourceHookExecutor private readonly IIncludeService _includeService; private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; public ResourceHookExecutor( IHookExecutorHelper executorHelper, ITraversalHelper traversalHelper, ITargetedFields targetedFields, IIncludeService includedRelationships, - IResourceGraph resourceGraph) + IResourceGraph resourceGraph, + IResourceFactory resourceFactory) { _executorHelper = executorHelper; _traversalHelper = traversalHelper; _targetedFields = targetedFields; _includeService = includedRelationships; _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; } /// @@ -57,7 +60,7 @@ public IEnumerable BeforeUpdate(IEnumerable ent var diff = new DiffableEntityHashSet(node.UniqueEntities, dbValues, node.LeftsToNextLayer(), _targetedFields); IEnumerable updated = container.BeforeUpdate(diff, pipeline); node.UpdateUnique(updated); - node.Reassign(entities); + node.Reassign(_resourceFactory, entities); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); @@ -72,7 +75,7 @@ public IEnumerable BeforeCreate(IEnumerable ent var affected = new EntityHashSet((HashSet)node.UniqueEntities, node.LeftsToNextLayer()); IEnumerable updated = container.BeforeCreate(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(entities); + node.Reassign(_resourceFactory, entities); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); return entities; @@ -89,7 +92,7 @@ public IEnumerable BeforeDelete(IEnumerable ent IEnumerable updated = container.BeforeDelete(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(entities); + node.Reassign(_resourceFactory, entities); } // If we're deleting an article, we're implicitly affected any owners related to it. @@ -113,14 +116,14 @@ public IEnumerable OnReturn(IEnumerable entitie IEnumerable updated = container.OnReturn((HashSet)node.UniqueEntities, pipeline); ValidateHookResponse(updated); node.UpdateUnique(updated); - node.Reassign(entities); + node.Reassign(_resourceFactory, entities); } Traverse(_traversalHelper.CreateNextLayer(node), ResourceHook.OnReturn, (nextContainer, nextNode) => { var filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, new object[] { nextNode.UniqueEntities, pipeline }); nextNode.UpdateUnique(filteredUniqueSet); - nextNode.Reassign(); + nextNode.Reassign(_resourceFactory); }); return entities; } @@ -270,7 +273,7 @@ private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, NodeLayer la var allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, new object[] { GetIds(uniqueEntities), resourcesByRelationship, pipeline }).Cast(); var updated = GetAllowedEntities(uniqueEntities, allowedIds); node.UpdateUnique(updated); - node.Reassign(); + node.Reassign(_resourceFactory); } } @@ -404,7 +407,7 @@ private Dictionary ReplaceWithDbValues(Dicti { foreach (var key in prevLayerRelationships.Keys.ToList()) { - var replaced = prevLayerRelationships[key].Cast().Select(entity => dbValues.Single(dbEntity => dbEntity.StringId == entity.StringId)).Cast(key.LeftType); + var replaced = prevLayerRelationships[key].Cast().Select(entity => dbValues.Single(dbEntity => dbEntity.StringId == entity.StringId)).CopyToList(key.LeftType); prevLayerRelationships[key] = TypeHelper.CreateHashSetFor(key.LeftType, replaced); } return prevLayerRelationships; diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs index 80c83b4a34..3e971e3fb9 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs @@ -53,8 +53,7 @@ public void UpdateUnique(IEnumerable updated) /// /// Reassignment is done according to provided relationships /// - /// - public void Reassign(IEnumerable updated = null) + public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = null) { var unique = (HashSet)UniqueEntities; foreach (var group in _relationshipsFromPreviousLayer) @@ -68,14 +67,15 @@ public void Reassign(IEnumerable updated = null) if (currentValue is IEnumerable relationshipCollection) { - var newValue = relationshipCollection.Intersect(unique, _comparer).Cast(proxy.RightType); - proxy.SetValue(left, newValue); + var intersection = relationshipCollection.Intersect(unique, _comparer); + IEnumerable typedCollection = intersection.CopyToTypedCollection(relationshipCollection.GetType()); + proxy.SetValue(left, typedCollection, resourceFactory); } else if (currentValue is IIdentifiable relationshipSingle) { if (!unique.Intersect(new HashSet { relationshipSingle }, _comparer).Any()) { - proxy.SetValue(left, null); + proxy.SetValue(left, null, resourceFactory); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs index c610e2cc18..71d305227c 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs @@ -1,4 +1,5 @@ using System.Collections; +using JsonApiDotNetCore.Internal; using RightType = System.Type; namespace JsonApiDotNetCore.Hooks @@ -30,7 +31,7 @@ internal interface INode /// A helper method to assign relationships to the previous layer after firing hooks. /// Or, in case of the root node, to update the original source enumerable. /// - void Reassign(IEnumerable source = null); + void Reassign(IResourceFactory resourceFactory, IEnumerable source = null); /// /// A helper method to internally update the unique set of entities as a result of /// a filter action in a hook. diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs index 8e8201151e..f0babe9dd7 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Hooks @@ -19,7 +20,6 @@ namespace JsonApiDotNetCore.Hooks /// internal sealed class RelationshipProxy { - private readonly bool _isHasManyThrough; private readonly bool _skipJoinTable; /// @@ -40,7 +40,6 @@ public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isCo IsContextRelation = isContextRelation; if (attr is HasManyThroughAttribute throughAttr) { - _isHasManyThrough = true; _skipJoinTable |= RightType != throughAttr.ThroughType; } } @@ -54,23 +53,23 @@ public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isCo /// Parent entity. public object GetValue(IIdentifiable entity) { - if (_isHasManyThrough) + if (Attribute is HasManyThroughAttribute hasManyThrough) { - var throughAttr = (HasManyThroughAttribute)Attribute; if (!_skipJoinTable) { - return throughAttr.ThroughProperty.GetValue(entity); + return hasManyThrough.ThroughProperty.GetValue(entity); } var collection = new List(); - var joinEntities = (IList)throughAttr.ThroughProperty.GetValue(entity); + var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity); if (joinEntities == null) return null; foreach (var joinEntity in joinEntities) { - var rightEntity = (IIdentifiable)throughAttr.RightProperty.GetValue(joinEntity); + var rightEntity = (IIdentifiable)hasManyThrough.RightProperty.GetValue(joinEntity); if (rightEntity == null) continue; collection.Add(rightEntity); } + return collection; } return Attribute.GetValue(entity); @@ -83,33 +82,35 @@ public object GetValue(IIdentifiable entity) /// /// Parent entity. /// The relationship value. - public void SetValue(IIdentifiable entity, object value) + /// + public void SetValue(IIdentifiable entity, object value, IResourceFactory resourceFactory) { - if (_isHasManyThrough) + if (Attribute is HasManyThroughAttribute hasManyThrough) { if (!_skipJoinTable) { - var list = (IEnumerable)value; - ((HasManyThroughAttribute)Attribute).ThroughProperty.SetValue(entity, list.Cast(RightType)); + hasManyThrough.ThroughProperty.SetValue(entity, value); return; } - var throughAttr = (HasManyThroughAttribute)Attribute; - var joinEntities = (IEnumerable)throughAttr.ThroughProperty.GetValue(entity); + + var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity); var filteredList = new List(); - var rightEntities = ((IEnumerable)value).Cast(RightType); - foreach (var je in joinEntities) + var rightEntities = ((IEnumerable)value).CopyToList(RightType); + foreach (var joinEntity in joinEntities) { - - if (((IList)rightEntities).Contains(throughAttr.RightProperty.GetValue(je))) + if (((IList)rightEntities).Contains(hasManyThrough.RightProperty.GetValue(joinEntity))) { - filteredList.Add(je); + filteredList.Add(joinEntity); } } - throughAttr.ThroughProperty.SetValue(entity, filteredList.Cast(throughAttr.ThroughType)); + + var collectionValue = filteredList.CopyToTypedCollection(hasManyThrough.ThroughProperty.PropertyType); + hasManyThrough.ThroughProperty.SetValue(entity, collectionValue); return; } - Attribute.SetValue(entity, value); + + Attribute.SetValue(entity, value, resourceFactory); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs index f346cdffc7..3e8f368a41 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs @@ -59,11 +59,22 @@ public void UpdateUnique(IEnumerable updated) _uniqueEntities = new HashSet(intersected); } - public void Reassign(IEnumerable source = null) + public void Reassign(IResourceFactory resourceFactory, IEnumerable source = null) { var ids = _uniqueEntities.Select(ue => ue.StringId); - ((List)source).RemoveAll(se => !ids.Contains(se.StringId)); + + if (source is HashSet hashSet) + { + hashSet.RemoveWhere(se => !ids.Contains(se.StringId)); + } + else if (source is List list) + { + list.RemoveAll(se => !ids.Contains(se.StringId)); + } + else if (source != null) + { + throw new NotSupportedException($"Unsupported collection type '{source.GetType()}'."); + } } } - } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs index b380f8f0ae..46da95ef28 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs @@ -161,7 +161,7 @@ private Dictionary UniqueInTree(IEnumerable entiti /// Relationship attribute private RightType GetRightTypeFromRelationship(RelationshipAttribute attr) { - if (attr is HasManyThroughAttribute throughAttr && throughAttr.ThroughType.Inherits(typeof(IIdentifiable))) + if (attr is HasManyThroughAttribute throughAttr && throughAttr.ThroughType.IsOrImplementsInterface(typeof(IIdentifiable))) { return throughAttr.ThroughType; } @@ -295,7 +295,7 @@ private INode CreateNodeInstance(RightType nodeType, RelationshipProxy[] relatio /// private IRelationshipsFromPreviousLayer CreateRelationshipsFromInstance(RightType nodeType, IEnumerable relationshipsFromPrev) { - var cast = relationshipsFromPrev.Cast(relationshipsFromPrev.First().GetType()); + var cast = relationshipsFromPrev.CopyToList(relationshipsFromPrev.First().GetType()); return (IRelationshipsFromPreviousLayer)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipsFromPreviousLayer<>), nodeType, cast); } @@ -304,7 +304,7 @@ private IRelationshipsFromPreviousLayer CreateRelationshipsFromInstance(RightTyp /// private IRelationshipGroup CreateRelationshipGroupInstance(Type thisLayerType, RelationshipProxy proxy, List leftEntities, List rightEntities) { - var rightEntitiesHashed = TypeHelper.CreateInstanceOfOpenType(typeof(HashSet<>), thisLayerType, rightEntities.Cast(thisLayerType)); + var rightEntitiesHashed = TypeHelper.CreateInstanceOfOpenType(typeof(HashSet<>), thisLayerType, rightEntities.CopyToList(thisLayerType)); return (IRelationshipGroup)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipGroup<>), thisLayerType, proxy, new HashSet(leftEntities), rightEntitiesHashed); } diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs index f1ecbe903b..cef144a9ab 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs +++ b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal.Contracts @@ -11,7 +12,7 @@ public interface IResourceContextProvider /// /// Gets all registered context entities /// - ResourceContext[] GetResourceContexts(); + IEnumerable GetResourceContexts(); /// /// Get the resource metadata by the DbSet property name @@ -28,4 +29,4 @@ public interface IResourceContextProvider /// ResourceContext GetResourceContext() where TResource : class, IIdentifiable; } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs index 176e46965f..9a327ebff2 100644 --- a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.Extensions.Options; using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Internal @@ -128,7 +127,6 @@ private Type GetResourceTypeFromController(Type type) var controllerBase = typeof(ControllerBase); var jsonApiMixin = typeof(JsonApiControllerMixin); var target = typeof(BaseJsonApiController<,>); - var identifiable = typeof(IIdentifiable); var currentBaseType = type; while (!currentBaseType.IsGenericType || currentBaseType.GetGenericTypeDefinition() != target) { @@ -136,7 +134,7 @@ private Type GetResourceTypeFromController(Type type) if ( (nextBaseType == controllerBase || nextBaseType == jsonApiMixin) && currentBaseType.IsGenericType) { - var potentialResource = currentBaseType.GetGenericArguments().FirstOrDefault(t => t.Inherits(identifiable)); + var potentialResource = currentBaseType.GetGenericArguments().FirstOrDefault(t => t.IsOrImplementsInterface(typeof(IIdentifiable))); if (potentialResource != null) { return potentialResource; diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs index f899de631c..ccd734a9bd 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -32,9 +33,12 @@ public interface IRepositoryRelationshipUpdateHelper /// public class RepositoryRelationshipUpdateHelper : IRepositoryRelationshipUpdateHelper where TRelatedResource : class { + private readonly IResourceFactory _resourceFactory; private readonly DbContext _context; - public RepositoryRelationshipUpdateHelper(IDbContextResolver contextResolver) + + public RepositoryRelationshipUpdateHelper(IDbContextResolver contextResolver, IResourceFactory resourceFactory) { + _resourceFactory = resourceFactory; _context = contextResolver.GetContext(); } @@ -54,7 +58,7 @@ private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribu TRelatedResource value = null; if (relationshipIds.Any()) { // newOwner.id - var target = Expression.Constant(TypeHelper.ConvertType(relationshipIds.First(), TypeHelper.GetIdentifierType(relationship.RightType))); + var target = Expression.Constant(TypeHelper.ConvertType(relationshipIds.First(), TypeHelper.GetIdType(relationship.RightType))); // (Person p) => ... ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); // (Person p) => p.Id @@ -64,15 +68,24 @@ private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribu var equalsLambda = Expression.Lambda>(callEquals, parameter); value = await _context.Set().FirstOrDefaultAsync(equalsLambda); } - relationship.SetValue(parent, value); + relationship.SetValue(parent, value, _resourceFactory); } private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { - var value = new List(); - if (relationshipIds.Any()) - { // [1, 2, 3] - var target = Expression.Constant(TypeHelper.ConvertListType(relationshipIds, TypeHelper.GetIdentifierType(relationship.RightType))); + IEnumerable value; + if (!relationshipIds.Any()) + { + var collectionType = relationship.PropertyInfo.PropertyType.ToConcreteCollectionType(); + value = (IEnumerable)TypeHelper.CreateInstance(collectionType); + } + else + { + var idType = TypeHelper.GetIdType(relationship.RightType); + var typedIds = relationshipIds.CopyToList(idType, stringId => TypeHelper.ConvertType(stringId, idType)); + + // [1, 2, 3] + var target = Expression.Constant(typedIds); // (Person p) => ... ParameterExpression parameter = Expression.Parameter(typeof(TRelatedResource)); // (Person p) => p.Id @@ -80,9 +93,12 @@ private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttrib // [1,2,3].Contains(p.Id) var callContains = Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] { idMember.Type }, target, idMember); var containsLambda = Expression.Lambda>(callContains, parameter); - value = await _context.Set().Where(containsLambda).ToListAsync(); + + var resultSet = await _context.Set().Where(containsLambda).ToListAsync(); + value = resultSet.CopyToTypedCollection(relationship.PropertyInfo.PropertyType); } - relationship.SetValue(parent, value); + + relationship.SetValue(parent, value, _resourceFactory); } private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IEnumerable relationshipIds) @@ -112,7 +128,7 @@ private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAtt var newLinks = relationshipIds.Select(x => { - var link = relationship.ThroughType.New(); + var link = _resourceFactory.CreateInstance(relationship.ThroughType); relationship.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, relationship.LeftIdProperty.PropertyType)); relationship.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, relationship.RightIdProperty.PropertyType)); return link; diff --git a/src/JsonApiDotNetCore/Internal/HeaderConstants.cs b/src/JsonApiDotNetCore/Internal/HeaderConstants.cs deleted file mode 100644 index b3086b09c9..0000000000 --- a/src/JsonApiDotNetCore/Internal/HeaderConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Internal -{ - public static class HeaderConstants - { - public const string AcceptHeader = "Accept"; - public const string ContentType = "application/vnd.api+json"; - } -} diff --git a/src/JsonApiDotNetCore/Internal/IResourceFactory.cs b/src/JsonApiDotNetCore/Internal/IResourceFactory.cs new file mode 100644 index 0000000000..67ea569592 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/IResourceFactory.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Internal +{ + public interface IResourceFactory + { + public object CreateInstance(Type resourceType); + public TResource CreateInstance(); + public NewExpression CreateNewExpression(Type resourceType); + } + + internal sealed class DefaultResourceFactory : IResourceFactory + { + private readonly IServiceProvider _serviceProvider; + + public DefaultResourceFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + public object CreateInstance(Type resourceType) + { + if (resourceType == null) + { + throw new ArgumentNullException(nameof(resourceType)); + } + + return InnerCreateInstance(resourceType, _serviceProvider); + } + + public TResource CreateInstance() + { + return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); + } + + private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + { + bool hasSingleConstructorWithoutParameters = type.HasSingleConstructorWithoutParameters(); + + try + { + return hasSingleConstructorWithoutParameters + ? Activator.CreateInstance(type) + : ActivatorUtilities.CreateInstance(serviceProvider, type); + } + catch (Exception exception) + { + throw new InvalidOperationException(hasSingleConstructorWithoutParameters + ? $"Failed to create an instance of '{type.FullName}' using its default constructor." + : $"Failed to create an instance of '{type.FullName}' using injected constructor parameters.", + exception); + } + } + + public NewExpression CreateNewExpression(Type resourceType) + { + if (resourceType.HasSingleConstructorWithoutParameters()) + { + return Expression.New(resourceType); + } + + List constructorArguments = new List(); + + var longestConstructor = resourceType.GetLongestConstructor(); + foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) + { + try + { + object constructorArgument = + ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); + + constructorArguments.Add(Expression.Constant(constructorArgument)); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to create an instance of '{resourceType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", + exception); + } + } + + return Expression.New(longestConstructor, constructorArguments); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs index 3aa999f9bb..ebef063067 100644 --- a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs @@ -52,7 +52,7 @@ public void Resolve() foreach (var attr in ce.Relationships) { if (attr is HasManyThroughAttribute) continue; - INavigation inverseNavigation = meta.FindNavigation(attr.InternalRelationshipName)?.FindInverse(); + INavigation inverseNavigation = meta.FindNavigation(attr.PropertyInfo.Name)?.FindInverse(); attr.InverseNavigation = inverseNavigation?.Name; } } diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs b/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs index 5ad0d0a386..61a342b14e 100644 --- a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs +++ b/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs @@ -23,7 +23,7 @@ protected BaseQueryContext(TQuery query) public string GetPropertyPath() { if (IsAttributeOfRelationship) - return $"{Relationship.InternalRelationshipName}.{Attribute.PropertyInfo.Name}"; + return $"{Relationship.PropertyInfo.Name}.{Attribute.PropertyInfo.Name}"; return Attribute.PropertyInfo.Name; } diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs index 4a5e1f4fcc..7b25a93180 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs @@ -12,17 +12,15 @@ namespace JsonApiDotNetCore.Internal /// public class ResourceGraph : IResourceGraph { - internal List ValidationResults { get; } private List Resources { get; } - public ResourceGraph(List resources, List validationResults = null) + public ResourceGraph(List resources) { Resources = resources; - ValidationResults = validationResults; } /// - public ResourceContext[] GetResourceContexts() => Resources.ToArray(); + public IEnumerable GetResourceContexts() => Resources; /// public ResourceContext GetResourceContext(string resourceName) => Resources.SingleOrDefault(e => e.ResourceName == resourceName); @@ -68,7 +66,7 @@ public RelationshipAttribute GetInverse(RelationshipAttribute relationship) if (relationship.InverseNavigation == null) return null; return GetResourceContext(relationship.RightType) .Relationships - .SingleOrDefault(r => r.InternalRelationshipName == relationship.InverseNavigation); + .SingleOrDefault(r => r.PropertyInfo.Name == relationship.InverseNavigation); } private IEnumerable Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where T : IIdentifiable @@ -90,7 +88,7 @@ private IEnumerable Getter(Expression> selec { // model => model.Field1 try { - targeted.Add(available.Single(f => f.ExposedInternalMemberName == memberExpression.Member.Name)); + targeted.Add(available.Single(f => f.PropertyName == memberExpression.Member.Name)); return targeted; } catch (InvalidOperationException) @@ -111,7 +109,7 @@ private IEnumerable Getter(Expression> selec foreach (var member in newExpression.Members) { memberName = member.Name; - targeted.Add(available.Single(f => f.ExposedInternalMemberName == memberName)); + targeted.Add(available.Single(f => f.PropertyName == memberName)); } return targeted; } diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 22f62e47b0..2288c35f71 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -11,24 +11,19 @@ namespace JsonApiDotNetCore.Internal { internal static class TypeHelper { - private static bool IsNullable(Type type) - { - return (!type.IsValueType || Nullable.GetUnderlyingType(type) != null); - } - public static object ConvertType(object value, Type type) { - if (value == null && !IsNullable(type)) + if (value == null && !CanBeNull(type)) throw new FormatException("Cannot convert null to a non-nullable type"); if (value == null) return null; - Type typeOfValue = value.GetType(); + Type runtimeType = value.GetType(); try { - if (typeOfValue == type || type.IsAssignableFrom(typeOfValue)) + if (runtimeType == type || type.IsAssignableFrom(runtimeType)) return value; type = Nullable.GetUnderlyingType(type) ?? type; @@ -44,7 +39,6 @@ public static object ConvertType(object value, Type type) if (type == typeof(DateTimeOffset)) return DateTimeOffset.Parse(stringValue); - if (type == typeof(TimeSpan)) return TimeSpan.Parse(stringValue); @@ -53,23 +47,35 @@ public static object ConvertType(object value, Type type) return Convert.ChangeType(stringValue, type); } - catch (Exception e) + catch (Exception exception) { - throw new FormatException($"{typeOfValue} cannot be converted to {type}", e); + throw new FormatException($"Failed to convert '{value}' of type '{runtimeType}' to type '{type}'.", exception); } } + private static bool CanBeNull(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + } + internal static object GetDefaultValue(this Type type) { - return type.IsValueType ? type.New() : null; + return type.IsValueType ? CreateInstance(type) : null; } - public static Type GetTypeOfList(Type type) + public static Type TryGetCollectionElementType(Type type) { - if (type != null && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + if (type != null) { - return type.GetGenericArguments()[0]; + if (type.IsGenericType && type.GenericTypeArguments.Length == 1) + { + if (type.IsOrImplementsInterface(typeof(IEnumerable))) + { + return type.GenericTypeArguments[0]; + } + } } + return null; } @@ -105,23 +111,6 @@ public static PropertyInfo ParseNavigationExpression(Expression - /// Convert collection of query string params to Collection of concrete Type - /// - /// Collection like ["10","20","30"] - /// Non array type. For e.g. int - /// Collection of concrete type - public static IList ConvertListType(IEnumerable values, Type type) - { - var list = CreateListFor(type); - foreach (var value in values) - { - list.Add(ConvertType(value, type)); - } - - return list; - } - /// /// Creates an instance of the specified generic type /// @@ -153,18 +142,6 @@ public static Dictionary> ConvertAttributeDicti return attributes?.ToDictionary(attr => attr.PropertyInfo, attr => entities); } - /// - /// Use this overload if you need to instantiate a type that has a internal constructor - /// - private static object CreateInstanceOfOpenType(Type openType, Type[] parameters, bool hasInternalConstructor, params object[] constructorArguments) - { - if (!hasInternalConstructor) return CreateInstanceOfOpenType(openType, parameters, constructorArguments); - var parameterizedType = openType.MakeGenericType(parameters); - // note that if for whatever reason the constructor of AffectedResource is set from - // internal to public, this will throw an error, as it is looking for a no - return Activator.CreateInstance(parameterizedType, BindingFlags.NonPublic | BindingFlags.Instance, null, constructorArguments, null); - } - /// /// Creates an instance of the specified generic type /// @@ -182,8 +159,12 @@ public static object CreateInstanceOfOpenType(Type openType, Type parameter, par /// public static object CreateInstanceOfOpenType(Type openType, Type parameter, bool hasInternalConstructor, params object[] constructorArguments) { - return CreateInstanceOfOpenType(openType, new[] { parameter }, hasInternalConstructor, constructorArguments); - + Type[] parameters = { parameter }; + if (!hasInternalConstructor) return CreateInstanceOfOpenType(openType, parameters, constructorArguments); + var parameterizedType = openType.MakeGenericType(parameters); + // note that if for whatever reason the constructor of AffectedResource is set from + // internal to public, this will throw an error, as it is looking for a no + return Activator.CreateInstance(parameterizedType, BindingFlags.NonPublic | BindingFlags.Instance, null, constructorArguments, null); } /// @@ -193,8 +174,7 @@ public static object CreateInstanceOfOpenType(Type openType, Type parameter, boo /// The target type public static IList CreateListFor(Type type) { - IList list = (IList)CreateInstanceOfOpenType(typeof(List<>), type); - return list; + return (IList)CreateInstanceOfOpenType(typeof(List<>), type); } /// @@ -206,31 +186,71 @@ public static IEnumerable CreateHashSetFor(Type type, object elements = null) } /// - /// Gets the generic argument T of List{T} + /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} /// - /// The type of the list - /// The list to be inspected - public static Type GetListInnerType(IEnumerable list) + public static Type ToConcreteCollectionType(this Type collectionType) { - return list.GetType().GetGenericArguments()[0]; + if (collectionType.IsInterface && collectionType.IsGenericType) + { + var genericTypeDefinition = collectionType.GetGenericTypeDefinition(); + + if (genericTypeDefinition == typeof(ICollection<>) || genericTypeDefinition == typeof(ISet<>) || + genericTypeDefinition == typeof(IEnumerable<>) || genericTypeDefinition == typeof(IReadOnlyCollection<>)) + { + return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]); + } + + if (genericTypeDefinition == typeof(IList<>) || genericTypeDefinition == typeof(IReadOnlyList<>)) + { + return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]); + } + } + + return collectionType; } /// /// Gets the type (Guid or int) of the Id of a type that implements IIdentifiable /// - public static Type GetIdentifierType(Type entityType) + public static Type GetIdType(Type resourceType) { - var property = entityType.GetProperty("Id"); - if (property == null) throw new ArgumentException("Type does not have a property Id"); - return entityType.GetProperty("Id").PropertyType; + var property = resourceType.GetProperty(nameof(Identifiable.Id)); + if (property == null) throw new ArgumentException("Type does not have 'Id' property."); + return property.PropertyType; } - /// - /// Gets the type (Guid or int) of the Id of a type that implements IIdentifiable - /// - public static Type GetIdentifierType() where T : IIdentifiable + public static object CreateInstance(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + try + { + return Activator.CreateInstance(type); + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); + } + } + + public static object ConvertStringIdToTypedId(Type resourceType, string stringId, IResourceFactory resourceFactory) + { + var tempResource = (IIdentifiable)resourceFactory.CreateInstance(resourceType); + tempResource.StringId = stringId; + return GetResourceTypedId(tempResource); + } + + public static object GetResourceTypedId(IIdentifiable resource) + { + PropertyInfo property = resource.GetType().GetProperty(nameof(Identifiable.Id)); + return property.GetValue(resource); + } + + public static string GetResourceStringId(TId id, IResourceFactory resourceFactory) where TResource : class, IIdentifiable { - return typeof(T).GetProperty("Id").PropertyType; + TResource tempResource = resourceFactory.CreateInstance(); + tempResource.Id = id; + return tempResource.StringId; } } } diff --git a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs index 3b1e82b4d8..328ed90491 100644 --- a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs @@ -1,3 +1,4 @@ +using JsonApiDotNetCore.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -12,6 +13,11 @@ public void OnResultExecuted(ResultExecutedContext context) public void OnResultExecuting(ResultExecutingContext context) { + if (!context.HttpContext.IsJsonApiRequest()) + { + return; + } + if (context.Result is ObjectResult objectResult && objectResult.Value != null) { return; diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs deleted file mode 100644 index fd0895da36..0000000000 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// This sets all necessary parameters relating to the HttpContext for JADNC - /// - public sealed class CurrentRequestMiddleware - { - private readonly RequestDelegate _next; - - public CurrentRequestMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task Invoke(HttpContext httpContext, - IControllerResourceMapping controllerResourceMapping, - IJsonApiOptions options, - ICurrentRequest currentRequest, - IResourceGraph resourceGraph) - { - var routeValues = httpContext.GetRouteData().Values; - var requestContext = new RequestContext(httpContext, currentRequest, resourceGraph, options, routeValues, - controllerResourceMapping); - - var requestResource = GetCurrentEntity(requestContext); - if (requestResource != null) - { - requestContext.CurrentRequest.SetRequestResource(requestResource); - requestContext.CurrentRequest.IsRelationshipPath = PathIsRelationship(requestContext.RouteValues); - requestContext.CurrentRequest.BasePath = GetBasePath(requestContext, requestResource.ResourceName); - requestContext.CurrentRequest.BaseId = GetBaseId(requestContext.RouteValues); - requestContext.CurrentRequest.RelationshipId = GetRelationshipId(requestContext); - } - - if (await IsValidAsync(requestContext)) - { - await _next(requestContext.HttpContext); - } - } - - private static string GetBaseId(RouteValueDictionary routeValues) - { - if (routeValues.TryGetValue("id", out object stringId)) - { - return (string)stringId; - } - - return null; - } - - private static string GetRelationshipId(RequestContext requestContext) - { - if (!requestContext.CurrentRequest.IsRelationshipPath) - { - return null; - } - var components = SplitCurrentPath(requestContext); - var toReturn = components.ElementAtOrDefault(4); - - return toReturn; - } - - private static string[] SplitCurrentPath(RequestContext requestContext) - { - var path = requestContext.HttpContext.Request.Path.Value; - var ns = $"/{requestContext.Options.Namespace}"; - var nonNameSpaced = path.Replace(ns, ""); - nonNameSpaced = nonNameSpaced.Trim('/'); - var individualComponents = nonNameSpaced.Split('/'); - return individualComponents; - } - - private static string GetBasePath(RequestContext requestContext, string resourceName = null) - { - var r = requestContext.HttpContext.Request; - if (requestContext.Options.RelativeLinks) - { - return requestContext.Options.Namespace; - } - - var customRoute = GetCustomRoute(requestContext.Options, r.Path.Value, resourceName); - var toReturn = $"{r.Scheme}://{r.Host}/{requestContext.Options.Namespace}"; - if (customRoute != null) - { - toReturn += $"/{customRoute}"; - } - return toReturn; - } - - private static object GetCustomRoute(IJsonApiOptions options, string path, string resourceName) - { - var trimmedComponents = path.Trim('/').Split('/').ToList(); - var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); - var newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); - var customRoute = string.Join('/', newComponents); - if (customRoute == options.Namespace) - { - return null; - } - else - { - return customRoute; - } - } - - private static bool PathIsRelationship(RouteValueDictionary routeValues) - { - var actionName = (string)routeValues["action"]; - return actionName.ToLowerInvariant().Contains("relationships"); - } - - private static async Task IsValidAsync(RequestContext requestContext) - { - return await IsValidContentTypeHeaderAsync(requestContext) && await IsValidAcceptHeaderAsync(requestContext); - } - - private static async Task IsValidContentTypeHeaderAsync(RequestContext requestContext) - { - var contentType = requestContext.HttpContext.Request.ContentType; - if (contentType != null && ContainsMediaTypeParameters(contentType)) - { - await FlushResponseAsync(requestContext, new Error(HttpStatusCode.UnsupportedMediaType) - { - Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{HeaderConstants.ContentType}' for the Content-Type header value." - }); - - return false; - } - return true; - } - - private static async Task IsValidAcceptHeaderAsync(RequestContext requestContext) - { - if (requestContext.HttpContext.Request.Headers.TryGetValue(HeaderConstants.AcceptHeader, out StringValues acceptHeaders) == false) - return true; - - foreach (var acceptHeader in acceptHeaders) - { - if (ContainsMediaTypeParameters(acceptHeader) == false) - { - continue; - } - - await FlushResponseAsync(requestContext, new Error(HttpStatusCode.NotAcceptable) - { - Title = "The specified Accept header value is not supported.", - Detail = $"Please specify '{HeaderConstants.ContentType}' for the Accept header value." - }); - return false; - } - return true; - } - - private static bool ContainsMediaTypeParameters(string mediaType) - { - var incomingMediaTypeSpan = mediaType.AsSpan(); - - // if the content type is not application/vnd.api+json then continue on - if (incomingMediaTypeSpan.Length < HeaderConstants.ContentType.Length) - { - return false; - } - - var incomingContentType = incomingMediaTypeSpan.Slice(0, HeaderConstants.ContentType.Length); - if (incomingContentType.SequenceEqual(HeaderConstants.ContentType.AsSpan()) == false) - return false; - - // anything appended to "application/vnd.api+json;" will be considered a media type param - return ( - incomingMediaTypeSpan.Length >= HeaderConstants.ContentType.Length + 2 - && incomingMediaTypeSpan[HeaderConstants.ContentType.Length] == ';' - ); - } - - private static async Task FlushResponseAsync(RequestContext requestContext, Error error) - { - requestContext.HttpContext.Response.StatusCode = (int) error.StatusCode; - - JsonSerializer serializer = JsonSerializer.CreateDefault(requestContext.Options.SerializerSettings); - serializer.ApplyErrorSettings(); - - // https://github.com/JamesNK/Newtonsoft.Json/issues/1193 - await using (var stream = new MemoryStream()) - { - await using (var streamWriter = new StreamWriter(stream, leaveOpen: true)) - { - using var jsonWriter = new JsonTextWriter(streamWriter); - serializer.Serialize(jsonWriter, new ErrorDocument(error)); - } - - stream.Seek(0, SeekOrigin.Begin); - await stream.CopyToAsync(requestContext.HttpContext.Response.Body); - } - - requestContext.HttpContext.Response.Body.Flush(); - } - - /// - /// Gets the current entity that we need for serialization and deserialization. - /// - /// - private static ResourceContext GetCurrentEntity(RequestContext requestContext) - { - var controllerName = (string)requestContext.RouteValues["controller"]; - if (controllerName == null) - { - return null; - } - var resourceType = requestContext.ControllerResourceMapping.GetAssociatedResource(controllerName); - var requestResource = requestContext.ResourceGraph.GetResourceContext(resourceType); - if (requestResource == null) - { - return null; - } - if (requestContext.RouteValues.TryGetValue("relationshipName", out object relationshipName)) - { - requestContext.CurrentRequest.RequestRelationship = requestResource.Relationships.SingleOrDefault(r => r.PublicRelationshipName == (string)relationshipName); - } - return requestResource; - } - - private sealed class RequestContext - { - public HttpContext HttpContext { get; } - public ICurrentRequest CurrentRequest { get; } - public IResourceGraph ResourceGraph { get; } - public IJsonApiOptions Options { get; } - public RouteValueDictionary RouteValues { get; } - public IControllerResourceMapping ControllerResourceMapping { get; } - - public RequestContext(HttpContext httpContext, - ICurrentRequest currentRequest, - IResourceGraph resourceGraph, - IJsonApiOptions options, - RouteValueDictionary routeValues, - IControllerResourceMapping controllerResourceMapping) - { - HttpContext = httpContext; - CurrentRequest = currentRequest; - ResourceGraph = resourceGraph; - Options = options; - RouteValues = routeValues; - ControllerResourceMapping = controllerResourceMapping; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 58e4ab5c6a..2ddd341372 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,9 +1,9 @@ using System.Linq; using System.Net.Http; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Middleware @@ -22,8 +22,13 @@ public DefaultTypeMatchFilter(IResourceContextProvider provider) public void OnActionExecuting(ActionExecutingContext context) { + if (!context.HttpContext.IsJsonApiRequest()) + { + return; + } + var request = context.HttpContext.Request; - if (IsJsonApiRequest(request) && (request.Method == "PATCH" || request.Method == "POST")) + if (request.Method == "PATCH" || request.Method == "POST") { var deserializedType = context.ActionArguments.FirstOrDefault().Value?.GetType(); var targetType = context.ActionDescriptor.Parameters.FirstOrDefault()?.ParameterType; @@ -38,11 +43,6 @@ public void OnActionExecuting(ActionExecutingContext context) } } - private bool IsJsonApiRequest(HttpRequest request) - { - return request.ContentType == HeaderConstants.ContentType; - } - public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } } } diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs new file mode 100644 index 0000000000..7868140198 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCore +{ + public static class HeaderConstants + { + public const string MediaType = "application/vnd.api+json"; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs new file mode 100644 index 0000000000..d2952010e2 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -0,0 +1,223 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Intercepts HTTP requests to populate injected instance for json:api requests. + /// + public sealed class JsonApiMiddleware + { + private readonly RequestDelegate _next; + + public JsonApiMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext httpContext, + IControllerResourceMapping controllerResourceMapping, + IJsonApiOptions options, + ICurrentRequest currentRequest, + IResourceGraph resourceGraph) + { + var routeValues = httpContext.GetRouteData().Values; + + var resourceContext = CreateResourceContext(routeValues, controllerResourceMapping, resourceGraph); + if (resourceContext != null) + { + if (!await ValidateContentTypeHeaderAsync(httpContext, options.SerializerSettings) || + !await ValidateAcceptHeaderAsync(httpContext, options.SerializerSettings)) + { + return; + } + + SetupCurrentRequest(currentRequest, resourceContext, routeValues, options, httpContext.Request); + + httpContext.SetJsonApiRequest(); + } + + await _next(httpContext); + } + + private static ResourceContext CreateResourceContext(RouteValueDictionary routeValues, + IControllerResourceMapping controllerResourceMapping, IResourceContextProvider resourceGraph) + { + var controllerName = (string) routeValues["controller"]; + if (controllerName == null) + { + return null; + } + + var resourceType = controllerResourceMapping.GetAssociatedResource(controllerName); + return resourceGraph.GetResourceContext(resourceType); + } + + private static async Task ValidateContentTypeHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) + { + var contentType = httpContext.Request.ContentType; + if (contentType != null && contentType != HeaderConstants.MediaType) + { + await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.UnsupportedMediaType) + { + Title = "The specified Content-Type header value is not supported.", + Detail = $"Please specify '{HeaderConstants.MediaType}' instead of '{contentType}' for the Content-Type header value." + }); + return false; + } + + return true; + } + + private static async Task ValidateAcceptHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) + { + StringValues acceptHeaders = httpContext.Request.Headers["Accept"]; + if (!acceptHeaders.Any() || acceptHeaders == HeaderConstants.MediaType) + { + return true; + } + + bool seenCompatibleMediaType = false; + + foreach (var acceptHeader in acceptHeaders) + { + if (MediaTypeHeaderValue.TryParse(acceptHeader, out var headerValue)) + { + if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") + { + seenCompatibleMediaType = true; + break; + } + + if (headerValue.MediaType == HeaderConstants.MediaType && !headerValue.Parameters.Any()) + { + seenCompatibleMediaType = true; + break; + } + } + } + + if (!seenCompatibleMediaType) + { + await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.NotAcceptable) + { + Title = "The specified Accept header value does not contain any supported media types.", + Detail = $"Please include '{HeaderConstants.MediaType}' in the Accept header values." + }); + return false; + } + + return true; + } + + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerSettings serializerSettings, Error error) + { + httpResponse.StatusCode = (int) error.StatusCode; + + JsonSerializer serializer = JsonSerializer.CreateDefault(serializerSettings); + serializer.ApplyErrorSettings(); + + // https://github.com/JamesNK/Newtonsoft.Json/issues/1193 + await using (var stream = new MemoryStream()) + { + await using (var streamWriter = new StreamWriter(stream, leaveOpen: true)) + { + using var jsonWriter = new JsonTextWriter(streamWriter); + serializer.Serialize(jsonWriter, new ErrorDocument(error)); + } + + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(httpResponse.Body); + } + + httpResponse.Body.Flush(); + } + + private static void SetupCurrentRequest(ICurrentRequest currentRequest, ResourceContext resourceContext, + RouteValueDictionary routeValues, IJsonApiOptions options, HttpRequest httpRequest) + { + currentRequest.SetRequestResource(resourceContext); + currentRequest.BaseId = GetBaseId(routeValues); + currentRequest.BasePath = GetBasePath(resourceContext.ResourceName, options, httpRequest); + currentRequest.IsRelationshipPath = GetIsRelationshipPath(routeValues); + currentRequest.RelationshipId = GetRelationshipId(currentRequest.IsRelationshipPath, httpRequest.Path.Value, options.Namespace); + + if (routeValues.TryGetValue("relationshipName", out object relationshipName)) + { + currentRequest.RequestRelationship = + resourceContext.Relationships.SingleOrDefault(relationship => + relationship.PublicRelationshipName == (string) relationshipName); + } + } + + private static string GetBaseId(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("id", out var id) ? (string) id : null; + } + + private static string GetBasePath(string resourceName, IJsonApiOptions options, HttpRequest httpRequest) + { + if (options.RelativeLinks) + { + return options.Namespace; + } + + var customRoute = GetCustomRoute(httpRequest.Path.Value, resourceName, options.Namespace); + var toReturn = $"{httpRequest.Scheme}://{httpRequest.Host}/{options.Namespace}"; + if (customRoute != null) + { + toReturn += $"/{customRoute}"; + } + return toReturn; + } + + private static string GetCustomRoute(string path, string resourceName, string apiNamespace) + { + var trimmedComponents = path.Trim('/').Split('/').ToList(); + var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); + var newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); + var customRoute = string.Join('/', newComponents); + return customRoute == apiNamespace ? null : customRoute; + } + + private static bool GetIsRelationshipPath(RouteValueDictionary routeValues) + { + var actionName = (string)routeValues["action"]; + return actionName.ToLowerInvariant().Contains("relationships"); + } + + private static string GetRelationshipId(bool currentRequestIsRelationshipPath, string requestPath, + string apiNamespace) + { + if (!currentRequestIsRelationshipPath) + { + return null; + } + + var components = SplitCurrentPath(requestPath, apiNamespace); + return components.ElementAtOrDefault(4); + } + + private static IEnumerable SplitCurrentPath(string requestPath, string apiNamespace) + { + var namespacePrefix = $"/{apiNamespace}"; + var nonNameSpaced = requestPath.Replace(namespacePrefix, ""); + nonNameSpaced = nonNameSpaced.Trim('/'); + return nonNameSpaced.Split('/'); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 54ae5f2c15..25b25e8bf0 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -68,7 +68,7 @@ public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(pu Capabilities = capabilities; } - public string ExposedInternalMemberName => PropertyInfo.Name; + string IResourceField.PropertyName => PropertyInfo.Name; /// /// The publicly exposed name of this json:api attribute. @@ -79,7 +79,7 @@ public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(pu public AttrCapabilities Capabilities { get; internal set; } /// - /// Provides access to the property on which this attribute is applied. + /// The resource property that this attribute is declared on. /// public PropertyInfo PropertyInfo { get; internal set; } @@ -91,10 +91,17 @@ public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(pu public object GetValue(object entity) { if (entity == null) - throw new InvalidOperationException("Cannot GetValue from null object."); + { + throw new ArgumentNullException(nameof(entity)); + } + + var property = GetResourceProperty(entity); + if (property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{property.DeclaringType?.Name}.{property.Name}' is write-only."); + } - var prop = GetResourceProperty(entity); - return prop?.GetValue(entity); + return property.GetValue(entity); } /// @@ -103,14 +110,19 @@ public object GetValue(object entity) public void SetValue(object entity, object newValue) { if (entity == null) - throw new InvalidOperationException("Cannot SetValue on null object."); + { + throw new ArgumentNullException(nameof(entity)); + } - var prop = GetResourceProperty(entity); - if(prop != null) + var property = GetResourceProperty(entity); + if (property.SetMethod == null) { - var convertedValue = TypeHelper.ConvertType(newValue, prop.PropertyType); - prop.SetValue(entity, convertedValue); - } + throw new InvalidOperationException( + $"Property '{property.DeclaringType?.Name}.{property.Name}' is read-only."); + } + + var convertedValue = TypeHelper.ConvertType(newValue, property.PropertyType); + property.SetValue(entity, convertedValue); } private PropertyInfo GetResourceProperty(object resource) diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs index d7ad78ef77..540f0dba00 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs @@ -20,7 +20,7 @@ public class HasManyAttribute : RelationshipAttribute /// public class Author : Identifiable /// { /// [HasMany("articles"] - /// public virtual List<Article> Articles { get; set; } + /// public List<Article> Articles { get; set; } /// } /// /// @@ -30,31 +30,5 @@ public HasManyAttribute(string publicName = null, Link relationshipLinks = Link. { InverseNavigation = inverseNavigationProperty; } - - /// - /// Gets the value of the navigation property, defined by the relationshipName, - /// on the provided instance. - /// - public override object GetValue(object entity) - { - return entity?.GetType() - .GetProperty(InternalRelationshipName)? - .GetValue(entity); - } - - - /// - /// Sets the value of the property identified by this attribute - /// - /// The target object - /// The new property value - public override void SetValue(object entity, object newValue) - { - var propertyInfo = entity - .GetType() - .GetProperty(InternalRelationshipName); - - propertyInfo.SetValue(entity, newValue); - } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 52cddf20fb..d4ad24c2f7 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Models { /// /// Create a HasMany relationship through a many-to-many join relationship. - /// This type can only be applied on types that implement IList. + /// This type can only be applied on types that implement ICollection. /// /// /// @@ -22,8 +22,8 @@ namespace JsonApiDotNetCore.Models /// /// [NotMapped] /// [HasManyThrough("tags", nameof(ArticleTags))] - /// public List<Tag> Tags { get; set; } - /// public List<ArticleTag> ArticleTags { get; set; } + /// public ICollection<Tag> Tags { get; set; } + /// public ICollection<ArticleTag> ArticleTags { get; set; } /// /// [AttributeUsage(AttributeTargets.Property)] @@ -34,19 +34,19 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// The public name exposed through the API will be based on the configured convention. /// /// - /// The name of the navigation property that will be used to get the HasMany relationship + /// The name of the navigation property that will be used to get the HasMany relationship /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// /// /// - /// [HasManyThrough(nameof(ArticleTags), documentLinks: Link.All, canInclude: true)] + /// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] /// /// - public HasManyThroughAttribute(string internalThroughName, Link relationshipLinks = Link.All, bool canInclude = true) + public HasManyThroughAttribute(string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) : base(null, relationshipLinks, canInclude) { - InternalThroughName = internalThroughName; + ThroughPropertyName = throughPropertyName; } /// @@ -54,58 +54,41 @@ public HasManyThroughAttribute(string internalThroughName, Link relationshipLink /// /// /// The relationship name as exposed by the API - /// The name of the navigation property that will be used to get the HasMany relationship - /// Which links are available. Defaults to + /// The name of the navigation property that will be used to get the HasMany relationship + /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// /// /// - /// [HasManyThrough("tags", nameof(ArticleTags), documentLinks: Link.All, canInclude: true)] + /// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] /// /// - public HasManyThroughAttribute(string publicName, string internalThroughName, Link documentLinks = Link.All, bool canInclude = true) - : base(publicName, documentLinks, canInclude) + public HasManyThroughAttribute(string publicName, string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) + : base(publicName, relationshipLinks, canInclude) { - InternalThroughName = internalThroughName; + ThroughPropertyName = throughPropertyName; } /// - /// Traverses the through the provided entity and returns the + /// Traverses through the provided entity and returns the /// value of the relationship on the other side of a join entity /// (e.g. Articles.ArticleTags.Tag). /// public override object GetValue(object entity) { - var throughNavigationProperty = entity.GetType() - .GetProperties() - .SingleOrDefault(p => p.Name == InternalThroughName); - - var throughEntities = throughNavigationProperty.GetValue(entity); - - if (throughEntities == null) - // return an empty list for the right-type of the property. - return TypeHelper.CreateListFor(RightType); + IEnumerable joinEntities = (IEnumerable)ThroughProperty.GetValue(entity) ?? Array.Empty(); - // the right entities are included on the navigation/through entities. Extract and return them. - var rightEntities = new List(); - foreach (var rightEntity in (IList)throughEntities) - rightEntities.Add((IIdentifiable)RightProperty.GetValue(rightEntity)); + IEnumerable rightEntities = joinEntities + .Cast() + .Select(rightEntity => RightProperty.GetValue(rightEntity)); - return rightEntities.Cast(RightType); + return rightEntities.CopyToTypedCollection(PropertyInfo.PropertyType); } - - /// - /// Sets the value of the property identified by this attribute - /// - /// The target object - /// The new property value - public override void SetValue(object entity, object newValue) + /// + public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) { - var propertyInfo = entity - .GetType() - .GetProperty(InternalRelationshipName); - propertyInfo.SetValue(entity, newValue); + base.SetValue(entity, newValue, resourceFactory); if (newValue == null) { @@ -113,16 +96,17 @@ public override void SetValue(object entity, object newValue) } else { - var throughRelationshipCollection = ThroughProperty.PropertyType.New(); - ThroughProperty.SetValue(entity, throughRelationshipCollection); - - foreach (IIdentifiable pointer in (IList)newValue) + List joinEntities = new List(); + foreach (IIdentifiable resource in (IEnumerable)newValue) { - var throughInstance = ThroughType.New(); - LeftProperty.SetValue(throughInstance, entity); - RightProperty.SetValue(throughInstance, pointer); - throughRelationshipCollection.Add(throughInstance); + object joinEntity = resourceFactory.CreateInstance(ThroughType); + LeftProperty.SetValue(joinEntity, entity); + RightProperty.SetValue(joinEntity, resource); + joinEntities.Add(joinEntity); } + + var typedCollection = joinEntities.CopyToTypedCollection(ThroughProperty.PropertyType); + ThroughProperty.SetValue(entity, typedCollection); } } @@ -133,7 +117,7 @@ public override void SetValue(object entity, object newValue) /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example /// this would be "ArticleTags". /// - public string InternalThroughName { get; } + internal string ThroughPropertyName { get; } /// /// The join type. @@ -213,7 +197,7 @@ public override void SetValue(object entity, object newValue) /// this would point to the `Article.ArticleTags` property /// /// - /// public List<ArticleTags> ArticleTags { get; set; } + /// public ICollection<ArticleTags> ArticleTags { get; set; } /// /// /// @@ -223,6 +207,6 @@ public override void SetValue(object entity, object newValue) /// /// "ArticleTags.Tag" /// - public override string RelationshipPath => $"{InternalThroughName}.{RightProperty.Name}"; + public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs index 48bceae5ef..9afed2438e 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs @@ -37,31 +37,19 @@ public HasOneAttribute(string publicName = null, Link links = Link.NotConfigured InverseNavigation = inverseNavigationProperty; } - - public override object GetValue(object entity) - { - return entity?.GetType() - .GetProperty(InternalRelationshipName)? - .GetValue(entity); - } - private readonly string _explicitIdentifiablePropertyName; /// /// The independent resource identifier. /// public string IdentifiablePropertyName => string.IsNullOrWhiteSpace(_explicitIdentifiablePropertyName) - ? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(InternalRelationshipName) + ? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(PropertyInfo.Name) : _explicitIdentifiablePropertyName; - /// - /// Sets the value of the property identified by this attribute - /// - /// The target object - /// The new property value - public override void SetValue(object entity, object newValue) + /// + public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) { - string propertyName = InternalRelationshipName; + string propertyName = PropertyInfo.Name; // if we're deleting the relationship (setting it to null), // we set the foreignKey to null. We could also set the actual property to null, // but then we would first need to load the current relationship, which requires an extra query. diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs index 83a3eb3f90..d5a742ead3 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore.Extensions; +using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; @@ -17,11 +17,16 @@ protected RelationshipAttribute(string publicName, Link relationshipLinks, bool CanInclude = canInclude; } - public string ExposedInternalMemberName => InternalRelationshipName; + string IResourceField.PropertyName => PropertyInfo.Name; + public string PublicRelationshipName { get; internal set; } - public string InternalRelationshipName { get; internal set; } public string InverseNavigation { get; internal set; } + /// + /// The resource property that this attribute is declared on. + /// + public PropertyInfo PropertyInfo { get; internal set; } + /// /// The related entity type. This does not necessarily match the navigation property type. /// In the case of a HasMany relationship, this value will be the generic argument type. @@ -39,9 +44,6 @@ protected RelationshipAttribute(string publicName, Link relationshipLinks, bool /// public Type LeftType { get; internal set; } - public bool IsHasMany => GetType() == typeof(HasManyAttribute) || GetType().Inherits(typeof(HasManyAttribute)); - public bool IsHasOne => GetType() == typeof(HasOneAttribute); - /// /// Configures which links to show in the /// object for this relationship. @@ -49,9 +51,21 @@ protected RelationshipAttribute(string publicName, Link relationshipLinks, bool public Link RelationshipLinks { get; } public bool CanInclude { get; } - public abstract void SetValue(object entity, object newValue); + /// + /// Gets the value of the resource property this attributes was declared on. + /// + public virtual object GetValue(object entity) + { + return PropertyInfo.GetValue(entity); + } - public abstract object GetValue(object entity); + /// + /// Sets the value of the resource property this attributes was declared on. + /// + public virtual void SetValue(object entity, object newValue, IResourceFactory resourceFactory) + { + PropertyInfo.SetValue(entity, newValue); + } public override string ToString() { @@ -85,8 +99,8 @@ public override int GetHashCode() /// The internal navigation property path to the related entity. /// /// - /// In all cases except the HasManyThrough relationships, this will just be the . + /// In all cases except the HasManyThrough relationships, this will just be the property name. /// - public virtual string RelationshipPath => InternalRelationshipName; + public virtual string RelationshipPath => PropertyInfo.Name; } } diff --git a/src/JsonApiDotNetCore/Models/IResourceField.cs b/src/JsonApiDotNetCore/Models/IResourceField.cs index 90ec306c93..1e64f2b64f 100644 --- a/src/JsonApiDotNetCore/Models/IResourceField.cs +++ b/src/JsonApiDotNetCore/Models/IResourceField.cs @@ -1,7 +1,7 @@ -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models { public interface IResourceField { - string ExposedInternalMemberName { get; } + string PropertyName { get; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs index a5879d7595..99e5232598 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Query @@ -12,7 +12,11 @@ public interface ISparseFieldsService : IQueryParameterService /// Gets the list of targeted fields. If a relationship is supplied, /// gets the list of targeted fields for that relationship. /// - /// List Get(RelationshipAttribute relationship = null); + + /// + /// Gets the set of all targeted fields, including fields for related entities, as a set of dotted property names. + /// + ISet GetAll(); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index f88e3c1e5e..18f527e83e 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -17,16 +17,16 @@ public class SparseFieldsService : QueryParameterService, ISparseFieldsService /// /// The selected fields for the primary resource of this request. /// - private List _selectedFields; + private readonly List _selectedFields = new List(); + /// /// The selected field for any included relationships /// - private readonly Dictionary> _selectedRelationshipFields; + private readonly Dictionary> _selectedRelationshipFields = new Dictionary>(); - public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest) + public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) + : base(resourceGraph, currentRequest) { - _selectedFields = new List(); - _selectedRelationshipFields = new Dictionary>(); } /// @@ -35,8 +35,23 @@ public List Get(RelationshipAttribute relationship = null) if (relationship == null) return _selectedFields; - _selectedRelationshipFields.TryGetValue(relationship, out var fields); - return fields; + return _selectedRelationshipFields.TryGetValue(relationship, out var fields) + ? fields + : new List(); + } + + public ISet GetAll() + { + var properties = new HashSet(); + properties.AddRange(_selectedFields.Select(x => x.PropertyInfo.Name)); + + foreach (var pair in _selectedRelationshipFields) + { + string pathPrefix = pair.Key.RelationshipPath + "."; + properties.AddRange(pair.Value.Select(x => pathPrefix + x.PropertyInfo.Name)); + } + + return properties; } /// @@ -67,9 +82,9 @@ public virtual void Parse(string parameterName, StringValues parameterValue) var keySplit = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET); if (keySplit.Length == 1) - { // input format: fields=prop1,prop2 - foreach (var field in fields) - RegisterRequestResourceField(field, parameterName); + { + // input format: fields=prop1,prop2 + RegisterRequestResourceFields(fields, parameterName); } else { // input format: fields[articles]=prop1,prop2 @@ -98,45 +113,73 @@ public virtual void Parse(string parameterName, StringValues parameterValue) $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}."); } - foreach (var field in fields) - RegisterRelatedResourceField(field, relationship, parameterName); + RegisterRelatedResourceFields(fields, relationship, parameterName); } } /// - /// Registers field selection queries of the form articles?fields[author]=firstName + /// Registers field selection of the form articles?fields[author]=firstName,lastName /// - private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship, string parameterName) + private void RegisterRelatedResourceFields(IEnumerable fields, RelationshipAttribute relationship, string parameterName) { - var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); - var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); - if (attr == null) + var selectedFields = new List(); + + foreach (var field in fields) { - // TODO: Add unit test for this error, once the nesting limitation is removed and this code becomes reachable again. + var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); + var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); + if (attr == null) + { + // TODO: Add unit test for this error, once the nesting limitation is removed and this code becomes reachable again. + + throw new InvalidQueryStringParameterException(parameterName, + "Sparse field navigation path refers to an invalid related field.", + $"Related resource '{relationship.RightType.Name}' does not contain an attribute named '{field}'."); + } - throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid related field.", - $"Related resource '{relationship.RightType.Name}' does not contain an attribute named '{field}'."); + if (attr.PropertyInfo.SetMethod == null) + { + // A read-only property was selected. Its value likely depends on another property, so include all related fields. + return; + } + + selectedFields.Add(attr); } if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields)) + { _selectedRelationshipFields.Add(relationship, registeredFields = new List()); - registeredFields.Add(attr); + } + registeredFields.AddRange(selectedFields); } /// - /// Registers field selection queries of the form articles?fields=title + /// Registers field selection of the form articles?fields=title,description /// - private void RegisterRequestResourceField(string field, string parameterName) + private void RegisterRequestResourceFields(IEnumerable fields, string parameterName) { - var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field)); - if (attr == null) + var selectedFields = new List(); + + foreach (var field in fields) { - throw new InvalidQueryStringParameterException(parameterName, - "The specified field does not exist on the requested resource.", - $"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'."); + var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field)); + if (attr == null) + { + throw new InvalidQueryStringParameterException(parameterName, + "The specified field does not exist on the requested resource.", + $"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'."); + } + + if (attr.PropertyInfo.SetMethod == null) + { + // A read-only property was selected. Its value likely depends on another property, so include all resource fields. + return; + } + + selectedFields.Add(attr); } - (_selectedFields ??= new List()).Add(attr); + _selectedFields.AddRange(selectedFields); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs index b8808b0095..c3210eb131 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Serialization.Client /// public class ResponseDeserializer : BaseDocumentParser, IResponseDeserializer { - public ResponseDeserializer(IResourceContextProvider provider) : base(provider) { } + public ResponseDeserializer(IResourceContextProvider contextProvider, IResourceFactory resourceFactory) : base(contextProvider, resourceFactory) { } /// public DeserializedSingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable @@ -65,15 +65,13 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f { // add attributes and relationships of a parsed HasOne relationship var rio = data.SingleData; - hasOneAttr.SetValue(entity, rio == null ? null : ParseIncludedRelationship(hasOneAttr, rio)); + hasOneAttr.SetValue(entity, rio == null ? null : ParseIncludedRelationship(hasOneAttr, rio), _resourceFactory); } else if (field is HasManyAttribute hasManyAttr) { // add attributes and relationships of a parsed HasMany relationship - var values = TypeHelper.CreateListFor(hasManyAttr.RightType); - foreach (var rio in data.ManyData) - values.Add(ParseIncludedRelationship(hasManyAttr, rio)); - - hasManyAttr.SetValue(entity, values); + var items = data.ManyData.Select(rio => ParseIncludedRelationship(hasManyAttr, rio)); + var values = items.CopyToTypedCollection(hasManyAttr.PropertyInfo.PropertyType); + hasManyAttr.SetValue(entity, values, _resourceFactory); } } @@ -82,14 +80,14 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f /// private IIdentifiable ParseIncludedRelationship(RelationshipAttribute relationshipAttr, ResourceIdentifierObject relatedResourceIdentifier) { - var relatedInstance = relationshipAttr.RightType.New(); + var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(relationshipAttr.RightType); relatedInstance.StringId = relatedResourceIdentifier.Id; var includedResource = GetLinkedResource(relatedResourceIdentifier); if (includedResource == null) return relatedInstance; - var resourceContext = _provider.GetResourceContext(relatedResourceIdentifier.Type); + var resourceContext = _contextProvider.GetResourceContext(relatedResourceIdentifier.Type); if (resourceContext == null) throw new InvalidOperationException($"Included type '{relationshipAttr.RightType}' is not a registered json:api resource."); diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 1454c0ef2b..b4626bf884 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -21,12 +21,14 @@ namespace JsonApiDotNetCore.Serialization /// public abstract class BaseDocumentParser { - protected readonly IResourceContextProvider _provider; + protected readonly IResourceContextProvider _contextProvider; + protected readonly IResourceFactory _resourceFactory; protected Document _document; - protected BaseDocumentParser(IResourceContextProvider provider) + protected BaseDocumentParser(IResourceContextProvider contextProvider, IResourceFactory resourceFactory) { - _provider = provider; + _contextProvider = contextProvider; + _resourceFactory = resourceFactory; } /// @@ -131,7 +133,7 @@ private JToken LoadJToken(string body) /// The parsed entity private IIdentifiable ParseResourceObject(ResourceObject data) { - var resourceContext = _provider.GetResourceContext(data.Type); + var resourceContext = _contextProvider.GetResourceContext(data.Type); if (resourceContext == null) { throw new InvalidRequestBodyException("Payload includes unknown resource type.", @@ -140,7 +142,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) "If you have manually registered the resource, check that the call to AddResource correctly sets the public name.", null); } - var entity = resourceContext.ResourceType.New(); + var entity = (IIdentifiable)_resourceFactory.CreateInstance(resourceContext.ResourceType); entity = SetAttributes(entity, data.Attributes, resourceContext.Attributes); entity = SetRelationships(entity, data.Relationships, resourceContext.Relationships); @@ -196,8 +198,9 @@ private void SetForeignKey(IIdentifiable entity, PropertyInfo foreignKey, HasOne // For a server deserializer, it should be mapped to a BadRequest HTTP error code. throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); } - var convertedId = TypeHelper.ConvertType(id, foreignKey.PropertyType); - foreignKey.SetValue(entity, convertedId); + + var typedId = TypeHelper.ConvertStringIdToTypedId(attr.PropertyInfo.PropertyType, id, _resourceFactory); + foreignKey.SetValue(entity, typedId); } /// @@ -208,13 +211,13 @@ private void SetNavigation(IIdentifiable entity, HasOneAttribute attr, string re { if (relatedId == null) { - attr.SetValue(entity, null); + attr.SetValue(entity, null, _resourceFactory); } else { - var relatedInstance = attr.RightType.New(); + var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(attr.RightType); relatedInstance.StringId = relatedId; - attr.SetValue(entity, relatedInstance); + attr.SetValue(entity, relatedInstance, _resourceFactory); } } @@ -230,12 +233,13 @@ private void SetHasManyRelationship( { // if the relationship is set to null, no need to set the navigation property to null: this is the default value. var relatedResources = relationshipData.ManyData.Select(rio => { - var relatedInstance = attr.RightType.New(); + var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(attr.RightType); relatedInstance.StringId = rio.Id; return relatedInstance; }); - var convertedCollection = relatedResources.Cast(attr.RightType); - attr.SetValue(entity, convertedCollection); + + var convertedCollection = relatedResources.CopyToTypedCollection(attr.PropertyInfo.PropertyType); + attr.SetValue(entity, convertedCollection, _resourceFactory); } AfterProcessField(entity, attr, relationshipData); diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index c2f950995f..2346632e60 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -69,9 +69,7 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r private bool ShouldInclude(RelationshipAttribute relationship, out List> inclusionChain) { inclusionChain = _includeService.Get()?.Where(l => l.First().Equals(relationship)).ToList(); - if (inclusionChain == null || !inclusionChain.Any()) - return false; - return true; + return inclusionChain != null && inclusionChain.Any(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs index 8991113311..8bf89c4727 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs @@ -34,7 +34,7 @@ public List GetAllowedAttributes(Type type, RelationshipAttribute allowed = allowed.Intersect(resourceDefinition.GetAllowedAttributes()).ToList(); var sparseFieldsSelection = _sparseFieldsService.Get(relationship); - if (sparseFieldsSelection != null && sparseFieldsSelection.Any()) + if (sparseFieldsSelection.Any()) // from the allowed attributes, select the ones flagged by sparse field selection. allowed = allowed.Intersect(sparseFieldsSelection).ToList(); diff --git a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs index 1302e2b0bb..c17ac03473 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs @@ -1,5 +1,6 @@ using System; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; @@ -12,8 +13,8 @@ public class RequestDeserializer : BaseDocumentParser, IJsonApiDeserializer { private readonly ITargetedFields _targetedFields; - public RequestDeserializer(IResourceContextProvider provider, - ITargetedFields targetedFields) : base(provider) + public RequestDeserializer(IResourceContextProvider contextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields) + : base(contextProvider, resourceFactory) { _targetedFields = targetedFields; } diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index 9df4450cfc..dac9ee7aff 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -32,6 +32,7 @@ public class DefaultResourceService : private readonly ISortService _sortService; private readonly IResourceRepository _repository; private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceFactory _resourceFactory; private readonly ILogger _logger; private readonly IResourceHookExecutor _hookExecutor; private readonly IIncludeService _includeService; @@ -45,6 +46,7 @@ public DefaultResourceService( IResourceRepository repository, IResourceContextProvider provider, IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) { _includeService = queryParameters.FirstOrDefault(); @@ -56,19 +58,19 @@ public DefaultResourceService( _logger = loggerFactory.CreateLogger>(); _repository = repository; _resourceChangeTracker = resourceChangeTracker; + _resourceFactory = resourceFactory; _hookExecutor = hookExecutor; _currentRequestResource = provider.GetResourceContext(); } public virtual async Task CreateAsync(TResource entity) { - _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(CreateAsync)}(object)."); entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeCreate(AsList(entity), ResourcePipeline.Post).SingleOrDefault(); - entity = await _repository.CreateAsync(entity); + await _repository.CreateAsync(entity); - if (_includeService.Get().Any()) - entity = await GetWithRelationshipsAsync(entity.Id); + entity = await GetWithRelationshipsAsync(entity.Id); if (!IsNull(_hookExecutor, entity)) { @@ -82,18 +84,28 @@ public virtual async Task DeleteAsync(TId id) { _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); - var entity = typeof(TResource).New(); - entity.Id = id; - if (!IsNull(_hookExecutor, entity)) _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); - - var succeeded = await _repository.DeleteAsync(entity.Id); + if (!IsNull(_hookExecutor)) + { + var entity = _resourceFactory.CreateInstance(); + entity.Id = id; + + _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); + } + + var succeeded = await _repository.DeleteAsync(id); if (!succeeded) { - string resourceId = TypeExtensions.GetResourceStringId(id); + string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } - if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded); + if (!IsNull(_hookExecutor)) + { + var entity = _resourceFactory.CreateInstance(); + entity.Id = id; + + _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded); + } } public virtual async Task> GetAsync() @@ -137,7 +149,7 @@ public virtual async Task GetAsync(TId id) if (entity == null) { - string resourceId = TypeExtensions.GetResourceStringId(id); + string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } @@ -160,12 +172,12 @@ public virtual async Task GetRelationshipsAsync(TId id, string relati // BeforeRead hook execution _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - var entityQuery = ApplyInclude(_repository.Get(id), chainPrefix: new List { relationship }); + var entityQuery = ApplyInclude(_repository.Get(id), relationship); var entity = await _repository.FirstOrDefaultAsync(entityQuery); if (entity == null) { - string resourceId = TypeExtensions.GetResourceStringId(id); + string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } @@ -195,7 +207,7 @@ public virtual async Task UpdateAsync(TId id, TResource requestEntity TResource databaseEntity = await _repository.Get(id).FirstOrDefaultAsync(); if (databaseEntity == null) { - string resourceId = TypeExtensions.GetResourceStringId(id); + string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } @@ -231,7 +243,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa if (entity == null) { - string resourceId = TypeExtensions.GetResourceStringId(id); + string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } @@ -300,78 +312,92 @@ protected virtual IQueryable ApplyFilter(IQueryable entiti return entities; } - /// /// Applies include queries /// - protected virtual IQueryable ApplyInclude(IQueryable entities, IEnumerable chainPrefix = null) + protected virtual IQueryable ApplyInclude(IQueryable entities, RelationshipAttribute chainPrefix = null) { var chains = _includeService.Get(); - bool hasInclusionChain = chains.Any(); - if (chains == null) + if (chainPrefix != null) { - throw new Exception(); + chains.Add(new List()); } - if (chainPrefix != null && !hasInclusionChain) + foreach (var inclusionChain in chains) { - hasInclusionChain = true; - chains.Add(new List()); - } - - - if (hasInclusionChain) - { - foreach (var inclusionChain in chains) + if (chainPrefix != null) { - if (chainPrefix != null) - { - inclusionChain.InsertRange(0, chainPrefix); - } - entities = _repository.Include(entities, inclusionChain.ToArray()); + inclusionChain.Insert(0, chainPrefix); } + + entities = _repository.Include(entities, inclusionChain); } return entities; } /// - /// Applies sparse field selection queries + /// Applies sparse field selection to queries /// - /// - /// protected virtual IQueryable ApplySelect(IQueryable entities) { - var fields = _sparseFieldsService.Get(); - if (fields != null && fields.Any()) - entities = _repository.Select(entities, fields.ToArray()); + var propertyNames = _sparseFieldsService.GetAll(); + + if (propertyNames.Any()) + { + // All resources without a sparse fieldset specified must be entirely selected. + EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(propertyNames); + } + entities = _repository.Select(entities, propertyNames); return entities; } + private void EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(ISet propertyNames) + { + bool hasTopLevelSparseFieldSet = propertyNames.Any(x => !x.Contains(".")); + if (!hasTopLevelSparseFieldSet) + { + var topPropertyNames = _currentRequestResource.Attributes + .Where(x => x.PropertyInfo.SetMethod != null) + .Select(x => x.PropertyInfo.Name); + propertyNames.AddRange(topPropertyNames); + } + + var chains = _includeService.Get(); + foreach (var inclusionChain in chains) + { + string relationshipPath = null; + foreach (var relationship in inclusionChain) + { + relationshipPath = relationshipPath == null + ? relationship.RelationshipPath + : $"{relationshipPath}.{relationship.RelationshipPath}"; + } + + if (relationshipPath != null) + { + bool hasRelationSparseFieldSet = propertyNames.Any(x => x.StartsWith(relationshipPath + ".")); + if (!hasRelationSparseFieldSet) + { + propertyNames.Add(relationshipPath); + } + } + } + } + /// /// Get the specified id with relationships provided in the post request /// - /// i - /// private async Task GetWithRelationshipsAsync(TId id) { - var sparseFieldset = _sparseFieldsService.Get(); - var query = _repository.Select(_repository.Get(id), sparseFieldset.ToArray()); - - foreach (var chain in _includeService.Get()) - query = _repository.Include(query, chain.ToArray()); - - TResource value; - // https://github.com/aspnet/EntityFrameworkCore/issues/6573 - if (sparseFieldset.Any()) - value = query.FirstOrDefault(); - else - value = await _repository.FirstOrDefaultAsync(query); + var query = _repository.Get(id); + query = ApplyInclude(query); + query = ApplySelect(query); - - return value; + var entity = await _repository.FirstOrDefaultAsync(query); + return entity; } private bool IsNull(params object[] values) @@ -414,8 +440,9 @@ public DefaultResourceService( IResourceRepository repository, IResourceContextProvider provider, IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) { } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index bfaa340554..e58de0c4dc 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Managers.Contracts; @@ -18,6 +19,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -38,15 +40,16 @@ public ServiceDiscoveryFacadeTests() _services.AddSingleton(options); _services.AddSingleton(new LoggerFactory()); - _services.AddScoped((_) => new Mock().Object); - _services.AddScoped((_) => new Mock().Object); - _services.AddScoped((_) => new Mock().Object); - _services.AddScoped((_) => new Mock().Object); - _services.AddScoped((_) => new Mock().Object); - _services.AddScoped((_) => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); + _services.AddScoped(_ => new Mock().Object); - _resourceGraphBuilder = new ResourceGraphBuilder(options); + _resourceGraphBuilder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); } private ServiceDiscoveryFacade Facade => new ServiceDiscoveryFacade(_services, _resourceGraphBuilder); @@ -114,8 +117,9 @@ public TestModelService( IResourceRepository repository, IResourceContextProvider provider, IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, hookExecutor) + : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) { } } @@ -127,8 +131,9 @@ public TestModelRepository( ITargetedFields targetedFields, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, + IResourceFactory resourceFactory, ILoggerFactory loggerFactory) - : base(targetedFields, _dbContextResolver, resourceGraph, genericServiceFactory, loggerFactory) + : base(targetedFields, _dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) { } } } diff --git a/test/IntegrationTests/Data/EntityRepositoryTests.cs b/test/IntegrationTests/Data/EntityRepositoryTests.cs index 4ef139c18d..2182ac475c 100644 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityRepositoryTests.cs @@ -10,7 +10,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using IntegrationTests; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -160,11 +163,13 @@ public async Task Paging_PageNumberIsNegative_GiveBackReverseAmountOfIds(int pag private (DefaultResourceRepository Repository, Mock TargetedFields) Setup(AppDbContext context) { + var serviceProvider = ((IInfrastructure) context).Instance; + var resourceFactory = new DefaultResourceFactory(serviceProvider); var contextResolverMock = new Mock(); contextResolverMock.Setup(m => m.GetContext()).Returns(context); - var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build(); + var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build(); var targetedFields = new Mock(); - var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, null, NullLoggerFactory.Instance); + var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, null, resourceFactory, NullLoggerFactory.Instance); return (repository, targetedFields); } @@ -174,9 +179,9 @@ private AppDbContext GetContext(Guid? seed = null) var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"IntegrationDatabaseRepository{actualSeed}") .Options; - var context = new AppDbContext(options); + var context = new AppDbContext(options, new FrozenSystemClock()); - context.TodoItems.RemoveRange(context.TodoItems.ToList()); + context.TodoItems.RemoveRange(context.TodoItems); return context; } diff --git a/test/IntegrationTests/FrozenSystemClock.cs b/test/IntegrationTests/FrozenSystemClock.cs new file mode 100644 index 0000000000..ff49df2f07 --- /dev/null +++ b/test/IntegrationTests/FrozenSystemClock.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace IntegrationTests +{ + internal class FrozenSystemClock : ISystemClock + { + public DateTimeOffset UtcNow { get; } + + public FrozenSystemClock() + : this(new DateTimeOffset(new DateTime(2000, 1, 1))) + { + } + + public FrozenSystemClock(DateTimeOffset utcNow) + { + UtcNow = utcNow; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs index 3ed690b9b9..bc337196eb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Newtonsoft.Json; @@ -13,9 +14,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class ActionResultTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; - public ActionResultTests(TestFixture fixture) + public ActionResultTests(TestFixture fixture) { _fixture = fixture; } @@ -40,7 +41,7 @@ public async Task ActionResult_With_Error_Object_Is_Converted_To_Error_Collectio }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index cb047ff96a..e3d86523a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -4,7 +4,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -21,11 +21,11 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility [Collection("WebHostCollection")] public sealed class CustomControllerTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public CustomControllerTests(TestFixture fixture) + public CustomControllerTests(TestFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() @@ -41,7 +41,7 @@ public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() { // Arrange var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "testValues"; @@ -61,7 +61,7 @@ public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() { // Arrange var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/custom/route/todoItems"; @@ -88,7 +88,7 @@ public async Task CustomRouteControllers_Uses_Dasherized_Item_Route() await context.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/custom/route/todoItems/{todoItem.Id}"; @@ -114,7 +114,7 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/custom/route/todoItems/{todoItem.Id}"; @@ -139,7 +139,7 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var route = "/custom/route/todoItems/99999999"; var requestBody = new @@ -160,7 +160,7 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs index e1c7063789..892664e0fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs @@ -21,7 +21,7 @@ public sealed class IgnoreDefaultValuesTests : IAsyncLifetime private readonly AppDbContext _dbContext; private readonly TodoItem _todoItem; - public IgnoreDefaultValuesTests(TestFixture fixture) + public IgnoreDefaultValuesTests(TestFixture fixture) { _dbContext = fixture.GetService(); var todoItem = new TodoItem @@ -90,7 +90,7 @@ public Task DisposeAsync() [InlineData(DefaultValueHandling.Include, true, "", null)] public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, DefaultValueHandling? expected) { - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var services = server.Host.Services; var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs index 83f4bc9d5f..a1dc725a22 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs @@ -21,15 +21,15 @@ public sealed class IgnoreNullValuesTests : IAsyncLifetime private readonly AppDbContext _dbContext; private readonly TodoItem _todoItem; - public IgnoreNullValuesTests(TestFixture fixture) + public IgnoreNullValuesTests(TestFixture fixture) { _dbContext = fixture.GetService(); var todoItem = new TodoItem { Description = null, Ordinal = 1, - CreatedDate = DateTime.Now, - AchievedDate = DateTime.Now.AddDays(2), + CreatedDate = new DateTime(2002, 2,2), + AchievedDate = new DateTime(2002, 2,4), Owner = new Person { FirstName = "Bob", LastName = null } }; _todoItem = _dbContext.TodoItems.Add(todoItem).Entity; @@ -93,7 +93,7 @@ public Task DisposeAsync() [InlineData(NullValueHandling.Include, true, "", null)] public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, NullValueHandling? expected) { - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var services = server.Host.Services; var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs index 46128676c5..b56c6f0bb3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs @@ -14,9 +14,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility [Collection("WebHostCollection")] public sealed class RequestMetaTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; - public RequestMetaTests(TestFixture fixture) + public RequestMetaTests(TestFixture fixture) { _fixture = fixture; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs index c7428dfa12..9154fd08fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs @@ -93,7 +93,7 @@ public async Task Rejects_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs index d58df9116f..f77c0c5335 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs @@ -79,7 +79,7 @@ public async Task Rejects_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs index e8c1b5bf02..a8d9034a3e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs @@ -79,7 +79,7 @@ public async Task Allows_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs index c7d2d07482..c56a18eafa 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs @@ -79,7 +79,7 @@ public async Task Allows_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs new file mode 100644 index 0000000000..f846325b7f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -0,0 +1,222 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public class InjectableResourceTests + { + private readonly TestFixture _fixture; + private readonly AppDbContext _context; + private readonly Faker _personFaker; + private readonly Faker _todoItemFaker; + private readonly Faker _passportFaker; + private readonly Faker _countryFaker; + private readonly Faker _visaFaker; + + public InjectableResourceTests(TestFixture 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()) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + _personFaker = new Faker() + .RuleFor(t => t.FirstName, f => f.Name.FirstName()) + .RuleFor(t => t.LastName, f => f.Name.LastName()); + _passportFaker = new Faker() + .CustomInstantiator(f => new Passport(_context)) + .RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number(100, 10_000)); + _countryFaker = new Faker() + .RuleFor(c => c.Name, f => f.Address.Country()); + _visaFaker = new Faker() + .RuleFor(v => v.ExpiresAt, f => f.Date.Future()); + } + + [Fact] + public async Task Can_Get_Single_Passport() + { + // Arrange + var passport = _passportFaker.Generate(); + passport.BirthCountry = _countryFaker.Generate(); + + _context.Passports.Add(passport); + _context.SaveChanges(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports/" + passport.StringId); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + + Assert.NotNull(document.SingleData); + Assert.Equal(passport.IsLocked, document.SingleData.Attributes["isLocked"]); + } + + [Fact] + public async Task Can_Get_Passports() + { + // Arrange + _context.Passports.RemoveRange(_context.Passports); + _context.SaveChanges(); + + var passports = _passportFaker.Generate(3); + foreach (var passport in passports) + { + passport.BirthCountry = _countryFaker.Generate(); + } + + _context.Passports.AddRange(passports); + _context.SaveChanges(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports"); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + + Assert.Equal(3, document.ManyData.Count); + foreach (var passport in passports) + { + Assert.Contains(document.ManyData, + resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); + Assert.Contains(document.ManyData, + resource => (string)resource.Attributes["birthCountryName"] == passport.BirthCountryName); + } + } + + [Fact] + public async Task Can_Get_Passports_With_Filter() + { + // Arrange + _context.Passports.RemoveRange(_context.Passports); + _context.SaveChanges(); + + var passports = _passportFaker.Generate(3); + foreach (var passport in passports) + { + passport.SocialSecurityNumber = 11111; + passport.BirthCountry = _countryFaker.Generate(); + passport.Person = _personFaker.Generate(); + passport.Person.FirstName = "Jack"; + } + + passports[2].SocialSecurityNumber = 12345; + passports[2].Person.FirstName= "Joe"; + + _context.Passports.AddRange(passports); + _context.SaveChanges(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&filter[socialSecurityNumber]=12345&filter[person.firstName]=Joe"); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + + Assert.Single(document.ManyData); + Assert.Equal(12345L, document.ManyData[0].Attributes["socialSecurityNumber"]); + + Assert.Single(document.Included); + Assert.Equal("Joe", document.Included[0].Attributes["firstName"]); + } + + [Fact(Skip = "https://github.com/dotnet/efcore/issues/20502")] + public async Task Can_Get_Passports_With_Sparse_Fieldset() + { + // Arrange + _context.Passports.RemoveRange(_context.Passports); + _context.SaveChanges(); + + var passports = _passportFaker.Generate(2); + foreach (var passport in passports) + { + passport.BirthCountry = _countryFaker.Generate(); + passport.Person = _personFaker.Generate(); + } + + _context.Passports.AddRange(passports); + _context.SaveChanges(); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields=socialSecurityNumber&fields[person]=firstName"); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + + Assert.Equal(2, document.ManyData.Count); + foreach (var passport in passports) + { + Assert.Contains(document.ManyData, + resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); + } + + Assert.DoesNotContain(document.ManyData, + resource => resource.Attributes.ContainsKey("isLocked")); + + Assert.Equal(2, document.Included.Count); + foreach (var person in passports.Select(p => p.Person)) + { + Assert.Contains(document.Included, + resource => (string) resource.Attributes["firstName"] == person.FirstName); + } + + Assert.DoesNotContain(document.Included, + resource => resource.Attributes.ContainsKey("lastName")); + } + + [Fact] + public async Task Fail_When_Deleting_Missing_Passport() + { + // Arrange + string passportId = HexadecimalObfuscationCodec.Encode(1234567890); + + var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/" + passportId); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + _fixture.AssertEqualStatusCode(HttpStatusCode.NotFound, response); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'passports' with id '" + passportId + "' does not exist.", errorDocument.Errors[0].Detail); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index b4e194a962..94cffa66bb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -20,16 +21,21 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class ManyToManyTests { - private static readonly Faker
_articleFaker = new Faker
() + private readonly Faker
_articleFaker = new Faker
() .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) .RuleFor(a => a.Author, f => new Author()); - private static readonly Faker _tagFaker = new Faker().RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); + private readonly Faker _tagFaker; - private readonly TestFixture _fixture; - public ManyToManyTests(TestFixture fixture) + private readonly TestFixture _fixture; + + public ManyToManyTests(TestFixture fixture) { _fixture = fixture; + + _tagFaker = new Faker() + .CustomInstantiator(f => new Tag(_fixture.GetService())) + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } [Fact] @@ -43,7 +49,7 @@ public async Task Can_Fetch_Many_To_Many_Through_All() context.Articles.RemoveRange(context.Articles); await context.SaveChangesAsync(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -54,7 +60,7 @@ public async Task Can_Fetch_Many_To_Many_Through_All() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -87,7 +93,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -99,7 +105,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -129,7 +135,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Id() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -141,7 +147,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Id() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -168,7 +174,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -180,7 +186,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -206,7 +212,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -218,7 +224,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -244,7 +250,7 @@ public async Task Can_Fetch_Many_To_Many_Without_Include() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = tag @@ -255,7 +261,7 @@ public async Task Can_Fetch_Many_To_Many_Without_Include() // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -310,11 +316,11 @@ public async Task Can_Create_Many_To_Many() } }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -369,11 +375,11 @@ public async Task Can_Update_Many_To_Many() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -403,7 +409,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() var context = _fixture.GetService(); var firstTag = _tagFaker.Generate(); var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = firstTag @@ -435,10 +441,10 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -467,7 +473,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap var context = _fixture.GetService(); var firstTag = _tagFaker.Generate(); var article = _articleFaker.Generate(); - var articleTag = new ArticleTag + var articleTag = new ArticleTag(context) { Article = article, Tag = firstTag @@ -503,10 +509,10 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -552,10 +558,10 @@ public async Task Can_Update_Many_To_Many_Through_Relationship_Link() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index 9ae4d1c971..7907b28305 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -2,8 +2,8 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -24,7 +24,7 @@ public ModelStateValidationTests(StandardApplicationFactory factory) public async Task When_posting_tag_with_invalid_name_it_must_fail() { // Arrange - var tag = new Tag + var tag = new Tag(_dbContext) { Name = "!@#$%^&*().-" }; @@ -36,7 +36,7 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = true; @@ -60,7 +60,7 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() public async Task When_posting_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange - var tag = new Tag + var tag = new Tag(_dbContext) { Name = "!@#$%^&*().-" }; @@ -72,7 +72,7 @@ public async Task When_posting_tag_with_invalid_name_without_model_state_validat { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = false; @@ -88,7 +88,7 @@ public async Task When_posting_tag_with_invalid_name_without_model_state_validat public async Task When_patching_tag_with_invalid_name_it_must_fail() { // Arrange - var existingTag = new Tag + var existingTag = new Tag(_dbContext) { Name = "Technology" }; @@ -97,7 +97,7 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() context.Tags.Add(existingTag); context.SaveChanges(); - var updatedTag = new Tag + var updatedTag = new Tag(_dbContext) { Id = existingTag.Id, Name = "!@#$%^&*().-" @@ -110,7 +110,7 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = true; @@ -134,7 +134,7 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() public async Task When_patching_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange - var existingTag = new Tag + var existingTag = new Tag(_dbContext) { Name = "Technology" }; @@ -143,7 +143,7 @@ public async Task When_patching_tag_with_invalid_name_without_model_state_valida context.Tags.Add(existingTag); context.SaveChanges(); - var updatedTag = new Tag + var updatedTag = new Tag(_dbContext) { Id = existingTag.Id, Name = "!@#$%^&*().-" @@ -156,7 +156,7 @@ public async Task When_patching_tag_with_invalid_name_without_model_state_valida { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = false; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs new file mode 100644 index 0000000000..eff5795f94 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + public sealed class NonJsonApiControllerTests + { + [Fact] + public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Get() + { + // Arrange + const string route = "testValues"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("[\"value\"]", body); + } + + [Fact] + public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Post() + { + // Arrange + const string route = "testValues?name=Jack"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent("XXX")}; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello, Jack", body); + } + + [Fact] + public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Patch() + { + // Arrange + const string route = "testValues?name=Jack"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent("XXX")}; + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Hello, Jack", body); + } + + [Fact] + public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Delete() + { + // Arrange + const string route = "testValues"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Delete, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Deleted", body); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs index 6c312ddd3d..e83a63d60d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs @@ -15,18 +15,19 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class QueryFiltersTests { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _userFaker; + private readonly TestFixture _fixture; + private readonly AppDbContext _context; + private readonly Faker _userFaker; - public QueryFiltersTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetService(); - _userFaker = new Faker() - .RuleFor(u => u.Username, f => f.Internet.UserName()) - .RuleFor(u => u.Password, f => f.Internet.Password()); - } + public QueryFiltersTests(TestFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _userFaker = new Faker() + .CustomInstantiator(f => new User(_context)) + .RuleFor(u => u.Username, f => f.Internet.UserName()) + .RuleFor(u => u.Password, f => f.Internet.Password()); + } [Fact] public async Task FiltersWithCustomQueryFiltersEquals() @@ -42,7 +43,7 @@ public async Task FiltersWithCustomQueryFiltersEquals() var request = new HttpRequestMessage(httpMethod, route); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -74,7 +75,7 @@ public async Task FiltersWithCustomQueryFiltersLessThan() var request = new HttpRequestMessage(httpMethod, route); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index c5b3195b39..41ea2d9d45 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; @@ -20,21 +21,23 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class ResourceDefinitionTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly AppDbContext _context; private readonly Faker _userFaker; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - private static readonly Faker
_articleFaker = new Faker
() + private readonly Faker
_articleFaker = new Faker
() .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) .RuleFor(a => a.Author, f => new Author()); - private static readonly Faker _tagFaker = new Faker().RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - public ResourceDefinitionTests(TestFixture fixture) + private readonly Faker _tagFaker; + + public ResourceDefinitionTests(TestFixture fixture) { _fixture = fixture; _context = fixture.GetService(); _userFaker = new Faker() + .CustomInstantiator(f => new User(_context)) .RuleFor(u => u.Username, f => f.Internet.UserName()) .RuleFor(u => u.Password, f => f.Internet.Password()); _todoItemFaker = new Faker() @@ -44,6 +47,9 @@ public ResourceDefinitionTests(TestFixture fixture) _personFaker = new Faker() .RuleFor(p => p.FirstName, f => f.Name.FirstName()) .RuleFor(p => p.LastName, f => f.Name.LastName()); + _tagFaker = new Faker() + .CustomInstantiator(f => new Tag(_context)) + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } [Fact] @@ -84,7 +90,7 @@ public async Task Can_Create_User_With_Password() { Content = new StringContent(serializer.Serialize(user)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -120,7 +126,7 @@ public async Task Can_Update_User_Password() { Content = new StringContent(serializer.Serialize(user)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -213,7 +219,7 @@ public async Task Article_Is_Hidden() // Arrange var context = _fixture.GetService(); - var articles = _articleFaker.Generate(3).ToList(); + var articles = _articleFaker.Generate(3); string toBeExcluded = "This should not be included"; articles[0].Name = toBeExcluded; @@ -246,12 +252,12 @@ public async Task Tag_Is_Hidden() var articleTags = new[] { - new ArticleTag + new ArticleTag(context) { Article = article, Tag = tags[0] }, - new ArticleTag + new ArticleTag(context) { Article = article, Tag = tags[1] @@ -284,7 +290,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() var context = _fixture.GetService(); var lockedPerson = _personFaker.Generate(); lockedPerson.IsLocked = true; - var passport = new Passport(); + var passport = new Passport(context); lockedPerson.Passport = passport; context.People.AddRange(lockedPerson); await context.SaveChangesAsync(); @@ -298,7 +304,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{lockedPerson.Passport.Id}" } + data = new { type = "passports", id = $"{lockedPerson.Passport.StringId}" } } } } @@ -311,7 +317,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -333,10 +339,10 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() // Arrange var context = _fixture.GetService(); var person = _personFaker.Generate(); - var passport = new Passport { IsLocked = true }; + var passport = new Passport(context) { IsLocked = true }; person.Passport = passport; context.People.AddRange(person); - var newPassport = new Passport(); + var newPassport = new Passport(context); context.Passports.Add(newPassport); await context.SaveChangesAsync(); @@ -350,7 +356,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() { { "passport", new { - data = new { type = "passports", id = $"{newPassport.Id}" } + data = new { type = "passports", id = $"{newPassport.StringId}" } } } } @@ -363,7 +369,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -385,10 +391,10 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( // Arrange var context = _fixture.GetService(); var person = _personFaker.Generate(); - var passport = new Passport { IsLocked = true }; + var passport = new Passport(context) { IsLocked = true }; person.Passport = passport; context.People.AddRange(person); - var newPassport = new Passport(); + var newPassport = new Passport(context); context.Passports.Add(newPassport); await context.SaveChangesAsync(); @@ -415,7 +421,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -438,13 +444,13 @@ public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() var context = _fixture.GetService(); var lockedPerson = _personFaker.Generate(); lockedPerson.IsLocked = true; - var passport = new Passport(); + var passport = new Passport(context); lockedPerson.Passport = passport; context.People.AddRange(lockedPerson); await context.SaveChangesAsync(); var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/passports/{lockedPerson.PassportId}"; + var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; var request = new HttpRequestMessage(httpMethod, route); // Act @@ -466,10 +472,10 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() { // Arrange var context = _fixture.GetService(); - var persons = _personFaker.Generate(2).ToList(); + var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons; + lockedTodo.StakeHolders = persons.ToHashSet(); context.TodoItems.Add(lockedTodo); await context.SaveChangesAsync(); @@ -484,8 +490,8 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() { data = new object[] { - new { type = "people", id = $"{lockedTodo.StakeHolders[0].Id}" }, - new { type = "people", id = $"{lockedTodo.StakeHolders[1].Id}" } + new { type = "people", id = $"{persons[0].Id}" }, + new { type = "people", id = $"{persons[1].Id}" } } } @@ -500,7 +506,7 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -521,10 +527,10 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() { // Arrange var context = _fixture.GetService(); - var persons = _personFaker.Generate(2).ToList(); + var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons; + lockedTodo.StakeHolders = persons.ToHashSet(); context.TodoItems.Add(lockedTodo); var unlockedTodo = _todoItemFaker.Generate(); context.TodoItems.Add(unlockedTodo); @@ -542,8 +548,8 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() { data = new object[] { - new { type = "people", id = $"{lockedTodo.StakeHolders[0].Id}" }, - new { type = "people", id = $"{lockedTodo.StakeHolders[1].Id}" } + new { type = "people", id = $"{persons[0].Id}" }, + new { type = "people", id = $"{persons[1].Id}" } } } @@ -558,7 +564,7 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -579,15 +585,15 @@ public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() { // Arrange var context = _fixture.GetService(); - var persons = _personFaker.Generate(2).ToList(); + var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons; + lockedTodo.StakeHolders = persons.ToHashSet(); context.TodoItems.Add(lockedTodo); await context.SaveChangesAsync(); var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/people/{lockedTodo.StakeHolders[0].Id}"; + var route = $"/api/v1/people/{persons[0].Id}"; var request = new HttpRequestMessage(httpMethod, route); // Act diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 66f596a6e3..ac692ce4fb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs @@ -1,11 +1,6 @@ using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Acceptance.Spec; using Newtonsoft.Json; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 25165bc961..71b3e7c36e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -19,11 +19,11 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class AttributeFilterTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public AttributeFilterTests(TestFixture fixture) + public AttributeFilterTests(TestFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() @@ -94,7 +94,7 @@ public async Task Cannot_Filter_If_Explicitly_Forbidden() { // Arrange var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?include=owner&filter[achievedDate]={DateTime.UtcNow.Date}"; + var route = $"/api/v1/todoItems?include=owner&filter[achievedDate]={new DateTime(2002, 2, 2).ToShortDateString()}"; var request = new HttpRequestMessage(httpMethod, route); // Act diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs index 1266afbf83..0400ad6346 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs @@ -11,9 +11,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class AttributeSortTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; - public AttributeSortTests(TestFixture fixture) + public AttributeSortTests(TestFixture fixture) { _fixture = fixture; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs deleted file mode 100644 index 0f9b6db262..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class ContentNegotiation - { - [Fact] - public async Task Server_Sends_Correct_ContentType_Header() - { - // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); - } - - [Fact] - public async Task Server_Responds_415_With_MediaType_Parameters() - { - // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(string.Empty)}; - request.Content.Headers.ContentType = - new MediaTypeHeaderValue("application/vnd.api+json") {CharSet = "ISO-8859-4"}; - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); - Assert.Equal("Please specify 'application/vnd.api+json' for the Content-Type header value.", errorDocument.Errors[0].Detail); - - Assert.Equal( - @"{""errors"":[{""id"":""" + errorDocument.Errors[0].Id + - @""",""status"":""415"",""title"":""The specified Content-Type header value is not supported."",""detail"":""Please specify 'application/vnd.api+json' for the Content-Type header value.""}]}", - body); - } - - [Fact] - public async Task ServerResponds_406_If_RequestAcceptHeader_Contains_MediaTypeParameters() - { - // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var acceptHeader = new MediaTypeWithQualityHeaderValue("application/vnd.api+json") {CharSet = "ISO-8859-4"}; - client.DefaultRequestHeaders - .Accept - .Add(acceptHeader); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotAcceptable, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified Accept header value is not supported.", errorDocument.Errors[0].Title); - Assert.Equal("Please specify 'application/vnd.api+json' for the Accept header value.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs new file mode 100644 index 0000000000..7c1e20d6d1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs @@ -0,0 +1,295 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class ContentNegotiationTests + { + private readonly TestFixture _fixture; + + public ContentNegotiationTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Server_Sends_Correct_ContentType_Header() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HeaderConstants.MediaType, response.Content.Headers.ContentType.ToString()); + } + + [Fact] + public async Task Respond_415_If_Content_Type_Header_Is_Not_JsonApi_Media_Type() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(new HttpMethod("POST"), route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/html"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Respond_201_If_Content_Type_Header_Is_JsonApi_Media_Type() + { + // Arrange + var serializer = _fixture.GetSerializer(e => new { e.Description }); + var todoItem = new TodoItem {Description = "something not to forget"}; + + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent(serializer.Serialize(todoItem))}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Profile() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(new HttpMethod("POST"), route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType + "; profile=something"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; profile=something' for the Content-Type header value.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Extension() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(new HttpMethod("POST"), route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType + "; ext=something"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; ext=something' for the Content-Type header value.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_CharSet() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType + "; charset=ISO-8859-4"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; charset=ISO-8859-4' for the Content-Type header value.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Unknown() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' instead of 'application/vnd.api+json; unknown=unexpected' for the Content-Type header value.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Respond_200_If_Accept_Headers_Are_Missing() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Respond_200_If_Accept_Headers_Include_Any() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Respond_200_If_Accept_Headers_Include_Application_Prefix() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*")); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Respond_200_If_Accept_Headers_Contain_JsonApi_Media_Type() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Respond_406_If_Accept_Headers_Only_Contain_JsonApi_Media_Type_With_Parameters() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var route = "/api/v1/todoItems"; + var server = new TestServer(builder); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected")); + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; charset=ISO-8859-4")); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotAcceptable, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Accept header value does not contain any supported media types.", errorDocument.Errors[0].Title); + Assert.Equal("Please include 'application/vnd.api+json' in the Accept header values.", errorDocument.Errors[0].Detail); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index d9d4f0cb74..f821fee096 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -35,7 +35,7 @@ public async Task CreateResource_ModelWithEntityFrameworkInheritance_IsCreated() { // Arrange var serializer = GetSerializer(e => new { e.SecurityLevel, e.Username, e.Password }); - var superUser = new SuperUser { SecurityLevel = 1337, Username = "Super", Password = "User" }; + var superUser = new SuperUser(_dbContext) { SecurityLevel = 1337, Username = "Super", Password = "User" }; // Act var (body, response) = await Post("/api/v1/superUsers", serializer.Serialize(superUser)); @@ -43,7 +43,7 @@ public async Task CreateResource_ModelWithEntityFrameworkInheritance_IsCreated() // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); var createdSuperUser = _deserializer.DeserializeSingle(body).Data; - var first = _dbContext.SuperUsers.FirstOrDefault(e => e.Id.Equals(createdSuperUser.Id)); + var first = _dbContext.Set().FirstOrDefault(e => e.Id.Equals(createdSuperUser.Id)); Assert.NotNull(first); } @@ -94,7 +94,7 @@ public async Task CreateWithRelationship_HasMany_IsCreated() var todoItem = _todoItemFaker.Generate(); _dbContext.TodoItems.Add(todoItem); _dbContext.SaveChanges(); - var todoCollection = new TodoItemCollection { TodoItems = new List { todoItem } }; + var todoCollection = new TodoItemCollection { TodoItems = new HashSet { todoItem } }; // Act var (body, response) = await Post("/api/v1/todoCollections", serializer.Serialize(todoCollection)); @@ -121,7 +121,7 @@ public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncludes( _dbContext.People.Add(owner); _dbContext.TodoItems.Add(todoItem); _dbContext.SaveChanges(); - var todoCollection = new TodoItemCollection { Owner = owner, TodoItems = new List { todoItem } }; + var todoCollection = new TodoItemCollection { Owner = owner, TodoItems = new HashSet { todoItem } }; // Act var (body, response) = await Post("/api/v1/todoCollections?include=todoItems", serializer.Serialize(todoCollection)); @@ -134,6 +134,32 @@ public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncludes( Assert.Equal(todoItem.Description, responseItem.TodoItems.Single().Description); } + [Fact] + public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncludes() + { + // Arrange + var serializer = GetSerializer(e => new { e.Name }, e => new { e.TodoItems, e.Owner }); + var owner = new Person(); + var todoItem = new TodoItem { Owner = owner, Ordinal = 123, Description = "Description" }; + _dbContext.People.Add(owner); + _dbContext.TodoItems.Add(todoItem); + _dbContext.SaveChanges(); + var todoCollection = new TodoItemCollection {Owner = owner, Name = "Jack", TodoItems = new HashSet {todoItem}}; + + // Act + var (body, response) = await Post("/api/v1/todoCollections?include=todoItems&fields=name&fields[todoItems]=ordinal", serializer.Serialize(todoCollection)); + + // Assert + AssertEqualStatusCode(HttpStatusCode.Created, response); + var responseItem = _deserializer.DeserializeSingle(body).Data; + Assert.NotNull(responseItem); + Assert.Equal(todoCollection.Name, responseItem.Name); + + Assert.NotEmpty(responseItem.TodoItems); + Assert.Equal(todoItem.Ordinal, responseItem.TodoItems.Single().Ordinal); + Assert.Null(responseItem.TodoItems.Single().Description); + } + [Fact] public async Task CreateWithRelationship_HasOne_IsCreated() { @@ -179,6 +205,37 @@ public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncludes() Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); } + [Fact] + public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncludes() + { + // Arrange + var serializer = GetSerializer(attributes: ti => new { ti.Ordinal }, relationships: ti => new { ti.Owner }); + var todoItem = new TodoItem + { + Ordinal = 123, + Description = "some" + }; + var owner = new Person { FirstName = "Alice", LastName = "Cooper" }; + _dbContext.People.Add(owner); + _dbContext.SaveChanges(); + todoItem.Owner = owner; + + // Act + var (body, response) = await Post("/api/v1/todoItems?include=owner&fields=ordinal&fields[owner]=firstName", serializer.Serialize(todoItem)); + + // Assert + AssertEqualStatusCode(HttpStatusCode.Created, response); + var responseItem = _deserializer.DeserializeSingle(body).Data; + + Assert.NotNull(responseItem); + Assert.Equal(todoItem.Ordinal, responseItem.Ordinal); + Assert.Null(responseItem.Description); + + Assert.NotNull(responseItem.Owner); + Assert.Equal(owner.FirstName, responseItem.Owner.FirstName); + Assert.Null(responseItem.Owner.LastName); + } + [Fact] public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() { @@ -274,7 +331,7 @@ public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() { // Arrange var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.Passport }); - var passport = new Passport(); + var passport = new Passport(_dbContext); var currentPerson = _personFaker.Generate(); currentPerson.Passport = passport; _dbContext.People.Add(currentPerson); @@ -299,16 +356,16 @@ public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() // Arrange var serializer = GetSerializer(e => new { e.FirstName }, e => new { e.TodoItems }); var currentPerson = _personFaker.Generate(); - var todoItems = _todoItemFaker.Generate(3).ToList(); - currentPerson.TodoItems = todoItems; + var todoItems = _todoItemFaker.Generate(3); + currentPerson.TodoItems = todoItems.ToHashSet(); _dbContext.Add(currentPerson); _dbContext.SaveChanges(); - var firstTd = currentPerson.TodoItems[0]; - var secondTd = currentPerson.TodoItems[1]; - var thirdTd = currentPerson.TodoItems[2]; + var firstTd = todoItems[0]; + var secondTd = todoItems[1]; + var thirdTd = todoItems[2]; var newPerson = _personFaker.Generate(); - newPerson.TodoItems = new List { firstTd, secondTd }; + newPerson.TodoItems = new HashSet { firstTd, secondTd }; // Act var (body, response) = await Post("/api/v1/people", serializer.Serialize(newPerson)); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 79ab714435..2a52ebe6d4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Client; using JsonApiDotNetCoreExample; @@ -11,6 +12,7 @@ using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Extensions; using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -20,9 +22,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class DeeplyNestedInclusionTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; - public DeeplyNestedInclusionTests(TestFixture fixture) + public DeeplyNestedInclusionTests(TestFixture fixture) { _fixture = fixture; } @@ -42,8 +44,12 @@ public async Task Can_Include_Nested_Relationships() const string route = "/api/v1/todoItems?include=collection.owner"; var options = _fixture.GetService(); - var resourceGraph = new ResourceGraphBuilder(options).AddResource("todoItems").AddResource().AddResource().Build(); - var deserializer = new ResponseDeserializer(resourceGraph); + var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .AddResource("todoItems") + .AddResource() + .AddResource() + .Build(); + var deserializer = new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(_fixture.ServiceProvider)); var todoItem = new TodoItem { Collection = new TodoItemCollection @@ -84,7 +90,7 @@ public async Task Can_Include_Nested_HasMany_Relationships() Collection = new TodoItemCollection { Owner = new Person(), - TodoItems = new List { + TodoItems = new HashSet { new TodoItem(), new TodoItem() } @@ -125,7 +131,7 @@ public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() Collection = new TodoItemCollection { Owner = new Person(), - TodoItems = new List { + TodoItems = new HashSet { new TodoItem { Owner = new Person() }, @@ -171,7 +177,7 @@ public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() { Role = new PersonRole() }, - TodoItems = new List { + TodoItems = new HashSet { new TodoItem { Owner = new Person() }, @@ -286,15 +292,15 @@ public async Task Can_Include_Doubly_HasMany_Relationships() { // Arrange var person = new Person { - todoCollections = new List { + todoCollections = new HashSet { new TodoItemCollection { - TodoItems = new List { + TodoItems = new HashSet { new TodoItem(), new TodoItem() } }, new TodoItemCollection { - TodoItems = new List { + TodoItems = new HashSet { new TodoItem(), new TodoItem(), new TodoItem() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index f78c7d4f59..34975c28e9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -16,7 +16,7 @@ public sealed class DeletingDataTests { private readonly AppDbContext _context; - public DeletingDataTests(TestFixture fixture) + public DeletingDataTests(TestFixture fixture) { _context = fixture.GetService(); } @@ -29,7 +29,7 @@ public async Task Respond_404_If_EntityDoesNotExist() await _context.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs index 5a36018678..c76e118179 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -11,9 +11,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class DisableQueryAttributeTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; - public DisableQueryAttributeTests(TestFixture fixture) + public DisableQueryAttributeTests(TestFixture fixture) { _fixture = fixture; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index 234728004e..64ba7b4fe6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -25,7 +25,7 @@ public sealed class Included private readonly Faker _todoItemFaker; private readonly Faker _todoItemCollectionFaker; - public Included(TestFixture fixture) + public Included(TestFixture fixture) { _context = fixture.GetService(); _personFaker = new Faker() @@ -52,7 +52,7 @@ public async Task GET_Included_Contains_SideLoadedData_ForManyToOne() _context.TodoItems.Add(todoItem); _context.SaveChanges(); - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems?include=owner"; @@ -90,7 +90,7 @@ public async Task GET_ById_Included_Contains_SideLoadedData_ForManyToOne() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -130,7 +130,7 @@ public async Task GET_Included_Contains_SideLoadedData_OneToMany() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people?include=todoItems"; @@ -169,7 +169,7 @@ public async Task GET_Included_DoesNot_Duplicate_Records_ForMultipleRelationship _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&include=assignee"; @@ -208,7 +208,7 @@ public async Task GET_Included_DoesNot_Duplicate_Records_If_HasOne_Exists_Twice( _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems?include=owner"; @@ -246,7 +246,7 @@ public async Task GET_ById_Included_Contains_SideloadeData_ForOneToMany() } var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -290,7 +290,7 @@ public async Task Can_Include_MultipleRelationships() } var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -322,7 +322,7 @@ public async Task Request_ToIncludeUnknownRelationship_Returns_400() var person = _context.People.First(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -353,7 +353,7 @@ public async Task Request_ToIncludeDeeplyNestedRelationships_Returns_400() var person = _context.People.First(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -384,7 +384,7 @@ public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400 var person = _context.People.First(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); @@ -412,20 +412,22 @@ public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400 public async Task Can_Ignore_Null_Parent_In_Nested_Include() { // Arrange + _context.TodoItems.RemoveRange(_context.TodoItems); + var todoItem = _todoItemFaker.Generate(); todoItem.Owner = _personFaker.Generate(); - todoItem.CreatedDate = DateTime.Now; + todoItem.CreatedDate = new DateTime(2002, 2,2); _context.TodoItems.Add(todoItem); _context.SaveChanges(); var todoItemWithNullOwner = _todoItemFaker.Generate(); todoItemWithNullOwner.Owner = null; - todoItemWithNullOwner.CreatedDate = DateTime.Now; + todoItemWithNullOwner.CreatedDate = new DateTime(2002, 2,2); _context.TodoItems.Add(todoItemWithNullOwner); _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs index 71b3559965..cb122b274f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -17,9 +18,9 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests [Collection("WebHostCollection")] public sealed class Meta { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly AppDbContext _context; - public Meta(TestFixture fixture) + public Meta(TestFixture fixture) { _fixture = fixture; _context = fixture.GetService(); @@ -109,7 +110,7 @@ public async Task Total_Record_Count_Not_Included_In_POST_Response() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -152,7 +153,7 @@ public async Task Total_Record_Count_Not_Included_In_PATCH_Response() }; request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 782a42ebbd..c12f29c77f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -21,7 +21,7 @@ public sealed class Relationships private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public Relationships(TestFixture fixture) + public Relationships(TestFixture fixture) { _context = fixture.GetService(); _todoItemFaker = new Faker() @@ -47,7 +47,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -76,7 +76,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -108,7 +108,7 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -137,7 +137,7 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/people/{person.Id}"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs index 2b795d6c54..7b4aae679c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs @@ -3,8 +3,11 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -20,6 +23,8 @@ public class EagerLoadTests : FunctionalTestCollection(); + _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) @@ -28,6 +33,7 @@ public EagerLoadTests(StandardApplicationFactory factory) : base(factory) .RuleFor(t => t.FirstName, f => f.Name.FirstName()) .RuleFor(t => t.LastName, f => f.Name.LastName()); _passportFaker = new Faker() + .CustomInstantiator(f => new Passport(appDbContext)) .RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number(100, 10_000)); _countryFaker = new Faker() .RuleFor(c => c.Name, f => f.Address.Country()); @@ -54,15 +60,16 @@ public async Task GetSingleResource_TopLevel_AppliesEagerLoad() _dbContext.SaveChanges(); // Act - var (body, response) = await Get($"/api/v1/passports/{passport.Id}"); + var (body, response) = await Get($"/api/v1/passports/{passport.StringId}"); // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); - - var resultPassport = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(passport.Id, resultPassport.Id); - Assert.Equal(passport.BirthCountry.Name, resultPassport.BirthCountryName); - Assert.Equal(visa1.TargetCountry.Name + ", " + visa2.TargetCountry.Name, resultPassport.GrantedVisaCountries); + + var document = JsonConvert.DeserializeObject(body); + Assert.NotNull(document.SingleData); + Assert.Equal(passport.StringId, document.SingleData.Id); + Assert.Equal(passport.BirthCountry.Name, document.SingleData.Attributes["birthCountryName"]); + Assert.Equal(visa1.TargetCountry.Name + ", " + visa2.TargetCountry.Name, document.SingleData.Attributes["grantedVisaCountries"]); } [Fact] @@ -73,6 +80,10 @@ public async Task GetMultiResource_Nested_AppliesEagerLoad() person.Passport = _passportFaker.Generate(); person.Passport.BirthCountry = _countryFaker.Generate(); + var visa = _visaFaker.Generate(); + visa.TargetCountry = _countryFaker.Generate(); + person.Passport.GrantedVisas = new List {visa}; + _dbContext.People.RemoveRange(_dbContext.People); _dbContext.Add(person); _dbContext.SaveChanges(); @@ -83,12 +94,16 @@ public async Task GetMultiResource_Nested_AppliesEagerLoad() // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); - var resultPerson = _deserializer.DeserializeList(body).Data.Single(); - Assert.Equal(person.Id, resultPerson.Id); - Assert.Equal(person.Passport.Id, resultPerson.Passport.Id); - Assert.Equal(person.Passport.BirthCountryName, resultPerson.Passport.BirthCountry.Name); - } - + var document = JsonConvert.DeserializeObject(body); + Assert.NotEmpty(document.ManyData); + Assert.Equal(person.StringId, document.ManyData[0].Id); + + Assert.NotEmpty(document.Included); + Assert.Equal(person.Passport.StringId, document.Included[0].Id); + Assert.Equal(person.Passport.BirthCountry.Name, document.Included[0].Attributes["birthCountryName"]); + Assert.Equal(person.Passport.GrantedVisaCountries, document.Included[0].Attributes["grantedVisaCountries"]); + } + [Fact] public async Task GetMultiResource_DeeplyNested_AppliesEagerLoad() { @@ -99,6 +114,10 @@ public async Task GetMultiResource_DeeplyNested_AppliesEagerLoad() todo.Owner.Passport = _passportFaker.Generate(); todo.Owner.Passport.BirthCountry = _countryFaker.Generate(); + var visa = _visaFaker.Generate(); + visa.TargetCountry = _countryFaker.Generate(); + todo.Owner.Passport.GrantedVisas = new List {visa}; + _dbContext.Add(todo); _dbContext.SaveChanges(); @@ -108,8 +127,15 @@ public async Task GetMultiResource_DeeplyNested_AppliesEagerLoad() // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); - var resultTodoItem = _deserializer.DeserializeList(body).Data.Single(); - Assert.Equal(todo.Owner.Passport.BirthCountryName, resultTodoItem.Owner.Passport.BirthCountry.Name); + var document = JsonConvert.DeserializeObject(body); + Assert.NotEmpty(document.ManyData); + Assert.Equal(todo.StringId, document.ManyData[0].Id); + + Assert.Equal(2, document.Included.Count); + Assert.Equal(todo.Owner.StringId, document.Included[0].Id); + Assert.Equal(todo.Owner.Passport.StringId, document.Included[1].Id); + Assert.Equal(todo.Owner.Passport.BirthCountry.Name, document.Included[1].Attributes["birthCountryName"]); + Assert.Equal(todo.Owner.Passport.GrantedVisaCountries, document.Included[1].Attributes["grantedVisaCountries"]); } [Fact] @@ -127,11 +153,12 @@ public async Task PostSingleResource_TopLevel_AppliesEagerLoad() // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); - - var resultPassport = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(passport.SocialSecurityNumber, resultPassport.SocialSecurityNumber); - Assert.Equal(passport.BirthCountry.Name, resultPassport.BirthCountryName); - Assert.Null(resultPassport.GrantedVisaCountries); + + var document = JsonConvert.DeserializeObject(body); + Assert.NotNull(document.SingleData); + Assert.Equal((long?)passport.SocialSecurityNumber, document.SingleData.Attributes["socialSecurityNumber"]); + Assert.Equal(passport.BirthCountry.Name, document.SingleData.Attributes["birthCountryName"]); + Assert.Null(document.SingleData.Attributes["grantedVisaCountries"]); } [Fact] @@ -140,6 +167,7 @@ public async Task PatchResource_TopLevel_AppliesEagerLoad() // Arrange var passport = _passportFaker.Generate(); passport.BirthCountry = _countryFaker.Generate(); + var visa = _visaFaker.Generate(); visa.TargetCountry = _countryFaker.Generate(); passport.GrantedVisas = new List { visa }; @@ -154,16 +182,17 @@ public async Task PatchResource_TopLevel_AppliesEagerLoad() var content = serializer.Serialize(passport); // Act - var (body, response) = await Patch($"/api/v1/passports/{passport.Id}", content); + var (body, response) = await Patch($"/api/v1/passports/{passport.StringId}", content); // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); - - var resultPassport = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(passport.Id, resultPassport.Id); - Assert.Equal(passport.SocialSecurityNumber, resultPassport.SocialSecurityNumber); - Assert.Equal(passport.BirthCountry.Name, resultPassport.BirthCountryName); - Assert.Equal(passport.GrantedVisas.First().TargetCountry.Name, resultPassport.GrantedVisaCountries); + + var document = JsonConvert.DeserializeObject(body); + Assert.NotNull(document.SingleData); + Assert.Equal(passport.StringId, document.SingleData.Id); + Assert.Equal((long?)passport.SocialSecurityNumber, document.SingleData.Attributes["socialSecurityNumber"]); + Assert.Equal(passport.BirthCountry.Name, document.SingleData.Attributes["birthCountryName"]); + Assert.Equal(passport.GrantedVisas.First().TargetCountry.Name, document.SingleData.Attributes["grantedVisaCountries"]); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs index 70c59edb55..ad99a3da18 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs @@ -4,9 +4,11 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Client; @@ -16,13 +18,14 @@ using JsonApiDotNetCoreExampleTests.Helpers.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { public class FunctionalTestCollection : IClassFixture where TFactory : class, IApplicationFactory { - public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); protected readonly TFactory _factory; protected readonly HttpClient _client; protected readonly AppDbContext _dbContext; @@ -61,14 +64,8 @@ protected IRequestSerializer GetSerializer(Expression(); var graph = GetService(); - if (attributes != null) - { - serializer.AttributesToSerialize = graph.GetAttributes(attributes); - } - if (relationships != null) - { - serializer.RelationshipsToSerialize = graph.GetRelationships(relationships); - } + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; return serializer; } @@ -77,10 +74,10 @@ protected IResponseDeserializer GetDeserializer() var options = GetService(); var formatter = new ResourceNameFormatter(options); var resourcesContexts = GetService().GetResourceContexts(); - var builder = new ResourceGraphBuilder(options); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); foreach (var rc in resourcesContexts) { - if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection) || rc.ResourceType == typeof(Passport)) + if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection)) { continue; } @@ -88,8 +85,7 @@ protected IResponseDeserializer GetDeserializer() } builder.AddResource(formatter.FormatResourceName(typeof(TodoItem))); builder.AddResource(formatter.FormatResourceName(typeof(TodoItemCollection))); - builder.AddResource(formatter.FormatResourceName(typeof(Passport))); - return new ResponseDeserializer(builder.Build()); + return new ResponseDeserializer(builder.Build(), new DefaultResourceFactory(_factory.ServiceProvider)); } protected AppDbContext GetDbContext() => GetService(); @@ -132,11 +128,11 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { public class EndToEndTest { - public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); private HttpClient _client; - protected TestFixture _fixture; + protected TestFixture _fixture; protected readonly IResponseDeserializer _deserializer; - public EndToEndTest(TestFixture fixture) + public EndToEndTest(TestFixture fixture) { _fixture = fixture; _deserializer = GetDeserializer(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 13ef6a4fe9..cc5a0bb14a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -1,8 +1,8 @@ -using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; @@ -19,11 +19,11 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class FetchingDataTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public FetchingDataTests(TestFixture fixture) + public FetchingDataTests(TestFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() @@ -44,7 +44,7 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() await context.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; var server = new TestServer(builder); @@ -60,7 +60,7 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); + Assert.Equal(HeaderConstants.MediaType, response.Content.Headers.ContentType.ToString()); Assert.Empty(items); Assert.Equal(0, int.Parse(meta["total-records"].ToString())); context.Dispose(); @@ -78,7 +78,7 @@ public async Task Included_Records_Contain_Relationship_Links() await context.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner"; var server = new TestServer(builder); @@ -107,7 +107,7 @@ public async Task GetResources_NoDefaultPageSize_ReturnsResources() context.TodoItems.RemoveRange(context.TodoItems); await context.SaveChangesAsync(); - var todoItems = _todoItemFaker.Generate(20).ToList(); + var todoItems = _todoItemFaker.Generate(20); context.TodoItems.AddRange(todoItems); await context.SaveChangesAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 55552a69fa..f3b4ccc0f4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -18,10 +18,10 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class FetchingRelationshipsTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly Faker _todoItemFaker; - public FetchingRelationshipsTests(TestFixture fixture) + public FetchingRelationshipsTests(TestFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() @@ -43,7 +43,7 @@ public async Task When_getting_related_missing_to_one_resource_it_should_succeed var route = $"/api/v1/todoItems/{todoItem.Id}/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -75,7 +75,7 @@ public async Task When_getting_relationship_for_missing_to_one_resource_it_shoul var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -105,7 +105,7 @@ public async Task When_getting_related_missing_to_many_resource_it_should_succee var route = $"/api/v1/todoItems/{todoItem.Id}/childrenTodos"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -135,7 +135,7 @@ public async Task When_getting_relationship_for_missing_to_many_resource_it_shou var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -158,7 +158,7 @@ public async Task When_getting_related_for_missing_parent_resource_it_should_fai // Arrange var route = "/api/v1/todoItems/99999999/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -182,7 +182,7 @@ public async Task When_getting_relationship_for_missing_parent_resource_it_shoul // Arrange var route = "/api/v1/todoItems/99999999/relationships/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -212,7 +212,7 @@ public async Task When_getting_unknown_related_resource_it_should_fail() var route = $"/api/v1/todoItems/{todoItem.Id}/invalid"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -242,7 +242,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs deleted file mode 100644 index f920b43336..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public sealed class NestedResourceTests : FunctionalTestCollection - { - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - private readonly Faker _passportFaker; - private readonly Faker _countryFaker; - - public NestedResourceTests(StandardApplicationFactory factory) : base(factory) - { - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); - _passportFaker = new Faker() - .RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number(100, 10_000)); - _countryFaker = new Faker() - .RuleFor(c => c.Name, f => f.Address.Country()); - } - - [Fact] - public async Task NestedResourceRoute_RequestWithIncludeQueryParam_ReturnsRequestedRelationships() - { - // Arrange - var todo = _todoItemFaker.Generate(); - todo.Assignee = _personFaker.Generate(); - todo.Owner = _personFaker.Generate(); - todo.Owner.Passport = _passportFaker.Generate(); - todo.Owner.Passport.BirthCountry = _countryFaker.Generate(); - - _dbContext.Add(todo); - _dbContext.SaveChanges(); - - // Act - var (body, response) = await Get($"/api/v1/people/{todo.Assignee.Id}/assignedTodoItems?include=owner.passport"); - - // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - var resultTodoItem = _deserializer.DeserializeList(body).Data.Single(); - Assert.Equal(todo.Id, resultTodoItem.Id); - Assert.Equal(todo.Owner.Id, resultTodoItem.Owner.Id); - Assert.Equal(todo.Owner.Passport.Id, resultTodoItem.Owner.Passport.Id); - } - - [Theory] - [InlineData("filter[ordinal]=1")] - [InlineData("fields=ordinal")] - [InlineData("sort=ordinal")] - [InlineData("page[number]=1")] - [InlineData("page[size]=10")] - public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParameter) - { - string parameterName = queryParameter.Split('=')[0]; - - // Act - var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParameter}"); - - // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified query string parameter is currently not supported on nested resource endpoints.", errorDocument.Errors[0].Title); - Assert.Equal($"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')", errorDocument.Errors[0].Detail); - Assert.Equal(parameterName, errorDocument.Errors[0].Source.Parameter); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs index 357fe9b472..55fb4cd602 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs @@ -14,12 +14,12 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { [Collection("WebHostCollection")] - public sealed class PagingTests : TestFixture + public sealed class PagingTests : TestFixture { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly Faker _todoItemFaker; - public PagingTests(TestFixture fixture) + public PagingTests(TestFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() @@ -37,7 +37,7 @@ public async Task Pagination_WithPageSizeAndPageNumber_ReturnsCorrectSubsetOfRes const int expectedEntitiesPerPage = 2; var totalCount = expectedEntitiesPerPage * 2; var person = new Person(); - var todoItems = _todoItemFaker.Generate(totalCount).ToList(); + var todoItems = _todoItemFaker.Generate(totalCount); foreach (var todoItem in todoItems) { todoItem.Owner = person; @@ -82,7 +82,7 @@ public async Task Pagination_OnGivenPage_DisplaysCorrectTopLevelLinks(int pageNu { LastName = "&Ampersand" }; - var todoItems = _todoItemFaker.Generate(totalCount).ToList(); + var todoItems = _todoItemFaker.Generate(totalCount); foreach (var todoItem in todoItems) todoItem.Owner = person; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs index 7949b61bf8..df7c7abe75 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs @@ -19,7 +19,7 @@ public async Task Server_Returns_400_ForUnknownQueryParam() // Arrange const string queryString = "?someKey=someValue"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/todoItems" + queryString); @@ -45,7 +45,7 @@ public async Task Server_Returns_400_ForMissingQueryParameterValue() // Arrange const string queryString = "?include="; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems" + queryString; var server = new TestServer(builder); @@ -73,7 +73,7 @@ public async Task Server_Returns_400_ForUnknownQueryParameter_Attribute() // Arrange const string queryString = "?sort=notSoGood"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems" + queryString; var server = new TestServer(builder); @@ -101,7 +101,7 @@ public async Task Server_Returns_400_ForUnknownQueryParameter_RelatedAttribute() // Arrange const string queryString = "?sort=notSoGood.evenWorse"; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems" + queryString; var server = new TestServer(builder); @@ -123,5 +123,36 @@ public async Task Server_Returns_400_ForUnknownQueryParameter_RelatedAttribute() Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); } + [Theory] + [InlineData("filter[ordinal]=1")] + [InlineData("fields=ordinal")] + [InlineData("sort=ordinal")] + [InlineData("page[number]=1")] + [InlineData("page[size]=10")] + public async Task Server_Returns_400_ForQueryParamOnNestedResource(string queryParameter) + { + string parameterName = queryParameter.Split('=')[0]; + + var builder = new WebHostBuilder().UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/people/1/assignedTodoItems?{queryParameter}"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified query string parameter is currently not supported on nested resource endpoints.", errorDocument.Errors[0].Title); + Assert.Equal($"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')", errorDocument.Errors[0].Detail); + Assert.Equal(parameterName, errorDocument.Errors[0].Source.Parameter); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 2fe4565bd1..1b575be29a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.Design; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -15,10 +16,7 @@ using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; using System.Net; -using JsonApiDotNetCore.Serialization.Client; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.Helpers.Models; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models.JsonApiDocuments; @@ -27,13 +25,13 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class SparseFieldSetTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly AppDbContext _dbContext; private readonly IResourceGraph _resourceGraph; private readonly Faker _personFaker; private readonly Faker _todoItemFaker; - public SparseFieldSetTests(TestFixture fixture) + public SparseFieldSetTests(TestFixture fixture) { _fixture = fixture; _dbContext = fixture.GetService(); @@ -57,17 +55,23 @@ public async Task Can_Select_Sparse_Fieldsets() { Description = "description", Ordinal = 1, - CreatedDate = DateTime.Now, - AchievedDate = DateTime.Now.AddDays(2) + CreatedDate = new DateTime(2002, 2,2), + AchievedDate = new DateTime(2002, 2,4) }; _dbContext.TodoItems.Add(todoItem); await _dbContext.SaveChangesAsync(); + var properties = _resourceGraph + .GetAttributes(e => new {e.Id, e.Description, e.CreatedDate, e.AchievedDate}) + .Select(x => x.PropertyInfo.Name); + + var resourceFactory = new DefaultResourceFactory(new ServiceContainer()); + // Act var query = _dbContext .TodoItems .Where(t => t.Id == todoItem.Id) - .Select(_resourceGraph.GetAttributes(e => new { e.Id, e.Description, e.CreatedDate, e.AchievedDate })); + .Select(properties, resourceFactory); var result = await query.FirstAsync(); @@ -86,13 +90,13 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets() { Description = "description", Ordinal = 1, - CreatedDate = DateTime.Now + CreatedDate = new DateTime(2002, 2,2) }; _dbContext.TodoItems.Add(todoItem); await _dbContext.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); using var server = new TestServer(builder); var client = server.CreateClient(); @@ -110,6 +114,7 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets() Assert.Equal(2, deserializeBody.SingleData.Attributes.Count); Assert.Equal(todoItem.Description, deserializeBody.SingleData.Attributes["description"]); Assert.Equal(todoItem.CreatedDate.ToString("G"), ((DateTime)deserializeBody.SingleData.Attributes["createdDate"]).ToString("G")); + Assert.DoesNotContain("guidProperty", deserializeBody.SingleData.Attributes.Keys); } [Fact] @@ -120,13 +125,13 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation { Description = "description", Ordinal = 1, - CreatedDate = DateTime.Now + CreatedDate = new DateTime(2002, 2,2) }; _dbContext.TodoItems.Add(todoItem); await _dbContext.SaveChangesAsync(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); using var server = new TestServer(builder); var client = server.CreateClient(); @@ -148,52 +153,52 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation } [Fact] - public async Task Fields_Query_Selects_All_Fieldset_With_HasOne() + public async Task Fields_Query_Selects_All_Fieldset_From_HasOne() { // Arrange _dbContext.TodoItems.RemoveRange(_dbContext.TodoItems); _dbContext.SaveChanges(); + var owner = _personFaker.Generate(); var todoItem = new TodoItem { Description = "s", Ordinal = 123, - CreatedDate = DateTime.Now, + CreatedDate = new DateTime(2002, 2,2), Owner = owner }; _dbContext.TodoItems.Add(todoItem); _dbContext.SaveChanges(); - var builder = new WebHostBuilder() - .UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); using var server = new TestServer(builder); var client = server.CreateClient(); var route = "/api/v1/todoItems?include=owner&fields[owner]=firstName,the-Age"; var request = new HttpRequestMessage(httpMethod, route); - var options = _fixture.GetService(); - var resourceGraph = new ResourceGraphBuilder(options).AddResource().AddResource("todoItems").Build(); - var deserializer = new ResponseDeserializer(resourceGraph); + // Act var response = await client.SendAsync(request); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var deserializeBody = JsonConvert.DeserializeObject(body); - var deserializedTodoItems = deserializer.DeserializeList(body).Data; + Assert.Equal(todoItem.Description, deserializeBody.ManyData[0].Attributes["description"]); + Assert.Equal(todoItem.Ordinal, deserializeBody.ManyData[0].Attributes["ordinal"]); - foreach (var item in deserializedTodoItems.Where(i => i.Owner != null)) - { - Assert.Null(item.Owner.LastName); - Assert.NotNull(item.Owner.FirstName); - Assert.NotEqual(0, item.Owner.Age); - } + Assert.NotNull(deserializeBody.Included); + Assert.NotEmpty(deserializeBody.Included); + Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); + Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); + Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); + Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); } [Fact] - public async Task Fields_Query_Selects_Fieldset_With_HasOne() + public async Task Fields_Query_Selects_Fieldset_From_HasOne() { // Arrange var owner = _personFaker.Generate(); @@ -201,14 +206,14 @@ public async Task Fields_Query_Selects_Fieldset_With_HasOne() { Description = "description", Ordinal = 1, - CreatedDate = DateTime.Now, + CreatedDate = new DateTime(2002, 2,2), Owner = owner }; _dbContext.TodoItems.Add(todoItem); _dbContext.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); using var server = new TestServer(builder); var client = server.CreateClient(); @@ -219,33 +224,117 @@ public async Task Fields_Query_Selects_Fieldset_With_HasOne() // Act var response = await client.SendAsync(request); - // Assert - check status code + // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); var deserializeBody = JsonConvert.DeserializeObject(body); - // Assert - check owner attributes - var included = deserializeBody.Included.First(); - Assert.Equal(owner.StringId, included.Id); - Assert.Equal(owner.FirstName, included.Attributes["firstName"]); - Assert.Equal((long)owner.Age, included.Attributes["the-Age"]); - Assert.DoesNotContain("lastName", included.Attributes.Keys); + Assert.Equal(todoItem.Description, deserializeBody.SingleData.Attributes["description"]); + Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); + + Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); + Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); + Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); + Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); } [Fact] - public async Task Fields_Query_Selects_Fieldset_With_HasMany() + public async Task Fields_Query_Selects_Fieldset_From_Self_And_HasOne() { // Arrange var owner = _personFaker.Generate(); - var todoItems = _todoItemFaker.Generate(2); + var todoItem = new TodoItem + { + Description = "description", + Ordinal = 1, + CreatedDate = new DateTime(2002, 2,2), + Owner = owner + }; + _dbContext.TodoItems.Add(todoItem); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&fields=ordinal&fields[owner]=firstName,the-Age"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); + Assert.DoesNotContain("description", deserializeBody.SingleData.Attributes.Keys); + + Assert.NotNull(deserializeBody.Included); + Assert.NotEmpty(deserializeBody.Included); + Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); + Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); + Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); + Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); + } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_From_Self_With_HasOne_Include() + { + // Arrange + var owner = _personFaker.Generate(); + var todoItem = new TodoItem + { + Description = "description", + Ordinal = 1, + CreatedDate = new DateTime(2002, 2,2), + Owner = owner + }; + _dbContext.TodoItems.Add(todoItem); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&fields=ordinal"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); - owner.TodoItems = todoItems; + Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); + Assert.DoesNotContain("description", deserializeBody.SingleData.Attributes.Keys); + + Assert.NotNull(deserializeBody.Included); + Assert.NotEmpty(deserializeBody.Included); + Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); + Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); + Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); + } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_From_HasMany() + { + // Arrange + var owner = _personFaker.Generate(); + owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); _dbContext.People.Add(owner); _dbContext.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var httpMethod = new HttpMethod("GET"); using var server = new TestServer(builder); var client = server.CreateClient(); @@ -261,15 +350,99 @@ public async Task Fields_Query_Selects_Fieldset_With_HasMany() var body = await response.Content.ReadAsStringAsync(); var deserializeBody = JsonConvert.DeserializeObject(body); + Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); + Assert.Equal(owner.LastName, deserializeBody.SingleData.Attributes["lastName"]); + + foreach (var include in deserializeBody.Included) + { + var todoItem = owner.TodoItems.Single(i => i.StringId == include.Id); + + Assert.Equal(todoItem.Description, include.Attributes["description"]); + Assert.DoesNotContain("ordinal", include.Attributes.Keys); + Assert.DoesNotContain("createdDate", include.Attributes.Keys); + } + } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_From_Self_And_HasMany() + { + // Arrange + var owner = _personFaker.Generate(); + owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); + + _dbContext.People.Add(owner); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/people/{owner.Id}?include=todoItems&fields=firstName&fields[todoItems]=description"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); + Assert.DoesNotContain("lastName", deserializeBody.SingleData.Attributes.Keys); + // check owner attributes + Assert.NotNull(deserializeBody.Included); + Assert.Equal(2, deserializeBody.Included.Count); foreach (var includedItem in deserializeBody.Included) { - var todoItem = todoItems.FirstOrDefault(i => i.StringId == includedItem.Id); + var todoItem = owner.TodoItems.FirstOrDefault(i => i.StringId == includedItem.Id); Assert.NotNull(todoItem); Assert.Equal(todoItem.Description, includedItem.Attributes["description"]); Assert.DoesNotContain("ordinal", includedItem.Attributes.Keys); Assert.DoesNotContain("createdDate", includedItem.Attributes.Keys); } } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_From_Self_With_HasMany_Include() + { + // Arrange + var owner = _personFaker.Generate(); + owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); + + _dbContext.People.Add(owner); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/people/{owner.Id}?include=todoItems&fields=firstName"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); + Assert.DoesNotContain("lastName", deserializeBody.SingleData.Attributes.Keys); + + Assert.NotNull(deserializeBody.Included); + Assert.Equal(2, deserializeBody.Included.Count); + foreach (var includedItem in deserializeBody.Included) + { + var todoItem = owner.TodoItems.Single(i => i.StringId == includedItem.Id); + Assert.Equal(todoItem.Description, includedItem.Attributes["description"]); + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 6cb572ae29..2d6b7a0002 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -5,15 +5,18 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Xunit; @@ -28,7 +31,7 @@ public sealed class UpdatingDataTests : EndToEndTest private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public UpdatingDataTests(TestFixture fixture) : base(fixture) + public UpdatingDataTests(TestFixture fixture) : base(fixture) { _context = fixture.GetService(); @@ -45,12 +48,18 @@ public UpdatingDataTests(TestFixture fixture) : base(fixture) public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() { // Arrange - var dbContext = PrepareTest(); - var serializer = GetSerializer(e => new { e.SecurityLevel, e.Username, e.Password }); - var superUser = new SuperUser { SecurityLevel = 1337, Username = "Super", Password = "User", LastPasswordChange = DateTime.Now.AddMinutes(-15) }; - dbContext.SuperUsers.Add(superUser); + var dbContext = PrepareTest(); + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + + var clock = server.Host.Services.GetRequiredService(); + + var serializer = TestFixture.GetSerializer(server.Host.Services, e => new { e.SecurityLevel, e.Username, e.Password }); + var superUser = new SuperUser(_context) { SecurityLevel = 1337, Username = "Super", Password = "User", LastPasswordChange = clock.UtcNow.LocalDateTime.AddMinutes(-15) }; + dbContext.Set().Add(superUser); dbContext.SaveChanges(); - var su = new SuperUser { Id = superUser.Id, SecurityLevel = 2674, Username = "Power", Password = "secret" }; + var su = new SuperUser(_context) { Id = superUser.Id, SecurityLevel = 2674, Username = "Power", Password = "secret" }; var content = serializer.Serialize(su); // Act @@ -68,7 +77,7 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() public async Task Response422IfUpdatingNotSettableAttribute() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var loggerFactory = new FakeLoggerFactory(); builder.ConfigureLogging(options => @@ -86,7 +95,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() _context.TodoItems.Add(todoItem); _context.SaveChanges(); - var serializer = _fixture.GetSerializer(ti => new { ti.CalculatedValue }); + var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new { ti.CalculatedValue }); var content = serializer.Serialize(todoItem); var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}", content); @@ -103,7 +112,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); - Assert.StartsWith("Property set method not found. - Request body: <<", error.Detail); + Assert.StartsWith("Property 'TodoItem.CalculatedValue' is read-only. - Request body: <<", error.Detail); Assert.NotEmpty(loggerFactory.Logger.Messages); Assert.Contains(loggerFactory.Logger.Messages, @@ -121,14 +130,14 @@ public async Task Respond_404_If_EntityDoesNotExist() var todoItem = _todoItemFaker.Generate(); todoItem.Id = 100; - todoItem.CreatedDate = DateTime.Now; + todoItem.CreatedDate = new DateTime(2002, 2,2); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var serializer = _fixture.GetSerializer(ti => new { ti.Description, ti.Ordinal, ti.CreatedDate }); + var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new { ti.Description, ti.Ordinal, ti.CreatedDate }); var content = serializer.Serialize(todoItem); var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}", content); @@ -152,13 +161,13 @@ public async Task Respond_422_If_IdNotInAttributeList() // Arrange var maxPersonId = _context.TodoItems.ToList().LastOrDefault()?.Id ?? 0; var todoItem = _todoItemFaker.Generate(); - todoItem.CreatedDate = DateTime.Now; + todoItem.CreatedDate = new DateTime(2002, 2,2); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var serializer = _fixture.GetSerializer(ti => new {ti.Description, ti.Ordinal, ti.CreatedDate}); + var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new {ti.Description, ti.Ordinal, ti.CreatedDate}); var content = serializer.Serialize(todoItem); var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{maxPersonId}", content); @@ -177,13 +186,48 @@ public async Task Respond_422_If_IdNotInAttributeList() Assert.Equal("Failed to deserialize request body: Payload must include id attribute.", error.Title); Assert.StartsWith("Request body: <<", error.Detail); } - + + [Fact] + public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.CreatedDate = new DateTime(2002, 2,2); + + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var wrongTodoItemId = todoItem.Id + 1; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new {ti.Description, ti.Ordinal, ti.CreatedDate}); + var content = serializer.Serialize(todoItem); + var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{wrongTodoItemId}", content); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + Assert.Single(document.Errors); + + var error = document.Errors.Single(); + Assert.Equal(HttpStatusCode.Conflict, error.StatusCode); + Assert.Equal("Resource id mismatch between request body and endpoint URL.", error.Title); + Assert.Equal($"Expected resource id '{wrongTodoItemId}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{wrongTodoItemId}', instead of '{todoItem.Id}'.", error.Detail); + } + [Fact] public async Task Respond_422_If_Broken_JSON_Payload() { // Arrange var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -224,10 +268,10 @@ public async Task Can_Patch_Entity() var newTodoItem = _todoItemFaker.Generate(); newTodoItem.Id = todoItem.Id; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var serializer = _fixture.GetSerializer(p => new { p.Description, p.Ordinal }); + var serializer = TestFixture.GetSerializer(server.Host.Services, p => new { p.Description, p.Ordinal }); var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}", serializer.Serialize(newTodoItem)); @@ -267,10 +311,10 @@ public async Task Patch_Entity_With_HasMany_Does_Not_Include_Relationships() var newPerson = _personFaker.Generate(); newPerson.Id = person.Id; - var builder = new WebHostBuilder().UseStartup(); + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var serializer = _fixture.GetSerializer(p => new { p.LastName, p.FirstName }); + var serializer = TestFixture.GetSerializer(server.Host.Services, p => new { p.LastName, p.FirstName }); var request = PrepareRequest("PATCH", $"/api/v1/people/{person.Id}", serializer.Serialize(newPerson)); @@ -295,7 +339,7 @@ public async Task Can_Patch_Entity_And_HasOne_Relationships() { // Arrange var todoItem = _todoItemFaker.Generate(); - todoItem.CreatedDate = DateTime.Now; + todoItem.CreatedDate = new DateTime(2002, 2,2); var person = _personFaker.Generate(); _context.TodoItems.Add(todoItem); _context.People.Add(person); @@ -303,7 +347,7 @@ public async Task Can_Patch_Entity_And_HasOne_Relationships() todoItem.Owner = person; var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var serializer = _fixture.GetSerializer(ti => new { ti.Description, ti.Ordinal, ti.CreatedDate }, ti => new { ti.Owner }); @@ -326,7 +370,7 @@ private HttpRequestMessage PrepareRequest(string method, string route, string co var httpMethod = new HttpMethod(method); var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); return request; } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index 945df5b88b..df5db50f3f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -21,12 +22,12 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec [Collection("WebHostCollection")] public sealed class UpdatingRelationshipsTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private AppDbContext _context; private readonly Faker _personFaker; private readonly Faker _todoItemFaker; - public UpdatingRelationshipsTests(TestFixture fixture) + public UpdatingRelationshipsTests(TestFixture fixture) { _fixture = fixture; _context = fixture.GetService(); @@ -38,8 +39,6 @@ public UpdatingRelationshipsTests(TestFixture fixture) .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - } [Fact] @@ -54,7 +53,7 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -88,7 +87,7 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act @@ -112,7 +111,7 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -141,7 +140,7 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act @@ -167,7 +166,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -205,7 +204,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act @@ -223,7 +222,7 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() { // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new List()}; + var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); todoCollection.Owner = person; @@ -237,7 +236,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -270,7 +269,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -296,7 +295,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe // this user may not be reattached to the db context in the repository. // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new List()}; + var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); todoCollection.Owner = person; @@ -311,7 +310,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -348,7 +347,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -368,7 +367,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overlap() { // Arrange - var todoCollection = new TodoItemCollection {TodoItems = new List()}; + var todoCollection = new TodoItemCollection {TodoItems = new HashSet()}; var person = _personFaker.Generate(); var todoItem1 = _todoItemFaker.Generate(); var todoItem2 = _todoItemFaker.Generate(); @@ -379,7 +378,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -413,7 +412,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -442,7 +441,7 @@ public async Task Can_Update_ToMany_Relationship_ThroughLink() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -465,15 +464,18 @@ public async Task Can_Update_ToMany_Relationship_ThroughLink() Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); - _context = _fixture.GetService(); - var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; // Assert + var body = response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + _context = _fixture.GetService(); + var personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; + Assert.NotEmpty(personsTodoItems); } @@ -490,7 +492,7 @@ public async Task Can_Update_ToOne_Relationship_ThroughLink() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -502,7 +504,7 @@ public async Task Can_Update_ToOne_Relationship_ThroughLink() var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -526,7 +528,7 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -553,7 +555,7 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -568,14 +570,13 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() Assert.Null(todoItemResult.Owner); } - [Fact] public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() { // Arrange var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); - person.TodoItems = new List { todoItem }; + person.TodoItems = new HashSet { todoItem }; _context.People.Add(person); _context.SaveChanges(); @@ -602,7 +603,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -630,7 +631,7 @@ public async Task Can_Delete_Relationship_By_Patching_Relationship() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -647,7 +648,7 @@ public async Task Can_Delete_Relationship_By_Patching_Relationship() Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -667,7 +668,7 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() { // Arrange var context = _fixture.GetService(); - var passport = new Passport(); + var passport = new Passport(context); var person1 = _personFaker.Generate(); person1.Passport = passport; var person2 = _personFaker.Generate(); @@ -684,7 +685,7 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() { { "passport", new { - data = new { type = "passports", id = $"{passportId}" } + data = new { type = "passports", id = $"{passport.StringId}" } } } } @@ -697,7 +698,7 @@ public async Task Updating_ToOne_Relationship_With_Implicit_Remove() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -716,13 +717,13 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() // Arrange var context = _fixture.GetService(); var person1 = _personFaker.Generate(); - person1.TodoItems = _todoItemFaker.Generate(3).ToList(); + person1.TodoItems = _todoItemFaker.Generate(3).ToHashSet(); var person2 = _personFaker.Generate(); - person2.TodoItems = _todoItemFaker.Generate(2).ToList(); + person2.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); context.People.AddRange(new List { person1, person2 }); await context.SaveChangesAsync(); - var todoItem1Id = person1.TodoItems[0].Id; - var todoItem2Id = person1.TodoItems[1].Id; + var todoItem1Id = person1.TodoItems.ElementAt(0).Id; + var todoItem2Id = person1.TodoItems.ElementAt(1).Id; var content = new { @@ -757,7 +758,7 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() string serializedContent = JsonConvert.SerializeObject(content); request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -785,7 +786,7 @@ public async Task Fails_On_Unknown_Relationship() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -797,7 +798,7 @@ public async Task Fails_On_Unknown_Relationship() var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); @@ -822,7 +823,7 @@ public async Task Fails_On_Missing_Resource() _context.SaveChanges(); var builder = new WebHostBuilder() - .UseStartup(); + .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -834,7 +835,7 @@ public async Task Fails_On_Missing_Resource() var route = $"/api/v1/todoItems/99999999/relationships/owner"; var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index c13278ae3c..d6dd9fd675 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -1,31 +1,36 @@ using System; +using System.Linq.Expressions; +using System.Net; using System.Net.Http; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Client; using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using JsonApiDotNetCore.Data; using Microsoft.EntityFrameworkCore; -using JsonApiDotNetCore.Serialization.Client; -using System.Linq.Expressions; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCore.Internal.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance { public class TestFixture : IDisposable where TStartup : class { private readonly TestServer _server; - private readonly IServiceProvider _services; + public readonly IServiceProvider ServiceProvider; public TestFixture() { - var builder = new WebHostBuilder() - .UseStartup(); + var builder = new WebHostBuilder().UseStartup(); _server = new TestServer(builder); - _services = _server.Host.Services; + ServiceProvider = _server.Host.Services; Client = _server.CreateClient(); Context = GetService().GetContext() as AppDbContext; @@ -34,15 +39,21 @@ public TestFixture() public HttpClient Client { get; set; } public AppDbContext Context { get; private set; } + public static IRequestSerializer GetSerializer(IServiceProvider serviceProvider, Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable + { + var serializer = (IRequestSerializer)serviceProvider.GetService(typeof(IRequestSerializer)); + var graph = (IResourceGraph)serviceProvider.GetService(typeof(IResourceGraph)); + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; + return serializer; + } public IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable { var serializer = GetService(); var graph = GetService(); - if (attributes != null) - serializer.AttributesToSerialize = graph.GetAttributes(attributes); - if (relationships != null) - serializer.RelationshipsToSerialize = graph.GetRelationships(relationships); + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; return serializer; } @@ -50,7 +61,7 @@ public IResponseDeserializer GetDeserializer() { var options = GetService(); - var resourceGraph = new ResourceGraphBuilder(options) + var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) .AddResource() .AddResource
() .AddResource() @@ -62,14 +73,22 @@ public IResponseDeserializer GetDeserializer() .AddResource() .AddResource("todoItems") .AddResource().Build(); - return new ResponseDeserializer(resourceGraph); + return new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(ServiceProvider)); } - public T GetService() => (T)_services.GetService(typeof(T)); + public T GetService() => (T)ServiceProvider.GetService(typeof(T)); public void ReloadDbContext() { - Context = new AppDbContext(GetService>()); + ISystemClock systemClock = ServiceProvider.GetRequiredService(); + DbContextOptions options = GetService>(); + + Context = new AppDbContext(options, systemClock); + } + + public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) + { + Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code with payload instead of {expected}. Payload: {response.Content.ReadAsStringAsync().Result}"); } private bool disposedValue; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index e147b619b0..3ec7095d1b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -6,6 +6,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; @@ -22,12 +23,12 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class TodoItemControllerTests { - private readonly TestFixture _fixture; + private readonly TestFixture _fixture; private readonly AppDbContext _context; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; - public TodoItemControllerTests(TestFixture fixture) + public TodoItemControllerTests(TestFixture fixture) { _fixture = fixture; _context = fixture.GetService(); @@ -46,7 +47,7 @@ public TodoItemControllerTests(TestFixture fixture) public async Task Can_Get_TodoItems_Paginate_Check() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems.ToList()); + _context.TodoItems.RemoveRange(_context.TodoItems); _context.SaveChanges(); int expectedEntitiesPerPage = _fixture.GetService().DefaultPageSize; var person = new Person(); @@ -103,7 +104,7 @@ public async Task Can_Filter_By_Relationship_Id() { // Arrange var person = new Person(); - var todoItems = _todoItemFaker.Generate(3).ToList(); + var todoItems = _todoItemFaker.Generate(3); _context.TodoItems.AddRange(todoItems); todoItems[0].Owner = person; _context.SaveChanges(); @@ -530,7 +531,7 @@ public async Task Can_Post_TodoItem() { Content = new StringContent(serializer.Serialize(todoItem)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -542,11 +543,10 @@ public async Task Can_Post_TodoItem() Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(todoItem.Description, deserializedBody.Description); Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); - Assert.Equal(nowOffset.ToString("yyyy-MM-ddTHH:mm:ssK"), deserializedBody.OffsetDate?.ToString("yyyy-MM-ddTHH:mm:ssK")); + Assert.Equal(nowOffset, deserializedBody.OffsetDate); Assert.Null(deserializedBody.AchievedDate); } - [Fact] public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() { @@ -598,7 +598,7 @@ public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -654,7 +654,7 @@ public async Task Can_Patch_TodoItem() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -678,13 +678,13 @@ public async Task Can_Patch_TodoItemWithNullable() _context.SaveChanges(); var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = DateTime.Now; + todoItem.AchievedDate = new DateTime(2002, 2,2); todoItem.Owner = person; _context.TodoItems.Add(todoItem); _context.SaveChanges(); var newTodoItem = _todoItemFaker.Generate(); - newTodoItem.AchievedDate = DateTime.Now.AddDays(2); + newTodoItem.AchievedDate = new DateTime(2002, 2,4); var content = new { @@ -709,7 +709,7 @@ public async Task Can_Patch_TodoItemWithNullable() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -733,7 +733,7 @@ public async Task Can_Patch_TodoItemWithNullValue() _context.SaveChanges(); var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = DateTime.Now; + todoItem.AchievedDate = new DateTime(2002, 2,2); todoItem.Owner = person; _context.TodoItems.Add(todoItem); _context.SaveChanges(); @@ -763,7 +763,7 @@ public async Task Can_Patch_TodoItemWithNullValue() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); @@ -795,7 +795,7 @@ public async Task Can_Delete_TodoItem() var route = $"/api/v1/todoItems/{todoItem.Id}"; var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(string.Empty)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act var response = await _fixture.Client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs index 91d832e062..f1b0bfed37 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using System.Reflection; @@ -9,6 +9,8 @@ public class ClientEnabledIdsApplicationFactory : CustomApplicationFactoryBase { protected override void ConfigureWebHost(IWebHostBuilder builder) { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => { services.AddClientSerialization(); diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs index d94b8f001b..9497a3e123 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs @@ -1,15 +1,19 @@ -using JsonApiDotNetCoreExample; +using System; +using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using System.Net.Http; +using Microsoft.AspNetCore.Hosting; namespace JsonApiDotNetCoreExampleTests { - public class CustomApplicationFactoryBase : WebApplicationFactory, IApplicationFactory + public class CustomApplicationFactoryBase : WebApplicationFactory, IApplicationFactory { public readonly HttpClient Client; private readonly IServiceScope _scope; + public IServiceProvider ServiceProvider => _scope.ServiceProvider; + public CustomApplicationFactoryBase() { Client = CreateClient(); @@ -17,10 +21,17 @@ public CustomApplicationFactoryBase() } public T GetService() => (T)_scope.ServiceProvider.GetService(typeof(T)); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseStartup(); + } } public interface IApplicationFactory { + IServiceProvider ServiceProvider { get; } + T GetService(); HttpClient CreateClient(); } diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs index 26222b8602..8915bdf014 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs @@ -1,5 +1,6 @@ -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; namespace JsonApiDotNetCoreExampleTests { @@ -7,7 +8,9 @@ public class StandardApplicationFactory : CustomApplicationFactoryBase { protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => + base.ConfigureWebHost(builder); + + builder.ConfigureTestServices(services => { services.AddClientSerialization(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/PassportClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/PassportClient.cs deleted file mode 100644 index af739445d3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/PassportClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Models; - -namespace JsonApiDotNetCoreExampleTests.Helpers.Models -{ - /// - /// this "client" version of the is required because the - /// base property that is overridden here does not have a setter. For a model - /// defined on a json:api client, it would not make sense to have an exposed attribute - /// without a setter. - /// - public class PassportClient : Passport - { - [Attr] - public new string GrantedVisaCountries { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs index afe60c0fe7..e6e7e761a4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs @@ -18,16 +18,16 @@ public class TodoItemClient : TodoItem } [Resource("todoCollections")] - public class TodoItemCollectionClient : Identifiable + public sealed class TodoItemCollectionClient : Identifiable { [Attr] public string Name { get; set; } public int OwnerId { get; set; } [HasMany] - public virtual List TodoItems { get; set; } + public ISet TodoItems { get; set; } [HasOne] - public virtual Person Owner { get; set; } + public Person Owner { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs b/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs index edf8bac897..c50654c30d 100644 --- a/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs +++ b/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs @@ -5,6 +5,6 @@ namespace JsonApiDotNetCoreExampleTests { [CollectionDefinition("WebHostCollection")] - public class WebHostCollection : ICollectionFixture> + public class WebHostCollection : ICollectionFixture> { } } diff --git a/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs b/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs index a71495c367..cb14880b05 100644 --- a/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs +++ b/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -96,7 +97,7 @@ public async Task Can_Create_TodoItems() { Content = new StringContent(JsonConvert.SerializeObject(content)) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); var builder = new WebHostBuilder() .UseStartup(); diff --git a/test/NoEntityFrameworkTests/TestFixture.cs b/test/NoEntityFrameworkTests/TestFixture.cs index e145ec3ba5..19c0be041f 100644 --- a/test/NoEntityFrameworkTests/TestFixture.cs +++ b/test/NoEntityFrameworkTests/TestFixture.cs @@ -1,14 +1,13 @@ using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Client; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; using System; -using System.Linq.Expressions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using Microsoft.Extensions.Logging.Abstractions; using NoEntityFrameworkExample; namespace NoEntityFrameworkTests @@ -27,23 +26,12 @@ public TestFixture() _services = Server.Host.Services; } - public IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetService(); - var graph = GetService(); - if (attributes != null) - serializer.AttributesToSerialize = graph.GetAttributes(attributes); - if (relationships != null) - serializer.RelationshipsToSerialize = graph.GetRelationships(relationships); - return serializer; - } - public IResponseDeserializer GetDeserializer() { var options = GetService(); - var resourceGraph = new ResourceGraphBuilder(options).AddResource("todoItems").Build(); - return new ResponseDeserializer(resourceGraph); + var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).AddResource("todoItems").Build(); + return new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(_services)); } public T GetService() => (T)_services.GetService(typeof(T)); diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index d181b994e3..bb0ff76114 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,13 +1,14 @@ +using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions.EntityFrameworkCore; -using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace UnitTests @@ -21,6 +22,11 @@ private sealed class DbResource : Identifiable { } private class TestContext : DbContext { public DbSet DbResources { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); + } } [Fact] @@ -28,6 +34,9 @@ public void Can_Build_ResourceGraph_Using_Builder() { // Arrange var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(); + services.AddJsonApi(resources: builder => builder.AddResource("nonDbResources")); // Act @@ -46,7 +55,7 @@ public void Can_Build_ResourceGraph_Using_Builder() public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions()); + var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); builder.AddResource(); // Act @@ -61,7 +70,7 @@ public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions()); + var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); builder.AddResource(); // Act @@ -76,7 +85,7 @@ public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions()); + var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); builder.AddResource(); // Act @@ -84,15 +93,20 @@ public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter( // Assert var resource = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Equal("relatedResource", resource.Relationships.Single(r => r.IsHasOne).PublicRelationshipName); - Assert.Equal("relatedResources", resource.Relationships.Single(r => r.IsHasMany).PublicRelationshipName); + Assert.Equal("relatedResource", resource.Relationships.Single(r => r is HasOneAttribute).PublicRelationshipName); + Assert.Equal("relatedResources", resource.Relationships.Single(r => !(r is HasOneAttribute)).PublicRelationshipName); } public sealed class TestResource : Identifiable { - [Attr] public string CompoundAttribute { get; set; } - [HasOne] public RelatedResource RelatedResource { get; set; } - [HasMany] public List RelatedResources { get; set; } + [Attr] + public string CompoundAttribute { get; set; } + + [HasOne] + public RelatedResource RelatedResource { get; set; } + + [HasMany] + public ISet RelatedResources { get; set; } } public class RelatedResource : Identifiable { } diff --git a/test/UnitTests/Data/DefaultEntityRepositoryTest.cs b/test/UnitTests/Data/DefaultEntityRepositoryTest.cs index 3633000a70..78040a7d98 100644 --- a/test/UnitTests/Data/DefaultEntityRepositoryTest.cs +++ b/test/UnitTests/Data/DefaultEntityRepositoryTest.cs @@ -5,8 +5,10 @@ using Microsoft.EntityFrameworkCore; using Moq; using System.Collections.Generic; +using System.ComponentModel.Design; using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -65,9 +67,9 @@ private DefaultResourceRepository Setup() contextResolverMock.Setup(m => m.GetContext()).Returns(new Mock().Object); var resourceGraph = new Mock(); var targetedFields = new Mock(); - var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph.Object, null, NullLoggerFactory.Instance); + var resourceFactory = new DefaultResourceFactory(new ServiceContainer()); + var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph.Object, null, resourceFactory, NullLoggerFactory.Instance); return repository; } - } } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 0791f8cb22..738d4770d6 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Generics; @@ -14,11 +13,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Serialization.Server; -using JsonApiDotNetCore.Extensions.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication; namespace UnitTests.Extensions { @@ -30,6 +30,7 @@ public void AddJsonApiInternals_Adds_All_Required_Services() // Arrange var services = new ServiceCollection(); services.AddLogging(); + services.AddSingleton(); services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb"), ServiceLifetime.Transient); services.AddJsonApi(); @@ -64,7 +65,8 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() { // Arrange var services = new ServiceCollection(); - + services.AddLogging(); + services.AddSingleton(); services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb"), ServiceLifetime.Transient); services.AddJsonApi(); @@ -141,6 +143,8 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( { // Arrange var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); services.AddScoped(); @@ -184,6 +188,10 @@ private class GuidResourceService : IResourceService public class TestContext : DbContext { + public TestContext(DbContextOptions options) : base(options) + { + } + public DbSet Resource { get; set; } } } diff --git a/test/UnitTests/Extensions/TypeExtensions_Tests.cs b/test/UnitTests/Extensions/TypeExtensions_Tests.cs index 849b80d1f6..7bdba9ee32 100644 --- a/test/UnitTests/Extensions/TypeExtensions_Tests.cs +++ b/test/UnitTests/Extensions/TypeExtensions_Tests.cs @@ -1,6 +1,5 @@ -using System; -using System.Collections.Generic; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Xunit; @@ -8,21 +7,6 @@ namespace UnitTests.Extensions { public sealed class TypeExtensions_Tests { - [Fact] - public void GetCollection_Creates_List_If_T_Implements_Interface() - { - // Arrange - var type = typeof(Model); - - // Act - var collection = type.GetEmptyCollection(); - - // Assert - Assert.NotNull(collection); - Assert.Empty(collection); - Assert.IsType>(collection); - } - [Fact] public void New_Creates_An_Instance_If_T_Implements_Interface() { @@ -30,7 +14,7 @@ public void New_Creates_An_Instance_If_T_Implements_Interface() var type = typeof(Model); // Act - var instance = type.New(); + var instance = (IIdentifiable)TypeHelper.CreateInstance(type); // Assert Assert.NotNull(instance); @@ -44,7 +28,7 @@ public void Implements_Returns_True_If_Type_Implements_Interface() var type = typeof(Model); // Act - var result = type.Implements(); + var result = type.IsOrImplementsInterface(typeof(IIdentifiable)); // Assert Assert.True(result); @@ -54,10 +38,10 @@ public void Implements_Returns_True_If_Type_Implements_Interface() public void Implements_Returns_False_If_Type_DoesNot_Implement_Interface() { // Arrange - var type = typeof(String); + var type = typeof(string); // Act - var result = type.Implements(); + var result = type.IsOrImplementsInterface(typeof(IIdentifiable)); // Assert Assert.False(result); diff --git a/test/UnitTests/FakeLoggerFactory.cs b/test/UnitTests/FakeLoggerFactory.cs new file mode 100644 index 0000000000..999230e214 --- /dev/null +++ b/test/UnitTests/FakeLoggerFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace UnitTests +{ + internal sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider + { + public FakeLogger Logger { get; } + + public FakeLoggerFactory() + { + Logger = new FakeLogger(); + } + + public ILogger CreateLogger(string categoryName) => Logger; + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + internal sealed class FakeLogger : ILogger + { + public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + var message = formatter(state, exception); + Messages.Add((logLevel, message)); + } + + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable BeginScope(TState state) => null; + } + } +} diff --git a/test/UnitTests/FrozenSystemClock.cs b/test/UnitTests/FrozenSystemClock.cs new file mode 100644 index 0000000000..218fe1cb54 --- /dev/null +++ b/test/UnitTests/FrozenSystemClock.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace UnitTests +{ + internal class FrozenSystemClock : ISystemClock + { + public DateTimeOffset UtcNow { get; } + + public FrozenSystemClock() + : this(new DateTimeOffset(new DateTime(2000, 1, 1))) + { + } + + public FrozenSystemClock(DateTimeOffset utcNow) + { + UtcNow = utcNow; + } + } +} diff --git a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs b/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs index 9646a4ebd7..1ceb41f2aa 100644 --- a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs +++ b/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs @@ -1,9 +1,9 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions.EntityFrameworkCore; using JsonApiDotNetCore.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace UnitTests.Internal @@ -11,32 +11,34 @@ namespace UnitTests.Internal public sealed class ResourceGraphBuilder_Tests { [Fact] - public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_DoNot_Implement_IIdentifiable() + public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_Do_Not_Implement_IIdentifiable() { // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions()); + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); // Act - resourceGraphBuilder.AddDbContext(); - var resourceGraph = resourceGraphBuilder.Build() as ResourceGraph; + resourceGraphBuilder.AddResource(typeof(TestContext)); + var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); // Assert Assert.Empty(resourceGraph.GetResourceContexts()); } [Fact] - public void Adding_DbContext_Members_That_DoNot_Implement_IIdentifiable_Creates_Warning() + public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Warning() { // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions()); + var loggerFactory = new FakeLoggerFactory(); + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), loggerFactory); + resourceGraphBuilder.AddResource(typeof(TestContext)); // Act - resourceGraphBuilder.AddDbContext(); - var resourceGraph = resourceGraphBuilder.Build() as ResourceGraph; + resourceGraphBuilder.Build(); // Assert - Assert.Single(resourceGraph.ValidationResults); - Assert.Contains(resourceGraph.ValidationResults, v => v.LogLevel == LogLevel.Warning); + Assert.Single(loggerFactory.Logger.Messages); + Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); + Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilder_Tests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages[0].Text); } private class Foo { } diff --git a/test/UnitTests/Internal/TypeHelper_Tests.cs b/test/UnitTests/Internal/TypeHelper_Tests.cs index 387d0cfdc3..89c926aaab 100644 --- a/test/UnitTests/Internal/TypeHelper_Tests.cs +++ b/test/UnitTests/Internal/TypeHelper_Tests.cs @@ -11,7 +11,7 @@ public sealed class TypeHelper_Tests public void Can_Convert_DateTimeOffsets() { // Arrange - var dto = DateTimeOffset.Now; + var dto = new DateTimeOffset(new DateTime(2002, 2,2), TimeSpan.FromHours(4));; var formattedString = dto.ToString("O"); // Act diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index 73c368215f..5afd4ec1ca 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -73,7 +73,7 @@ public async Task ParseUrlBase_UrlHasNegativeBaseIdAndTypeIsInt_ShouldNotThrowJA private sealed class InvokeConfiguration { - public CurrentRequestMiddleware MiddleWare; + public JsonApiMiddleware MiddleWare; public HttpContext HttpContext; public Mock ControllerResourceMapping; public Mock Options; @@ -95,7 +95,7 @@ private InvokeConfiguration GetConfiguration(string path, string resourceName = { throw new ArgumentException("Path should start with a '/'"); } - var middleware = new CurrentRequestMiddleware(httpContext => + var middleware = new JsonApiMiddleware(httpContext => { return Task.Run(() => Console.WriteLine("finished")); }); diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs deleted file mode 100644 index 45d25d1832..0000000000 --- a/test/UnitTests/Models/ConstructionTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; -using Xunit; - -namespace UnitTests.Models -{ - public sealed class ConstructionTests - { - [Fact] - public void When_model_has_no_parameterless_contructor_it_must_fail() - { - // Arrange - var graph = new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build(); - - var serializer = new RequestDeserializer(graph, new TargetedFields()); - - var body = new - { - data = new - { - id = "1", - type = "resourceWithParameters" - } - }; - string content = Newtonsoft.Json.JsonConvert.SerializeObject(body); - - // Act - Action action = () => serializer.Deserialize(content); - - // Assert - var exception = Assert.Throws(action); - Assert.Equal("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Message); - } - - public class ResourceWithParameters : Identifiable - { - [Attr] public string Title { get; } - - public ResourceWithParameters(string title) - { - Title = title; - } - } - } -} diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs new file mode 100644 index 0000000000..90ac696958 --- /dev/null +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -0,0 +1,76 @@ +using System; +using System.ComponentModel.Design; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace UnitTests.Models +{ + public sealed class ResourceConstructionExpressionTests + { + [Fact] + public void When_resource_has_default_constructor_it_must_succeed() + { + // Arrange + var factory = new DefaultResourceFactory(new ServiceContainer()); + + // Act + NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithoutConstructor)); + + // Assert + var function = Expression + .Lambda>(newExpression) + .Compile(); + + ResourceWithoutConstructor resource = function(); + Assert.NotNull(resource); + } + + [Fact] + public void When_resource_has_constructor_with_injectable_parameter_it_must_succeed() + { + // Arrange + var contextOptions = new DbContextOptionsBuilder().Options; + var systemClock = new FrozenSystemClock(); + var appDbContext = new AppDbContext(contextOptions, systemClock); + + using var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(DbContextOptions), contextOptions); + serviceContainer.AddService(typeof(ISystemClock), systemClock); + serviceContainer.AddService(typeof(AppDbContext), appDbContext); + + var factory = new DefaultResourceFactory(serviceContainer); + + // Act + NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithDbContextConstructor)); + + // Assert + var function = Expression + .Lambda>(newExpression) + .Compile(); + + ResourceWithDbContextConstructor resource = function(); + Assert.NotNull(resource); + Assert.Equal(appDbContext, resource.AppDbContext); + } + + [Fact] + public void When_resource_has_constructor_with_string_parameter_it_must_fail() + { + // Arrange + var factory = new DefaultResourceFactory(new ServiceContainer()); + + // Act + Action action = () => factory.CreateNewExpression(typeof(ResourceWithStringConstructor)); + + // Assert + var exception = Assert.Throws(action); + Assert.Equal( + "Failed to create an instance of 'UnitTests.Models.ResourceWithStringConstructor': Parameter 'text' could not be resolved.", + exception.Message); + } + } +} diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs new file mode 100644 index 0000000000..b684491f85 --- /dev/null +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -0,0 +1,177 @@ +using System; +using System.ComponentModel.Design; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Server; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using Xunit; + +namespace UnitTests.Models +{ + public sealed class ResourceConstructionTests + { + [Fact] + public void When_resource_has_default_constructor_it_must_succeed() + { + // Arrange + var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + .AddResource() + .Build(); + + var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + + var body = new + { + data = new + { + id = "1", + type = "resourceWithoutConstructors" + } + }; + + string content = JsonConvert.SerializeObject(body); + + // Act + object result = serializer.Deserialize(content); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(ResourceWithoutConstructor), result.GetType()); + } + + [Fact] + public void When_resource_has_default_constructor_that_throws_it_must_fail() + { + // Arrange + var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + .AddResource() + .Build(); + + var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + + var body = new + { + data = new + { + id = "1", + type = "resourceWithThrowingConstructors" + } + }; + + string content = JsonConvert.SerializeObject(body); + + // Act + Action action = () => serializer.Deserialize(content); + + // Assert + var exception = Assert.Throws(action); + Assert.Equal( + "Failed to create an instance of 'UnitTests.Models.ResourceWithThrowingConstructor' using its default constructor.", + exception.Message); + } + + [Fact] + public void When_resource_has_constructor_with_injectable_parameter_it_must_succeed() + { + // Arrange + var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + .AddResource() + .Build(); + + var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); + + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(AppDbContext), appDbContext); + + var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(serviceContainer), new TargetedFields()); + + var body = new + { + data = new + { + id = "1", + type = "resourceWithDbContextConstructors" + } + }; + + string content = JsonConvert.SerializeObject(body); + + // Act + object result = serializer.Deserialize(content); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(ResourceWithDbContextConstructor), result.GetType()); + Assert.Equal(appDbContext, ((ResourceWithDbContextConstructor)result).AppDbContext); + } + + [Fact] + public void When_resource_has_constructor_with_string_parameter_it_must_fail() + { + // Arrange + var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + .AddResource() + .Build(); + + var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + + var body = new + { + data = new + { + id = "1", + type = "resourceWithStringConstructors" + } + }; + + string content = JsonConvert.SerializeObject(body); + + // Act + Action action = () => serializer.Deserialize(content); + + // Assert + var exception = Assert.Throws(action); + Assert.Equal( + "Failed to create an instance of 'UnitTests.Models.ResourceWithStringConstructor' using injected constructor parameters.", + exception.Message); + } + } + + public class ResourceWithoutConstructor : Identifiable + { + } + + public class ResourceWithDbContextConstructor : Identifiable + { + public AppDbContext AppDbContext { get; } + + public ResourceWithDbContextConstructor(AppDbContext appDbContext) + { + AppDbContext = appDbContext ?? throw new ArgumentNullException(nameof(appDbContext)); + } + } + + public class ResourceWithThrowingConstructor : Identifiable + { + public ResourceWithThrowingConstructor() + { + throw new ArgumentException("Failed to initialize."); + } + } + + public class ResourceWithStringConstructor : Identifiable + { + public string Text { get; } + + public ResourceWithStringConstructor(string text) + { + Text = text ?? throw new ArgumentNullException(nameof(text)); + } + } +} diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index 1e2a2ebbe1..412af3c7f5 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Models; using System.Linq; using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace UnitTests.Models @@ -48,7 +49,7 @@ public sealed class RequestFilteredResource : ResourceDefinition { // this constructor will be resolved from the container // that means you can take on any dependency that is also defined in the container - public RequestFilteredResource(bool isAdmin) : base(new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build()) + public RequestFilteredResource(bool isAdmin) : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { if (isAdmin) HideFields(m => m.AlwaysExcluded); diff --git a/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs b/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs index 1b4688ee4a..8fb916a2a7 100644 --- a/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs +++ b/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using UnitTests.TestModels; @@ -18,7 +19,7 @@ public class QueryParametersUnitTestCollection public QueryParametersUnitTestCollection() { - var builder = new ResourceGraphBuilder(new JsonApiOptions()); + var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); builder.AddResource
(); builder.AddResource(); builder.AddResource(); diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index e4749fec8f..d7f17ed8a2 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; +using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Primitives; using Xunit; @@ -47,9 +48,9 @@ public void Parse_ValidSelection_CanParse() { // Arrange const string type = "articles"; - const string attrName = "someField"; - var attribute = new AttrAttribute(attrName); - var idAttribute = new AttrAttribute("id"); + const string attrName = "name"; + var attribute = new AttrAttribute(attrName) {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Name))}; + var idAttribute = new AttrAttribute("id") {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Id))}; var query = new KeyValuePair("fields", attrName); @@ -138,7 +139,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() // Arrange const string type = "articles"; const string attrName = "dne"; - var idAttribute = new AttrAttribute("id"); + var idAttribute = new AttrAttribute("id") {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Id))}; var query = new KeyValuePair("fields", attrName); diff --git a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs b/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs index 34be2b6ea8..a30d928581 100644 --- a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs +++ b/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs @@ -17,7 +17,7 @@ public sealed class Dummy : Identifiable [HasOne] public ToOne SecondToOne { get; set; } [HasMany] - public List ToManies { get; set; } + public ISet ToManies { get; set; } } public class NotTargeted : Identifiable { } @@ -42,19 +42,19 @@ public RelationshipDictionaryTests() { LeftType = typeof(Dummy), RightType = typeof(ToOne), - InternalRelationshipName = "FirstToOne" + PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.FirstToOne)) }; SecondToOneAttr = new HasOneAttribute("secondToOne") { LeftType = typeof(Dummy), RightType = typeof(ToOne), - InternalRelationshipName = "SecondToOne" + PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.SecondToOne)) }; ToManyAttr = new HasManyAttribute("toManies") { LeftType = typeof(Dummy), RightType = typeof(ToMany), - InternalRelationshipName = "ToManies" + PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.ToManies)) }; Relationships.Add(FirstToOneAttr, FirstToOnesEntities); Relationships.Add(SecondToOneAttr, SecondToOnesEntities); @@ -89,9 +89,9 @@ public void RelationshipsDictionary_GetAffected() var affectedThroughToMany = relationshipsDictionary.GetAffected(d => d.ToManies).ToList(); // Assert - affectedThroughFirstToOne.ForEach((entity) => Assert.Contains(entity, FirstToOnesEntities)); - affectedThroughSecondToOne.ForEach((entity) => Assert.Contains(entity, SecondToOnesEntities)); - affectedThroughToMany.ForEach((entity) => Assert.Contains(entity, ToManiesEntities)); + affectedThroughFirstToOne.ForEach(entity => Assert.Contains(entity, FirstToOnesEntities)); + affectedThroughSecondToOne.ForEach(entity => Assert.Contains(entity, SecondToOnesEntities)); + affectedThroughToMany.ForEach(entity => Assert.Contains(entity, ToManiesEntities)); } [Fact] @@ -178,9 +178,9 @@ public void EntityDiff_GetAffected_Relationships() var affectedThroughToMany = diffs.GetAffected(d => d.ToManies).ToList(); // Assert - affectedThroughFirstToOne.ForEach((entity) => Assert.Contains(entity, FirstToOnesEntities)); - affectedThroughSecondToOne.ForEach((entity) => Assert.Contains(entity, SecondToOnesEntities)); - affectedThroughToMany.ForEach((entity) => Assert.Contains(entity, ToManiesEntities)); + affectedThroughFirstToOne.ForEach(entity => Assert.Contains(entity, FirstToOnesEntities)); + affectedThroughSecondToOne.ForEach(entity => Assert.Contains(entity, SecondToOnesEntities)); + affectedThroughToMany.ForEach(entity => Assert.Contains(entity, ToManiesEntities)); } [Fact] @@ -195,8 +195,8 @@ public void EntityDiff_GetAffected_Attributes() DiffableEntityHashSet diffs = new DiffableEntityHashSet(AllEntities, dbEntities, Relationships, updatedAttributes); // Act - var affectedThroughSomeUpdatedProperty = diffs.GetAffected(d => d.SomeUpdatedProperty).ToList(); - var affectedThroughSomeNotUpdatedProperty = diffs.GetAffected(d => d.SomeNotUpdatedProperty).ToList(); + var affectedThroughSomeUpdatedProperty = diffs.GetAffected(d => d.SomeUpdatedProperty); + var affectedThroughSomeNotUpdatedProperty = diffs.GetAffected(d => d.SomeNotUpdatedProperty); // Assert Assert.NotEmpty(affectedThroughSomeUpdatedProperty); @@ -213,17 +213,17 @@ private void AssertRelationshipDictionaryGetters(Dictionary + toOnes[FirstToOneAttr].ToList().ForEach(entity => { Assert.Contains(entity, FirstToOnesEntities); }); - toOnes[SecondToOneAttr].ToList().ForEach((entity) => + toOnes[SecondToOneAttr].ToList().ForEach(entity => { Assert.Contains(entity, SecondToOnesEntities); }); - toManies[ToManyAttr].ToList().ForEach((entity) => + toManies[ToManyAttr].ToList().ForEach(entity => { Assert.Contains(entity, ToManiesEntities); }); diff --git a/test/UnitTests/ResourceHooks/DiscoveryTests.cs b/test/UnitTests/ResourceHooks/DiscoveryTests.cs index bf40e0733a..493d61e4f8 100644 --- a/test/UnitTests/ResourceHooks/DiscoveryTests.cs +++ b/test/UnitTests/ResourceHooks/DiscoveryTests.cs @@ -8,6 +8,7 @@ using System; using JsonApiDotNetCore.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace UnitTests.ResourceHooks { @@ -16,7 +17,7 @@ public sealed class DiscoveryTests public class Dummy : Identifiable { } public sealed class DummyResourceDefinition : ResourceDefinition { - public DummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build()) { } + public DummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } public override IEnumerable BeforeDelete(IEntityHashSet affected, ResourcePipeline pipeline) { return affected; } public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } @@ -49,7 +50,7 @@ public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, public sealed class AnotherDummyResourceDefinition : ResourceDefinitionBase { - public AnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build()) { } + public AnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } } [Fact] @@ -65,7 +66,7 @@ public void HookDiscovery_InheritanceSubclass_CanDiscover() public class YetAnotherDummy : Identifiable { } public sealed class YetAnotherDummyResourceDefinition : ResourceDefinition { - public YetAnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build()) { } + public YetAnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } public override IEnumerable BeforeDelete(IEntityHashSet affected, ResourcePipeline pipeline) { return affected; } @@ -97,7 +98,7 @@ public void HookDiscovery_InheritanceWithGenericSubclass_CanDiscover() public sealed class GenericDummyResourceDefinition : ResourceDefinition where TResource : class, IIdentifiable { - public GenericDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions()).AddResource().Build()) { } + public GenericDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } public override IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline) { return entities; } public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs index 032af05430..53c1ba7d4f 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs @@ -23,7 +23,7 @@ public BeforeDelete_WithDbValues_Tests() var passport = _passportFaker.Generate(); person.Passport = passport; - person.TodoItems = new List { todo1 }; + person.TodoItems = new HashSet { todo1 }; person.StakeHolderTodoItem = todo2; options = InitInMemoryDb(context => { @@ -88,7 +88,7 @@ public void BeforeDelete_No_Children_Hooks() private bool CheckImplicitTodos(IRelationshipsDictionary rh) { - var todos = rh.GetByRelationship().ToList(); + var todos = rh.GetByRelationship(); return todos.Count == 2; } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs index c1abacdfc2..c4a2a1a6b6 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs @@ -13,21 +13,21 @@ public sealed class ManyToMany_OnReturnTests : HooksTestsSetup private (List
, List, List) CreateDummyData() { - var tagsSubset = _tagFaker.Generate(3).ToList(); - var joinsSubSet = _articleTagFaker.Generate(3).ToList(); + var tagsSubset = _tagFaker.Generate(3); + var joinsSubSet = _articleTagFaker.Generate(3); var articleTagsSubset = _articleFaker.Generate(); - articleTagsSubset.ArticleTags = joinsSubSet; + articleTagsSubset.ArticleTags = joinsSubSet.ToHashSet(); for (int i = 0; i < 3; i++) { joinsSubSet[i].Article = articleTagsSubset; joinsSubSet[i].Tag = tagsSubset[i]; } - var allTags = _tagFaker.Generate(3).ToList().Concat(tagsSubset).ToList(); - var completeJoin = _articleTagFaker.Generate(6).ToList(); + var allTags = _tagFaker.Generate(3).Concat(tagsSubset).ToList(); + var completeJoin = _articleTagFaker.Generate(6); var articleWithAllTags = _articleFaker.Generate(); - articleWithAllTags.ArticleTags = completeJoin; + articleWithAllTags.ArticleTags = completeJoin.ToHashSet(); for (int i = 0; i < 6; i++) { diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs index 9cdec75f65..c528e86929 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs @@ -19,10 +19,10 @@ public void Entity_Has_Multiple_Relations_To_Same_Type() var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var person1 = new Person(); var todo = new TodoItem { Owner = person1 }; - var person2 = new Person { AssignedTodoItems = new List { todo } }; + var person2 = new Person { AssignedTodoItems = new HashSet { todo } }; todo.Assignee = person2; var person3 = new Person { StakeHolderTodoItem = todo }; - todo.StakeHolders = new List { person3 }; + todo.StakeHolders = new HashSet { person3 }; var todoList = new List { todo }; // Act @@ -78,4 +78,3 @@ public void Entity_Has_Nested_Cyclic_Relations() } } } - diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 234cb8af5d..44547a7a9e 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -18,6 +18,7 @@ using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Query; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; namespace UnitTests.ResourceHooks @@ -38,7 +39,9 @@ public class HooksDummyData public HooksDummyData() { - _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions()) + var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); + + _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .AddResource() .AddResource() .AddResource() @@ -48,17 +51,19 @@ public HooksDummyData() .AddResource() .Build(); - - _todoFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _personFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _articleFaker = new Faker
().Rules((f, i) => i.Id = f.UniqueIndex + 1); - _articleTagFaker = new Faker(); + _articleTagFaker = new Faker().CustomInstantiator(f => new ArticleTag(appDbContext)); _identifiableArticleTagFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); - _tagFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); + _tagFaker = new Faker() + .CustomInstantiator(f => new Tag(appDbContext)) + .Rules((f, i) => i.Id = f.UniqueIndex + 1); - _passportFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); + _passportFaker = new Faker() + .CustomInstantiator(f => new Passport(appDbContext)) + .Rules((f, i) => i.Id = f.UniqueIndex + 1); } protected List CreateTodoWithToOnePerson() @@ -71,11 +76,11 @@ protected List CreateTodoWithToOnePerson() return todoList; } - protected List CreateTodoWithOwner() + protected HashSet CreateTodoWithOwner() { var todoItem = _todoFaker.Generate(); var person = _personFaker.Generate(); - var todoList = new List { todoItem }; + var todoList = new HashSet { todoItem }; person.AssignedTodoItems = todoList; todoItem.Owner = person; return todoList; @@ -83,21 +88,21 @@ protected List CreateTodoWithOwner() protected (List
, List, List) CreateManyToManyData() { - var tagsSubset = _tagFaker.Generate(3).ToList(); - var joinsSubSet = _articleTagFaker.Generate(3).ToList(); + var tagsSubset = _tagFaker.Generate(3); + var joinsSubSet = _articleTagFaker.Generate(3); var articleTagsSubset = _articleFaker.Generate(); - articleTagsSubset.ArticleTags = joinsSubSet; + articleTagsSubset.ArticleTags = joinsSubSet.ToHashSet(); for (int i = 0; i < 3; i++) { joinsSubSet[i].Article = articleTagsSubset; joinsSubSet[i].Tag = tagsSubset[i]; } - var allTags = _tagFaker.Generate(3).ToList().Concat(tagsSubset).ToList(); - var completeJoin = _articleTagFaker.Generate(6).ToList(); + var allTags = _tagFaker.Generate(3).Concat(tagsSubset).ToList(); + var completeJoin = _articleTagFaker.Generate(6); var articleWithAllTags = _articleFaker.Generate(); - articleWithAllTags.ArticleTags = completeJoin; + articleWithAllTags.ArticleTags = completeJoin.ToHashSet(); for (int i = 0; i < 6; i++) { @@ -113,20 +118,20 @@ protected List CreateTodoWithOwner() protected (List
, List, List) CreateIdentifiableManyToManyData() { - var tagsSubset = _tagFaker.Generate(3).ToList(); - var joinsSubSet = _identifiableArticleTagFaker.Generate(3).ToList(); + var tagsSubset = _tagFaker.Generate(3); + var joinsSubSet = _identifiableArticleTagFaker.Generate(3); var articleTagsSubset = _articleFaker.Generate(); - articleTagsSubset.IdentifiableArticleTags = joinsSubSet; + articleTagsSubset.IdentifiableArticleTags = joinsSubSet.ToHashSet(); for (int i = 0; i < 3; i++) { joinsSubSet[i].Article = articleTagsSubset; joinsSubSet[i].Tag = tagsSubset[i]; } - var allTags = _tagFaker.Generate(3).ToList().Concat(tagsSubset).ToList(); - var completeJoin = _identifiableArticleTagFaker.Generate(6).ToList(); + var allTags = _tagFaker.Generate(3).Concat(tagsSubset).ToList(); + var completeJoin = _identifiableArticleTagFaker.Generate(6); var articleWithAllTags = _articleFaker.Generate(); - articleWithAllTags.IdentifiableArticleTags = joinsSubSet; + articleWithAllTags.IdentifiableArticleTags = joinsSubSet.ToHashSet(); for (int i = 0; i < 6; i++) { @@ -164,7 +169,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); return (iqMock, hookExecutor, mainResource); } @@ -185,9 +190,9 @@ public class HooksTestsSetup : HooksDummyData // mocking the genericServiceFactory and JsonApiContext and wiring them up. var (ufMock, iqMock, gpfMock, options) = CreateMocks(); - var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions) : null; + var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; - var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions()) + var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .AddResource() .AddResource() .Build(); @@ -197,7 +202,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); return (iqMock, ufMock, hookExecutor, mainResource, nestedResource); } @@ -221,9 +226,9 @@ public class HooksTestsSetup : HooksDummyData // mocking the genericServiceFactory and JsonApiContext and wiring them up. var (ufMock, iqMock, gpfMock, options) = CreateMocks(); - var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions) : null; + var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; - var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions()) + var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .AddResource() .AddResource() .AddResource() @@ -235,7 +240,7 @@ public class HooksTestsSetup : HooksDummyData var execHelper = new HookExecutorHelper(gpfMock.Object, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); return (iqMock, hookExecutor, mainResource, firstNestedResource, secondNestedResource); } @@ -272,7 +277,7 @@ protected DbContextOptions InitInMemoryDb(Action seeder .UseInMemoryDatabase(databaseName: "repository_mock") .Options; - using (var context = new AppDbContext(options)) + using (var context = new AppDbContext(options, new FrozenSystemClock())) { seeder(context); ResolveInverseRelationships(context); @@ -339,7 +344,7 @@ private void SetupProcessorFactoryForResourceDefinition( if (dbContext != null) { - var idType = TypeHelper.GetIdentifierType(); + var idType = TypeHelper.GetIdType(typeof(TModel)); if (idType == typeof(int)) { IResourceReadRepository repo = CreateTestRepository(dbContext, resourceGraph); @@ -353,12 +358,13 @@ private void SetupProcessorFactoryForResourceDefinition( } } - private IResourceReadRepository CreateTestRepository( - AppDbContext dbContext, IResourceGraph resourceGraph - ) where TModel : class, IIdentifiable + private IResourceReadRepository CreateTestRepository(AppDbContext dbContext, IResourceGraph resourceGraph) + where TModel : class, IIdentifiable { + var serviceProvider = ((IInfrastructure) dbContext).Instance; + var resourceFactory = new DefaultResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); - return new DefaultResourceRepository(null, resolver, resourceGraph, null, NullLoggerFactory.Instance); + return new DefaultResourceRepository(null, resolver, resourceGraph, null, resourceFactory, NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable diff --git a/test/UnitTests/Serialization/Client/RequestSerializerTests.cs b/test/UnitTests/Serialization/Client/RequestSerializerTests.cs index 783c49c11a..c81f9faff8 100644 --- a/test/UnitTests/Serialization/Client/RequestSerializerTests.cs +++ b/test/UnitTests/Serialization/Client/RequestSerializerTests.cs @@ -129,7 +129,7 @@ public void SerializeSingle_ResourceWithTargetedRelationships_CanBuild() var entityWithRelationships = new MultipleRelationshipsPrincipalPart { PopulatedToOne = new OneToOneDependent { Id = 10 }, - PopulatedToManies = new List { new OneToManyDependent { Id = 20 } } + PopulatedToManies = new HashSet { new OneToManyDependent { Id = 20 } } }; _serializer.RelationshipsToSerialize = _resourceGraph.GetRelationships(tr => new { tr.EmptyToOne, tr.EmptyToManies, tr.PopulatedToOne, tr.PopulatedToManies }); diff --git a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs index fa9436d699..7c93a01da9 100644 --- a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs +++ b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.ComponentModel.Design; using System.Linq; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Serialization.Client; @@ -16,7 +18,7 @@ public sealed class ResponseDeserializerTests : DeserializerTestsSetup public ResponseDeserializerTests() { - _deserializer = new ResponseDeserializer(_resourceGraph); + _deserializer = new ResponseDeserializer(_resourceGraph, new DefaultResourceFactory(new ServiceContainer())); _linkValues.Add("self", "http://example.com/articles"); _linkValues.Add("next", "http://example.com/articles?page[offset]=2"); _linkValues.Add("last", "http://example.com/articles?page[offset]=10"); diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 360cb2c775..c6911cb1f5 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel.Design; using System.Linq; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Newtonsoft.Json; using Xunit; @@ -15,7 +17,7 @@ public sealed class BaseDocumentParserTests : DeserializerTestsSetup public BaseDocumentParserTests() { - _deserializer = new TestDocumentParser(_resourceGraph); + _deserializer = new TestDocumentParser(_resourceGraph, new DefaultResourceFactory(new ServiceContainer())); } [Fact] diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 0aded6e7f9..5a1f26e6f9 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -111,7 +111,7 @@ public void EntityWithRelationshipsToResourceObject_WithIncludedRelationshipsAtt var entity = new MultipleRelationshipsPrincipalPart { PopulatedToOne = new OneToOneDependent { Id = 10 }, - PopulatedToManies = new List { new OneToManyDependent { Id = 20 } } + PopulatedToManies = new HashSet { new OneToManyDependent { Id = 20 } } }; var relationships = _resourceGraph.GetRelationships(tr => new { tr.PopulatedToManies, tr.PopulatedToOne, tr.EmptyToOne, tr.EmptyToManies }); diff --git a/test/UnitTests/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs index 79d1a83666..16c0426b7e 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -1,7 +1,9 @@ -using JsonApiDotNetCore.Internal.Contracts; +using System; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using System.Collections.Generic; +using JsonApiDotNetCore.Internal; namespace UnitTests.Serialization { @@ -9,7 +11,7 @@ public class DeserializerTestsSetup : SerializationTestsSetupBase { protected sealed class TestDocumentParser : BaseDocumentParser { - public TestDocumentParser(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TestDocumentParser(IResourceGraph resourceGraph, IResourceFactory resourceFactory) : base(resourceGraph, resourceFactory) { } public new object Deserialize(string body) { diff --git a/test/UnitTests/Serialization/SerializationTestsSetupBase.cs b/test/UnitTests/Serialization/SerializationTestsSetupBase.cs index 610fb983a1..2c3c85368f 100644 --- a/test/UnitTests/Serialization/SerializationTestsSetupBase.cs +++ b/test/UnitTests/Serialization/SerializationTestsSetupBase.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; +using Microsoft.Extensions.Logging.Abstractions; using UnitTests.TestModels; using Person = UnitTests.TestModels.Person; @@ -38,7 +39,7 @@ public SerializationTestsSetupBase() protected IResourceGraph BuildGraph() { - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions()); + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); resourceGraphBuilder.AddResource("testResource"); resourceGraphBuilder.AddResource("testResource-with-list"); // one to one relationships diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 95e28c4a8d..6bcf5d4b83 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Query; @@ -122,4 +121,4 @@ public TestDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) : base( } } } -} \ No newline at end of file +} diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index e0dca46bd8..ed5bca93b9 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -26,11 +26,11 @@ public void BuildIncluded_DeeplyNestedCircularChainOfSingleData_CanBuild() // Assert Assert.Equal(6, result.Count); - var authorResourceObject = result.Single((ro) => ro.Type == "people" && ro.Id == author.StringId); + var authorResourceObject = result.Single(ro => ro.Type == "people" && ro.Id == author.StringId); var authorFoodRelation = authorResourceObject.Relationships["favoriteFood"].SingleData; Assert.Equal(author.FavoriteFood.StringId, authorFoodRelation.Id); - var reviewerResourceObject = result.Single((ro) => ro.Type == "people" && ro.Id == reviewer.StringId); + var reviewerResourceObject = result.Single(ro => ro.Type == "people" && ro.Id == reviewer.StringId); var reviewerFoodRelation = reviewerResourceObject.Relationships["favoriteFood"].SingleData; Assert.Equal(reviewer.FavoriteFood.StringId, reviewerFoodRelation.Id); } @@ -73,13 +73,13 @@ public void BuildIncluded_OverlappingDeeplyNestedCircularChains_CanBuild() // Assert Assert.Equal(10, result.Count); - var overlappingBlogResourceObject = result.Single((ro) => ro.Type == "blogs" && ro.Id == sharedBlog.StringId); + var overlappingBlogResourceObject = result.Single(ro => ro.Type == "blogs" && ro.Id == sharedBlog.StringId); - Assert.Equal(2, overlappingBlogResourceObject.Relationships.Keys.ToList().Count); - var nonOverlappingBlogs = result.Where((ro) => ro.Type == "blogs" && ro.Id != sharedBlog.StringId).ToList(); + Assert.Equal(2, overlappingBlogResourceObject.Relationships.Keys.Count); + var nonOverlappingBlogs = result.Where(ro => ro.Type == "blogs" && ro.Id != sharedBlog.StringId).ToList(); foreach (var blog in nonOverlappingBlogs) - Assert.Single(blog.Relationships.Keys.ToList()); + Assert.Single(blog.Relationships.Keys); Assert.Equal(authorSong.StringId, sharedBlogAuthor.FavoriteSong.StringId); Assert.Equal(reviewerFood.StringId, sharedBlogAuthor.FavoriteFood.StringId); @@ -90,9 +90,9 @@ public void BuildIncluded_OverlappingDeeplyNestedCircularChains_CanBuild() var reviewer = _personFaker.Generate(); article.Reviewer = reviewer; - var blogs = _blogFaker.Generate(1).ToList(); + var blogs = _blogFaker.Generate(1); blogs.Add(sharedBlog); - reviewer.Blogs = blogs; + reviewer.Blogs = blogs.ToHashSet(); blogs[0].Author = reviewer; var author = _personFaker.Generate(); @@ -114,8 +114,8 @@ public void BuildIncluded_OverlappingDeeplyNestedCircularChains_CanBuild() var author = _personFaker.Generate(); article.Author = author; - var blogs = _blogFaker.Generate(2).ToList(); - author.Blogs = blogs; + var blogs = _blogFaker.Generate(2); + author.Blogs = blogs.ToHashSet(); blogs[0].Reviewer = author; var reviewer = _personFaker.Generate(); @@ -133,7 +133,7 @@ public void BuildIncluded_OverlappingDeeplyNestedCircularChains_CanBuild() public void BuildIncluded_DuplicateChildrenMultipleChains_OnceInOutput() { var person = _personFaker.Generate(); - var articles = _articleFaker.Generate(5).ToList(); + var articles = _articleFaker.Generate(5); articles.ForEach(a => a.Author = person); articles.ForEach(a => a.Reviewer = person); var builder = GetBuilder(); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index b8bd224ad2..1094f60b37 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Net; +using System.ComponentModel.Design; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; @@ -17,7 +19,7 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup private readonly Mock _fieldsManagerMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, _fieldsManagerMock.Object); + _deserializer = new RequestDeserializer(_resourceGraph, new DefaultResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object); } [Fact] diff --git a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs index f26d6234a7..80ec869c01 100644 --- a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs @@ -51,7 +51,7 @@ public void Build_RelationshipNotIncludedAndLinksDisabled_NoRelationshipObject() public void Build_RelationshipIncludedAndLinksDisabled_RelationshipEntryWithData() { // Arrange - var entity = new OneToManyPrincipal { Id = 10, Dependents = new List { new OneToManyDependent { Id = 20 } } }; + var entity = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; var builder = GetResponseResourceObjectBuilder(inclusionChains: new List> { _relationshipsForBuild } ); // Act @@ -68,7 +68,7 @@ public void Build_RelationshipIncludedAndLinksDisabled_RelationshipEntryWithData public void Build_RelationshipIncludedAndLinksEnabled_RelationshipEntryWithDataAndLinks() { // Arrange - var entity = new OneToManyPrincipal { Id = 10, Dependents = new List { new OneToManyDependent { Id = 20 } } }; + var entity = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; var builder = GetResponseResourceObjectBuilder(inclusionChains: new List> { _relationshipsForBuild }, relationshipLinks: _dummyRelationshipLinks); // Act diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 1e83595d14..d210ab4ca2 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -87,7 +87,7 @@ public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() { Id = 1, PopulatedToOne = new OneToOneDependent { Id = 10 }, - PopulatedToManies = new List { new OneToManyDependent { Id = 20 } } + PopulatedToManies = new HashSet { new OneToManyDependent { Id = 20 } } }; var chain = _resourceGraph.GetRelationships().Select(r => new List { r }).ToList(); var serializer = GetResponseSerializer(inclusionChains: chain); @@ -149,7 +149,7 @@ public void SerializeSingle_ResourceWithDeeplyIncludedRelationships_CanSerialize var entity = new MultipleRelationshipsPrincipalPart { Id = 10, - PopulatedToManies = new List { includedEntity } + PopulatedToManies = new HashSet { includedEntity } }; var chains = _resourceGraph.GetRelationships() @@ -407,7 +407,7 @@ public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_Ca public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSerialize() { // Arrange - var entity = new OneToManyPrincipal { Id = 2, Dependents = new List() }; + var entity = new OneToManyPrincipal { Id = 2, Dependents = new HashSet() }; var serializer = GetResponseSerializer(); var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); serializer.RequestRelationship = requestRelationship; @@ -426,7 +426,7 @@ public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSe public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_CanSerialize() { // Arrange - var entity = new OneToManyPrincipal { Id = 2, Dependents = new List { new OneToManyDependent { Id = 1 } } }; + var entity = new OneToManyPrincipal { Id = 2, Dependents = new HashSet { new OneToManyDependent { Id = 1 } } }; var serializer = GetResponseSerializer(); var requestRelationship = _resourceGraph.GetRelationships((OneToManyPrincipal t) => t.Dependents).First(); serializer.RequestRelationship = requestRelationship; diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs index df2e85feee..4482a08db5 100644 --- a/test/UnitTests/Services/EntityResourceService_Tests.cs +++ b/test/UnitTests/Services/EntityResourceService_Tests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Design; using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; @@ -37,7 +39,7 @@ public EntityResourceService_Tests() _pageService = new Mock(); _sortService = new Mock(); _filterService = new Mock(); - _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions()) + _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .AddResource() .AddResource() .Build(); @@ -124,7 +126,7 @@ private DefaultResourceService GetService() var options = new JsonApiOptions(); var changeTracker = new DefaultResourceChangeTracker(options, _resourceGraph, new TargetedFields()); - return new DefaultResourceService(queryParamServices, options, NullLoggerFactory.Instance, _repositoryMock.Object, _resourceGraph, changeTracker); + return new DefaultResourceService(queryParamServices, options, NullLoggerFactory.Instance, _repositoryMock.Object, _resourceGraph, changeTracker, new DefaultResourceFactory(new ServiceContainer())); } } } diff --git a/test/UnitTests/TestModels.cs b/test/UnitTests/TestModels.cs index 398a14b5d8..c05d99df92 100644 --- a/test/UnitTests/TestModels.cs +++ b/test/UnitTests/TestModels.cs @@ -57,7 +57,7 @@ public class OneToManyRequiredDependent : IdentifiableWithAttribute public sealed class OneToManyPrincipal : IdentifiableWithAttribute { - [HasMany] public List Dependents { get; set; } + [HasMany] public ISet Dependents { get; set; } } public class IdentifiableWithAttribute : Identifiable @@ -69,8 +69,8 @@ public sealed class MultipleRelationshipsPrincipalPart : IdentifiableWithAttribu { [HasOne] public OneToOneDependent PopulatedToOne { get; set; } [HasOne] public OneToOneDependent EmptyToOne { get; set; } - [HasMany] public List PopulatedToManies { get; set; } - [HasMany] public List EmptyToManies { get; set; } + [HasMany] public ISet PopulatedToManies { get; set; } + [HasMany] public ISet EmptyToManies { get; set; } [HasOne] public MultipleRelationshipsPrincipalPart Multi { get; set; } } @@ -98,7 +98,7 @@ public class Article : Identifiable public class Person : Identifiable { [Attr] public string Name { get; set; } - [HasMany] public List Blogs { get; set; } + [HasMany] public ISet Blogs { get; set; } [HasOne] public Food FavoriteFood { get; set; } [HasOne] public Song FavoriteSong { get; set; } }