diff --git a/src/Example.Api/Controllers/BooksController.cs b/src/Example.Api/Controllers/BooksController.cs new file mode 100644 index 0000000..c0950d8 --- /dev/null +++ b/src/Example.Api/Controllers/BooksController.cs @@ -0,0 +1,17 @@ +using Example.Api.Resources; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace Example.Api.Controllers +{ + public class BooksController : JsonApiController + { + public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Example.Api/Controllers/PeopleController.cs b/src/Example.Api/Controllers/PeopleController.cs new file mode 100644 index 0000000..d34f15e --- /dev/null +++ b/src/Example.Api/Controllers/PeopleController.cs @@ -0,0 +1,17 @@ +using Example.Api.Resources; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace Example.Api.Controllers +{ + public class PeopleController : JsonApiController + { + public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Example.Api/Data/AppDbContext.cs b/src/Example.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..0644912 --- /dev/null +++ b/src/Example.Api/Data/AppDbContext.cs @@ -0,0 +1,16 @@ +using Example.Api.Resources; +using Microsoft.EntityFrameworkCore; + +namespace Example.Api.Data +{ + public class AppDbContext : DbContext + { + public DbSet People { get; set; } + public DbSet Books { get; set; } + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/src/Example.Api/Definitions/BookDefinition.cs b/src/Example.Api/Definitions/BookDefinition.cs new file mode 100644 index 0000000..9de0b29 --- /dev/null +++ b/src/Example.Api/Definitions/BookDefinition.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using System.Linq; +using Example.Api.Resources; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace Example.Api.Definitions +{ + public class BookDefinition : JsonApiResourceDefinition + { + private readonly IResourceGraph _resourceGraph; + + public BookDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + _resourceGraph = resourceGraph; + } + + public override SortExpression OnApplySort(SortExpression existingSort) + { + if (existingSort != null) + { + return existingSort; + } + + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (book => book.Id, ListSortDirection.Ascending) + }); + } + + public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + return new QueryStringParameterHandlers + { + {"hide", (bookQuery, parameterValue) => GetBooksFilter(bookQuery, parameterValue)} + }; + } + + private IQueryable GetBooksFilter(IQueryable bookQuery, string parameterValue) + { + return parameterValue == "all" ? bookQuery.Where(_ => false) : bookQuery; + } + + public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + if (existingSparseFieldSet != null) + { + var visibleFields = existingSparseFieldSet.Fields + .Where(field => !field.Property.Name.StartsWith("Hidden")) + .ToList(); + + if (visibleFields.Any()) + { + return new SparseFieldSetExpression(visibleFields); + } + } + + return null; + } + } +} diff --git a/src/Example.Api/Example.Api.csproj b/src/Example.Api/Example.Api.csproj new file mode 100644 index 0000000..618a87d --- /dev/null +++ b/src/Example.Api/Example.Api.csproj @@ -0,0 +1,11 @@ + + + netcoreapp3.1 + + + + + + + + diff --git a/src/Example.Api/Program.cs b/src/Example.Api/Program.cs new file mode 100644 index 0000000..d6d5290 --- /dev/null +++ b/src/Example.Api/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Example.Api +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } + } +} diff --git a/src/Example.Api/Properties/launchSettings.json b/src/Example.Api/Properties/launchSettings.json new file mode 100644 index 0000000..fcf3f6b --- /dev/null +++ b/src/Example.Api/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17959", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/books", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/books", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Example.Api/Repositories/PersonRepository.cs b/src/Example.Api/Repositories/PersonRepository.cs new file mode 100644 index 0000000..1a2a4e1 --- /dev/null +++ b/src/Example.Api/Repositories/PersonRepository.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Example.Api.Resources; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace Example.Api.Repositories +{ + public class PersonRepository : EntityFrameworkCoreRepository + { + private readonly ILogger _logger; + + public PersonRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + protected override IQueryable GetAll() + { + _logger.LogDebug("Entering PersonRepository.GetAll"); + + return base.GetAll(); + } + } +} diff --git a/src/Example.Api/Resources/Book.cs b/src/Example.Api/Resources/Book.cs new file mode 100644 index 0000000..2a02bb1 --- /dev/null +++ b/src/Example.Api/Resources/Book.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Example.Api.Resources +{ + public class Book : Identifiable + { + [Attr] + [Required] + public string Title { get; set; } + + [Attr(PublicName = "synopsis", Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] + [MinLength(3)] + public string Summary { get; set; } + + [HasOne] + public Person Author { get; set; } + } +} diff --git a/src/Example.Api/Resources/Person.cs b/src/Example.Api/Resources/Person.cs new file mode 100644 index 0000000..b64cc8b --- /dev/null +++ b/src/Example.Api/Resources/Person.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Example.Api.Resources +{ + public class Person : Identifiable + { + [Attr] + public string FirstName { get; set; } + + [Attr] + [Required] + public string LastName { get; set; } + + [Attr(Capabilities = ~AttrCapabilities.AllowFilter)] + public DateTimeOffset BornAt { get; set; } + + [HasMany] + public IList Books { get; set; } + } +} diff --git a/src/Example.Api/Services/BookService.cs b/src/Example.Api/Services/BookService.cs new file mode 100644 index 0000000..68ffd10 --- /dev/null +++ b/src/Example.Api/Services/BookService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Example.Api.Resources; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace Example.Api.Services +{ + public class BookService : JsonApiResourceService + { + private readonly ILogger _logger; + + public BookService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { + _logger = loggerFactory.CreateLogger(); + } + + public override async Task> GetAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Entering BookService.GetAsync"); + + return await base.GetAsync(cancellationToken); + } + } +} diff --git a/src/Example.Api/Startup.cs b/src/Example.Api/Startup.cs new file mode 100644 index 0000000..03b4519 --- /dev/null +++ b/src/Example.Api/Startup.cs @@ -0,0 +1,90 @@ +using System; +using Example.Api.Data; +using Example.Api.Definitions; +using Example.Api.Repositories; +using Example.Api.Resources; +using Example.Api.Services; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Example.Api +{ + public class Startup + { + private const string DbConnectionString = + "Host=localhost;Port=5432;Database=JsonApiDotNetCoreMigrationExample;User ID=postgres;Password=postgres"; + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(options => + { + options.UseNpgsql(DbConnectionString); + }); + + services.AddJsonApi(options => + { + options.IncludeTotalResourceCount = true; + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.DefaultPageSize = new PageSize(10); + options.EnableLegacyFilterNotation = true; + options.TopLevelLinks = LinkTypes.Paging; + options.ResourceLinks = LinkTypes.None; + + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new KebabCaseNamingStrategy() + }; + }); + + services.AddResourceRepository(); + services.AddResourceService(); + services.AddScoped, BookDefinition>(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, AppDbContext appDbContext) + { + SeedSampleData(appDbContext); + + if (environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + + private static void SeedSampleData(AppDbContext appDbContext) + { + appDbContext.Database.EnsureDeleted(); + appDbContext.Database.EnsureCreated(); + + appDbContext.Books.Add(new Book + { + Title = "Gulliver's Travels", + Summary = "This book is about...", + Author = new Person + { + FirstName = "John", + LastName = "Doe", + BornAt = new DateTimeOffset(new DateTime(1993, 3, 29), TimeSpan.FromHours(3)) + } + }); + + appDbContext.SaveChanges(); + } + } +} diff --git a/src/Example.Api/appsettings.json b/src/Example.Api/appsettings.json new file mode 100644 index 0000000..14d98b7 --- /dev/null +++ b/src/Example.Api/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Warning", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Example.Tests/Example.Tests.csproj b/src/Example.Tests/Example.Tests.csproj new file mode 100644 index 0000000..ecdb0bf --- /dev/null +++ b/src/Example.Tests/Example.Tests.csproj @@ -0,0 +1,27 @@ + + + netcoreapp3.1 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + PreserveNewest + + + diff --git a/src/Example.Tests/ExampleTestFixture.cs b/src/Example.Tests/ExampleTestFixture.cs new file mode 100644 index 0000000..8e24e1b --- /dev/null +++ b/src/Example.Tests/ExampleTestFixture.cs @@ -0,0 +1,24 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Example.Api; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Example.Tests +{ + public abstract class ExampleTestFixture : IClassFixture> + { + private readonly WebApplicationFactory _factory; + + protected ExampleTestFixture(WebApplicationFactory factory) + { + _factory = factory; + } + + protected async Task ExecuteGetRequestAsync(string route) + { + var client = _factory.CreateClient(); + return await client.GetAsync(route); + } + } +} diff --git a/src/Example.Tests/HttpResponseMessageExtensions.cs b/src/Example.Tests/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..8fd9eef --- /dev/null +++ b/src/Example.Tests/HttpResponseMessageExtensions.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; + +namespace Example.Tests +{ + public static class HttpResponseMessageExtensions + { + public static void EnsureSuccessStatus(this HttpResponseMessage httpResponseMessage) + { + if (!httpResponseMessage.IsSuccessStatusCode) + { + var body = httpResponseMessage.Content.ReadAsStringAsync().Result; + var message = $"Failed with status '{(int) httpResponseMessage.StatusCode}' and body:\n<<{body}>>"; + + throw new Exception(message); + } + } + } +} diff --git a/src/Example.Tests/IntegrationTests/BookTests.cs b/src/Example.Tests/IntegrationTests/BookTests.cs new file mode 100644 index 0000000..6df623f --- /dev/null +++ b/src/Example.Tests/IntegrationTests/BookTests.cs @@ -0,0 +1,345 @@ +using System.Threading.Tasks; +using Example.Api; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; +using Xunit; + +namespace Example.Tests.IntegrationTests +{ + public class BookTests : ExampleTestFixture + { + public BookTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task Can_get_books() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""meta"": { + ""total-resources"": 1 + }, + ""links"": { + ""first"": ""/api/books"", + ""last"": ""/api/books"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_filter_books_by_title() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books?filter[title]=like:Gulliver"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""meta"": { + ""total-resources"": 1 + }, + ""links"": { + ""first"": ""/api/books?filter[title]=like:Gulliver"", + ""last"": ""/api/books?filter[title]=like:Gulliver"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_filter_books_by_custom_query() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books?hide=all"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""meta"": { + ""total-resources"": 0 + }, + ""data"": [] +}"); + } + + [Fact] + public async Task Can_get_book_by_ID() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + } + } + } + } +}"); + } + + [Fact] + public async Task Can_get_book_author() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1/author"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + } + } + } + } +}"); + } + + [Fact] + public async Task Can_get_book_author_relationship() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1/relationships/author"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"" + } +}"); + } + + [Fact] + public async Task Can_get_book_including_author() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1?include=author"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + }, + ""data"": { + ""type"": ""people"", + ""id"": ""1"" + } + } + } + }, + ""included"": [ + { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_book_including_author_last_name() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1?include=author&fields[people]=last-name"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + }, + ""data"": { + ""type"": ""people"", + ""id"": ""1"" + } + } + } + }, + ""included"": [ + { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""last-name"": ""Doe"" + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_book_title() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/1?fields[books]=title"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"" + } + } +}"); + } + + [Fact] + public async Task Cannot_get_missing_book() + { + // Act + var response = await ExecuteGetRequestAsync("/api/books/99999999"); + + // Assert + response.StatusCode.Should().Be(404); + + var responseBody = await response.Content.ReadAsStringAsync(); + + var responseDocument = JsonConvert.DeserializeObject(responseBody); + var errorId = responseDocument.Errors[0].Id; + + responseBody.Should().Be(@"{ + ""errors"": [ + { + ""id"": """ + errorId + @""", + ""status"": ""404"", + ""title"": ""The requested resource does not exist."", + ""detail"": ""Resource of type 'books' with ID '99999999' does not exist."" + } + ] +}"); + } + } +} diff --git a/src/Example.Tests/IntegrationTests/PersonTests.cs b/src/Example.Tests/IntegrationTests/PersonTests.cs new file mode 100644 index 0000000..8d86bf4 --- /dev/null +++ b/src/Example.Tests/IntegrationTests/PersonTests.cs @@ -0,0 +1,343 @@ +using System.Threading.Tasks; +using Example.Api; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; +using Xunit; + +namespace Example.Tests.IntegrationTests +{ + public class PersonTests : ExampleTestFixture + { + public PersonTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task Can_get_people() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""meta"": { + ""total-resources"": 1 + }, + ""links"": { + ""first"": ""/api/people"", + ""last"": ""/api/people"" + }, + ""data"": [ + { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_filter_people_by_last_name() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people?filter[last-name]=Doe"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""meta"": { + ""total-resources"": 1 + }, + ""links"": { + ""first"": ""/api/people?filter[last-name]=Doe"", + ""last"": ""/api/people?filter[last-name]=Doe"" + }, + ""data"": [ + { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_person_by_ID() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + } + } + } + } +}"); + } + + [Fact] + public async Task Can_get_person_books() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1/books"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""links"": { + ""first"": ""/api/people/1/books"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_person_books_relationship() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1/relationships/books"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""links"": { + ""first"": ""/api/people/1/relationships/books"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"" + } + ] +}"); + } + + [Fact] + public async Task Can_get_person_including_books() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1?include=books"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"" + } + ] + } + } + }, + ""included"": [ + { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"", + ""synopsis"": ""This book is about..."" + }, + ""relationships"": { + ""author"": { + ""links"": { + ""self"": ""/api/books/1/relationships/author"", + ""related"": ""/api/books/1/author"" + } + } + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_person_including_book_titles() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1?include=books&fields[books]=title"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""first-name"": ""John"", + ""last-name"": ""Doe"", + ""born-at"": ""1993-03-28T23:00:00+02:00"" + }, + ""relationships"": { + ""books"": { + ""links"": { + ""self"": ""/api/people/1/relationships/books"", + ""related"": ""/api/people/1/books"" + }, + ""data"": [ + { + ""type"": ""books"", + ""id"": ""1"" + } + ] + } + } + }, + ""included"": [ + { + ""type"": ""books"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""Gulliver's Travels"" + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_person_last_name() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/1?fields[people]=last-name"); + + // Assert + response.EnsureSuccessStatus(); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().Be(@"{ + ""data"": { + ""type"": ""people"", + ""id"": ""1"", + ""attributes"": { + ""last-name"": ""Doe"" + } + } +}"); + } + + [Fact] + public async Task Cannot_get_missing_person() + { + // Act + var response = await ExecuteGetRequestAsync("/api/people/99999999"); + + // Assert + response.StatusCode.Should().Be(404); + + var responseBody = await response.Content.ReadAsStringAsync(); + + var responseDocument = JsonConvert.DeserializeObject(responseBody); + var errorId = responseDocument.Errors[0].Id; + + responseBody.Should().Be(@"{ + ""errors"": [ + { + ""id"": """ + errorId + @""", + ""status"": ""404"", + ""title"": ""The requested resource does not exist."", + ""detail"": ""Resource of type 'people' with ID '99999999' does not exist."" + } + ] +}"); + } + } +} diff --git a/src/Example.Tests/xunit.runner.json b/src/Example.Tests/xunit.runner.json new file mode 100644 index 0000000..9db029b --- /dev/null +++ b/src/Example.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/src/Example.sln b/src/Example.sln new file mode 100644 index 0000000..b82d09a --- /dev/null +++ b/src/Example.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Api", "Example.Api\Example.Api.csproj", "{72B77FD1-749A-4DD3-8A89-A162B1DF2E2E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Tests", "Example.Tests\Example.Tests.csproj", "{E88898F7-A677-4959-9AED-84B64C1BAAEF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {72B77FD1-749A-4DD3-8A89-A162B1DF2E2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72B77FD1-749A-4DD3-8A89-A162B1DF2E2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72B77FD1-749A-4DD3-8A89-A162B1DF2E2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72B77FD1-749A-4DD3-8A89-A162B1DF2E2E}.Release|Any CPU.Build.0 = Release|Any CPU + {E88898F7-A677-4959-9AED-84B64C1BAAEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E88898F7-A677-4959-9AED-84B64C1BAAEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E88898F7-A677-4959-9AED-84B64C1BAAEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E88898F7-A677-4959-9AED-84B64C1BAAEF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4CD9E004-B621-490B-B084-B2B60FC2E962} + EndGlobalSection +EndGlobal