diff --git a/.vscode/launch.json b/.vscode/launch.json index 4dfa71e085..b26b008078 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,41 +1,11 @@ { "version": "0.2.0", "configurations": [ - { - "name": ".NET Core Launch (web)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceRoot}/src/JsonApiDotNetCoreExample/bin/Debug/netcoreapp1.0/JsonApiDotNetCoreExample.dll", - "args": [], - "cwd": "${workspaceRoot}/src/JsonApiDotNetCoreExample", - "stopAtEntry": false, - "launchBrowser": { - "enabled": false, - "args": "${auto-detect-url}", - "windows": { - "command": "cmd.exe", - "args": "/C start ${auto-detect-url}" - }, - "osx": { - "command": "open" - }, - "linux": { - "command": "xdg-open" - } - }, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sourceFileMap": { - "/Views": "${workspaceRoot}/Views" - } - }, { "name": ".NET Core Attach", "type": "coreclr", "request": "attach", - "processId": "${command.pickProcess}" + "processId": "${command:pickProcess}" } ] } \ No newline at end of file diff --git a/README.md b/README.md index 5fe174ae4d..e43b83d6f1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ JsonApiDotnetCore provides a framework for building [json:api](http://jsonapi.or - [Sorting](#sorting) - [Meta](#meta) - [Client Generated Ids](#client-generated-ids) + - [Custom Errors](#custom-errors) + - [Sparse Fieldsets](#sparse-fieldsets) - [Tests](#tests) ## Comprehensive Demo @@ -44,14 +46,14 @@ Install-Package JsonApiDotnetCore - project.json ```json -"JsonApiDotNetCore": "1.1.0" +"JsonApiDotNetCore": "1.2.0" ``` - *.csproj ```xml - + ``` @@ -326,6 +328,10 @@ Resources can be sorted by an attribute: ### Meta +Meta objects can be assigned in two ways: + - Resource meta + - Request Meta + Resource meta can be defined by implementing `IHasMeta` on the model class: ```csharp @@ -343,6 +349,9 @@ public class Person : Identifiable, IHasMeta } ``` +Request Meta can be added by injecting a service that implements `IRequestMeta`. +In the event of a key collision, the Request Meta will take precendence. + ### Client Generated Ids By default, the server will respond with a `403 Forbidden` HTTP Status Code if a `POST` request is @@ -357,6 +366,55 @@ services.AddJsonApi(opt => }); ``` +### Custom Errors + +By default, errors will only contain the properties defined by the internal [Error](https://github.com/Research-Institute/json-api-dotnet-core/blob/master/src/JsonApiDotNetCore/Internal/Error.cs) class. However, you can create your own by inheriting from `Error` and either throwing it in a `JsonApiException` or returning the error from your controller. + +```csharp +// custom error definition +public class CustomError : Error { + public CustomError(string status, string title, string detail, string myProp) + : base(status, title, detail) + { + MyCustomProperty = myProp; + } + public string MyCustomProperty { get; set; } +} + +// throwing a custom error +public void MyMethod() { + var error = new CustomError("507", "title", "detail", "custom"); + throw new JsonApiException(error); +} + +// returning from controller +[HttpPost] +public override async Task PostAsync([FromBody] MyEntity entity) +{ + if(_db.IsFull) + return new ObjectResult(new CustomError("507", "Database is full.", "Theres no more room.", "Sorry.")); + + // ... +} +``` + +### Sparse Fieldsets + +We currently support top-level field selection. +What this means is you can restrict which fields are returned by a query using the `fields` query parameter, but this does not yet apply to included relationships. + +- Currently valid: +```http +GET /articles?fields[articles]=title,body HTTP/1.1 +Accept: application/vnd.api+json +``` + +- Not yet supported: +```http +GET /articles?include=author&fields[articles]=title,body&fields[people]=name HTTP/1.1 +Accept: application/vnd.api+json +``` + ## Tests I am using DotNetCoreDocs to generate sample requests and documentation. diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 2df9eaca4f..92c785c5d0 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -8,10 +8,11 @@ namespace JsonApiDotNetCore.Builders { - public class DocumentBuilder + public class DocumentBuilder : IDocumentBuilder { private IJsonApiContext _jsonApiContext; private IContextGraph _contextGraph; + private readonly IRequestMeta _requestMeta; public DocumentBuilder(IJsonApiContext jsonApiContext) { @@ -19,18 +20,25 @@ public DocumentBuilder(IJsonApiContext jsonApiContext) _contextGraph = jsonApiContext.ContextGraph; } + public DocumentBuilder(IJsonApiContext jsonApiContext, IRequestMeta requestMeta) + { + _jsonApiContext = jsonApiContext; + _contextGraph = jsonApiContext.ContextGraph; + _requestMeta = requestMeta; + } + public Document Build(IIdentifiable entity) { var contextEntity = _contextGraph.GetContextEntity(entity.GetType()); var document = new Document { - Data = _getData(contextEntity, entity), - Meta = _getMeta(entity), + Data = GetData(contextEntity, entity), + Meta = GetMeta(entity), Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; - document.Included = _appendIncludedObject(document.Included, contextEntity, entity); + document.Included = AppendIncludedObject(document.Included, contextEntity, entity); return document; } @@ -46,39 +54,42 @@ public Documents Build(IEnumerable entities) var documents = new Documents { Data = new List(), - Meta = _getMeta(entities.FirstOrDefault()), + Meta = GetMeta(entities.FirstOrDefault()), Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; foreach (var entity in entities) { - documents.Data.Add(_getData(contextEntity, entity)); - documents.Included = _appendIncludedObject(documents.Included, contextEntity, entity); + documents.Data.Add(GetData(contextEntity, entity)); + documents.Included = AppendIncludedObject(documents.Included, contextEntity, entity); } return documents; } - private Dictionary _getMeta(IIdentifiable entity) + private Dictionary GetMeta(IIdentifiable entity) { if (entity == null) return null; - - var meta = new Dictionary(); - var metaEntity = entity as IHasMeta; - if(metaEntity != null) - meta = metaEntity.GetMeta(_jsonApiContext); + var builder = _jsonApiContext.MetaBuilder; + + if(entity is IHasMeta metaEntity) + builder.Add(metaEntity.GetMeta(_jsonApiContext)); if(_jsonApiContext.Options.IncludeTotalRecordCount) - meta["total-records"] = _jsonApiContext.PageManager.TotalRecords; + builder.Add("total-records", _jsonApiContext.PageManager.TotalRecords); + if(_requestMeta != null) + builder.Add(_requestMeta.GetMeta()); + + var meta = builder.Build(); if(meta.Count > 0) return meta; return null; } - private List _appendIncludedObject(List includedObject, ContextEntity contextEntity, IIdentifiable entity) + private List AppendIncludedObject(List includedObject, ContextEntity contextEntity, IIdentifiable entity) { - var includedEntities = _getIncludedEntities(contextEntity, entity); + var includedEntities = GetIncludedEntities(contextEntity, entity); if (includedEntities.Count > 0) { if (includedObject == null) @@ -89,7 +100,7 @@ private List _appendIncludedObject(List includedObje return includedObject; } - private DocumentData _getData(ContextEntity contextEntity, IIdentifiable entity) + private DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity) { var data = new DocumentData { @@ -104,16 +115,24 @@ private DocumentData _getData(ContextEntity contextEntity, IIdentifiable entity) contextEntity.Attributes.ForEach(attr => { - data.Attributes.Add(attr.PublicAttributeName, attr.GetValue(entity)); + if(ShouldIncludeAttribute(attr)) + data.Attributes.Add(attr.PublicAttributeName, attr.GetValue(entity)); }); if (contextEntity.Relationships.Count > 0) - _addRelationships(data, contextEntity, entity); + AddRelationships(data, contextEntity, entity); return data; } - private void _addRelationships(DocumentData data, ContextEntity contextEntity, IIdentifiable entity) + private bool ShouldIncludeAttribute(AttrAttribute attr) + { + return (_jsonApiContext.QuerySet == null + || _jsonApiContext.QuerySet.Fields.Count == 0 + || _jsonApiContext.QuerySet.Fields.Contains(attr.InternalAttributeName)); + } + + private void AddRelationships(DocumentData data, ContextEntity contextEntity, IIdentifiable entity) { data.Relationships = new Dictionary(); var linkBuilder = new LinkBuilder(_jsonApiContext); @@ -129,7 +148,7 @@ private void _addRelationships(DocumentData data, ContextEntity contextEntity, I } }; - if (_relationshipIsIncluded(r.InternalRelationshipName)) + if (RelationshipIsIncluded(r.InternalRelationshipName)) { var navigationEntity = _jsonApiContext.ContextGraph .GetRelationship(entity, r.InternalRelationshipName); @@ -137,46 +156,49 @@ private void _addRelationships(DocumentData data, ContextEntity contextEntity, I if(navigationEntity == null) relationshipData.SingleData = null; else if (navigationEntity is IEnumerable) - relationshipData.ManyData = _getRelationships((IEnumerable)navigationEntity, r.InternalRelationshipName); + relationshipData.ManyData = GetRelationships((IEnumerable)navigationEntity, r.InternalRelationshipName); else - relationshipData.SingleData = _getRelationship(navigationEntity, r.InternalRelationshipName); + relationshipData.SingleData = GetRelationship(navigationEntity, r.InternalRelationshipName); } data.Relationships.Add(r.InternalRelationshipName.Dasherize(), relationshipData); }); } - private List _getIncludedEntities(ContextEntity contextEntity, IIdentifiable entity) + private List GetIncludedEntities(ContextEntity contextEntity, IIdentifiable entity) { var included = new List(); contextEntity.Relationships.ForEach(r => { - if (!_relationshipIsIncluded(r.InternalRelationshipName)) return; + if (!RelationshipIsIncluded(r.InternalRelationshipName)) return; var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, r.InternalRelationshipName); if (navigationEntity is IEnumerable) foreach (var includedEntity in (IEnumerable)navigationEntity) - included.Add(_getIncludedEntity((IIdentifiable)includedEntity)); + AddIncludedEntity(included, (IIdentifiable)includedEntity); else - included.Add(_getIncludedEntity((IIdentifiable)navigationEntity)); + AddIncludedEntity(included, (IIdentifiable)navigationEntity); }); return included; } - private DocumentData _getIncludedEntity(IIdentifiable entity) + private void AddIncludedEntity(List entities, IIdentifiable entity) + { + var includedEntity = GetIncludedEntity(entity); + if(includedEntity != null) + entities.Add(includedEntity); + } + + private DocumentData GetIncludedEntity(IIdentifiable entity) { if(entity == null) return null; var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(entity.GetType()); - var data = new DocumentData - { - Type = contextEntity.EntityName, - Id = entity.StringId - }; + var data = GetData(contextEntity, entity); data.Attributes = new Dictionary(); @@ -188,13 +210,13 @@ private DocumentData _getIncludedEntity(IIdentifiable entity) return data; } - private bool _relationshipIsIncluded(string relationshipName) + private bool RelationshipIsIncluded(string relationshipName) { return _jsonApiContext.IncludedRelationships != null && _jsonApiContext.IncludedRelationships.Contains(relationshipName.ToProperCase()); } - private List> _getRelationships(IEnumerable entities, string relationshipName) + private List> GetRelationships(IEnumerable entities, string relationshipName) { var objType = entities.GetType().GenericTypeArguments[0]; @@ -210,7 +232,7 @@ private List> _getRelationships(IEnumerable e } return relationships; } - private Dictionary _getRelationship(object entity, string relationshipName) + private Dictionary GetRelationship(object entity, string relationshipName) { var objType = entity.GetType(); diff --git a/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs new file mode 100644 index 0000000000..8fe5c65ae9 --- /dev/null +++ b/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Builders +{ + public interface IDocumentBuilder + { + Document Build(IIdentifiable entity); + Documents Build(IEnumerable entities); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Builders/IMetaBuilder.cs b/src/JsonApiDotNetCore/Builders/IMetaBuilder.cs new file mode 100644 index 0000000000..bf35b9d210 --- /dev/null +++ b/src/JsonApiDotNetCore/Builders/IMetaBuilder.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Builders +{ + public interface IMetaBuilder + { + void Add(string key, object value); + void Add(Dictionary values); + Dictionary Build(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Builders/MetaBuilder.cs b/src/JsonApiDotNetCore/Builders/MetaBuilder.cs new file mode 100644 index 0000000000..14b80321f6 --- /dev/null +++ b/src/JsonApiDotNetCore/Builders/MetaBuilder.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Builders +{ + public class MetaBuilder : IMetaBuilder + { + private Dictionary _meta = new Dictionary(); + + public void Add(string key, object value) + { + _meta[key] = value; + } + + /// + /// Joins the new dictionary with the current one. In the event of a key collision, + /// the new value will override the old. + /// + public void Add(Dictionary values) + { + _meta = values.Keys.Union(_meta.Keys) + .ToDictionary(key => key, + key => values.ContainsKey(key) ? values[key] : _meta[key]); + } + + public Dictionary Build() + { + return _meta; + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 5a44bb076c..62102bec7b 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -32,6 +32,7 @@ public class DefaultEntityRepository private readonly DbSet _dbSet; private readonly ILogger _logger; private readonly IJsonApiContext _jsonApiContext; + private readonly IGenericProcessorFactory _genericProcessorFactory; public DefaultEntityRepository( DbContext context, @@ -42,11 +43,12 @@ public DefaultEntityRepository( _dbSet = context.GetDbSet(); _jsonApiContext = jsonApiContext; _logger = loggerFactory.CreateLogger>(); + _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; } public virtual IQueryable Get() { - return _dbSet; + return _dbSet.Select(_jsonApiContext.QuerySet?.Fields); } public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) @@ -74,12 +76,12 @@ public virtual IQueryable Sort(IQueryable entities, List GetAsync(TId id) { - return await _dbSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); + return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id)); } public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { - return await _dbSet + return await Get() .Include(relationshipName) .SingleOrDefaultAsync(e => e.Id.Equals(id)); } @@ -103,14 +105,17 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) attr.SetValue(oldEntity, attr.GetValue(entity)); }); + foreach(var relationship in _jsonApiContext.RelationshipsToUpdate) + relationship.Key.SetValue(oldEntity, relationship.Value); + await _context.SaveChangesAsync(); - return oldEntity; + return oldEntity; } public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { - var genericProcessor = GenericProcessorFactory.GetProcessor(relationship.Type, _context); + var genericProcessor = _genericProcessorFactory.GetProcessor(relationship.Type); await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 2e2551603c..abd1686a22 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -1,5 +1,5 @@ - using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -126,5 +126,30 @@ public static IQueryable Filter(this IQueryable sourc throw new JsonApiException("400", $"Could not cast {filterQuery.PropertyValue} to {property.PropertyType.Name}"); } } + public static IQueryable Select(this IQueryable source, IEnumerable columns) + { + if(columns == null || columns.Count() == 0) + return source; + + var sourceType = source.ElementType; + + var resultType = typeof(TSource); + + // {model} + var parameter = Expression.Parameter(sourceType, "model"); + + var bindings = columns.Select(column => Expression.Bind( + resultType.GetProperty(column), Expression.PropertyOrField(parameter, column))); + + // { new Model () { Property = model.Property } } + var body = Expression.MemberInit(Expression.New(resultType), bindings); + + // { model => new TodoItem() { Property = model.Property } } + var selector = Expression.Lambda(body, parameter); + + return source.Provider.CreateQuery( + Expression.Call(typeof(Queryable), "Select", new Type[] { sourceType, resultType }, + source.Expression, Expression.Quote(selector))); + } } } diff --git a/src/JsonApiDotNetCore/Extensions/ServiceProviderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs similarity index 79% rename from src/JsonApiDotNetCore/Extensions/ServiceProviderExtensions.cs rename to src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 38face7307..1e646c085d 100644 --- a/src/JsonApiDotNetCore/Extensions/ServiceProviderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using System; +using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,7 +13,7 @@ namespace JsonApiDotNetCore.Extensions { - public static class ServiceProviderExtensions + public static class IServiceCollectionExtensions { public static void AddJsonApi(this IServiceCollection services) where TContext : DbContext @@ -54,6 +56,15 @@ public static void AddJsonApiInternals(this IServiceCollection service services.AddSingleton(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(typeof(GenericProcessor<>)); } public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions jsonApiOptions) diff --git a/src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs new file mode 100644 index 0000000000..5b64cc42bf --- /dev/null +++ b/src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace JsonApiDotNetCore.Formatters +{ + public interface IJsonApiReader + { + Task ReadAsync(InputFormatterContext context); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs new file mode 100644 index 0000000000..ce8b7da6a4 --- /dev/null +++ b/src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace JsonApiDotNetCore.Formatters +{ + public interface IJsonApiWriter + { + Task WriteAsync(OutputFormatterWriteContext context); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index c4f1692eaa..12e57deadf 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -1,13 +1,7 @@ using System; -using System.IO; using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Formatters { @@ -23,55 +17,10 @@ public bool CanRead(InputFormatterContext context) return contentTypeString == "application/vnd.api+json"; } - public Task ReadAsync(InputFormatterContext context) + public async Task ReadAsync(InputFormatterContext context) { - if (context == null) - throw new ArgumentNullException(nameof(context)); - - var request = context.HttpContext.Request; - - if (request.ContentLength == 0) - { - return InputFormatterResult.SuccessAsync(null); - } - - var loggerFactory = GetService(context); - var logger = loggerFactory?.CreateLogger(); - - var dbContext = GetService(context); - - try - { - var body = GetRequestBody(context.HttpContext.Request.Body); - var jsonApiContext = GetService(context); - var model = jsonApiContext.IsRelationshipPath ? - JsonApiDeSerializer.DeserializeRelationship(body, jsonApiContext) : - JsonApiDeSerializer.Deserialize(body, jsonApiContext, dbContext); - - if(model == null) - logger?.LogError("An error occurred while de-serializing the payload"); - - return InputFormatterResult.SuccessAsync(model); - } - catch (JsonSerializationException ex) - { - logger?.LogError(new EventId(), ex, "An error occurred while de-serializing the payload"); - context.HttpContext.Response.StatusCode = 422; - return InputFormatterResult.FailureAsync(); - } - } - - private string GetRequestBody(Stream body) - { - using (var reader = new StreamReader(body)) - { - return reader.ReadToEnd(); - } - } - - private TService GetService(InputFormatterContext context) - { - return context.HttpContext.RequestServices.GetService(); + var reader = context.HttpContext.RequestServices.GetService(); + return await reader.ReadAsync(context); } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index 95e607fca5..2431055d1d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -1,14 +1,7 @@ using System; -using System.Text; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace JsonApiDotNetCore.Formatters { @@ -26,76 +19,8 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) public async Task WriteAsync(OutputFormatterWriteContext context) { - if (context == null) - throw new ArgumentNullException(nameof(context)); - - var logger = GetService(context)? - .CreateLogger(); - - logger?.LogInformation("Formatting response as JSONAPI"); - - var response = context.HttpContext.Response; - using (var writer = context.WriterFactory(response.Body, Encoding.UTF8)) - { - var jsonApiContext = GetService(context); - - response.ContentType = "application/vnd.api+json"; - string responseContent; - try - { - responseContent = GetResponseBody(context.Object, jsonApiContext, logger); - } - catch (Exception e) - { - logger?.LogError(new EventId(), e, "An error ocurred while formatting the response"); - var errors = new ErrorCollection(); - errors.Add(new Error("400", e.Message)); - responseContent = errors.GetJson(); - response.StatusCode = 400; - } - - await writer.WriteAsync(responseContent); - await writer.FlushAsync(); - } - } - - private T GetService(OutputFormatterWriteContext context) - { - return context.HttpContext.RequestServices.GetService(); - } - - private string GetResponseBody(object responseObject, IJsonApiContext jsonApiContext, ILogger logger) - { - if (responseObject == null) - return GetNullDataResponse(); - - if (responseObject.GetType() == typeof(Error) || jsonApiContext.RequestEntity == null) - return GetErrorJson(responseObject, logger); - - return JsonApiSerializer.Serialize(responseObject, jsonApiContext); - } - - private string GetNullDataResponse() - { - return JsonConvert.SerializeObject(new Document - { - Data = null - }); - } - - private string GetErrorJson(object responseObject, ILogger logger) - { - if (responseObject.GetType() == typeof(Error)) - { - var errors = new ErrorCollection(); - errors.Add((Error)responseObject); - return errors.GetJson(); - } - else - { - logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); - return JsonConvert.SerializeObject(responseObject); - } + var writer = context.HttpContext.RequestServices.GetService(); + await writer.WriteAsync(context); } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs new file mode 100644 index 0000000000..65a2382a73 --- /dev/null +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Formatters +{ + public class JsonApiReader : IJsonApiReader + { + private readonly IJsonApiDeSerializer _deSerializer; + private readonly IJsonApiContext _jsonApiContext; + private readonly ILogger _logger; + + + public JsonApiReader(IJsonApiDeSerializer deSerializer, IJsonApiContext jsonApiContext, ILoggerFactory loggerFactory) + { + _deSerializer = deSerializer; + _jsonApiContext = jsonApiContext; + _logger = loggerFactory.CreateLogger(); + } + + public Task ReadAsync(InputFormatterContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var request = context.HttpContext.Request; + if (request.ContentLength == 0) + return InputFormatterResult.SuccessAsync(null); + + try + { + var body = GetRequestBody(context.HttpContext.Request.Body); + var model = _jsonApiContext.IsRelationshipPath ? + _deSerializer.DeserializeRelationship(body) : + _deSerializer.Deserialize(body); + + if(model == null) + _logger?.LogError("An error occurred while de-serializing the payload"); + + return InputFormatterResult.SuccessAsync(model); + } + catch (JsonSerializationException ex) + { + _logger?.LogError(new EventId(), ex, "An error occurred while de-serializing the payload"); + context.HttpContext.Response.StatusCode = 422; + return InputFormatterResult.FailureAsync(); + } + } + + private string GetRequestBody(Stream body) + { + using (var reader = new StreamReader(body)) + { + return reader.ReadToEnd(); + } + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs new file mode 100644 index 0000000000..730a88f13e --- /dev/null +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -0,0 +1,58 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Serialization; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Formatters +{ + public class JsonApiWriter : IJsonApiWriter + { + private readonly ILogger _logger; + private readonly IJsonApiSerializer _serializer; + + public JsonApiWriter(IJsonApiSerializer serializer, + ILoggerFactory loggerFactory) + { + _serializer = serializer; + _logger = loggerFactory.CreateLogger(); + } + + public async Task WriteAsync(OutputFormatterWriteContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + _logger?.LogInformation("Formatting response as JSONAPI"); + + var response = context.HttpContext.Response; + using (var writer = context.WriterFactory(response.Body, Encoding.UTF8)) + { + response.ContentType = "application/vnd.api+json"; + string responseContent; + try + { + responseContent = GetResponseBody(context.Object); + } + catch (Exception e) + { + _logger?.LogError(new EventId(), e, "An error ocurred while formatting the response"); + var errors = new ErrorCollection(); + errors.Add(new Error("400", e.Message)); + responseContent = errors.GetJson(); + response.StatusCode = 400; + } + + await writer.WriteAsync(responseContent); + await writer.FlushAsync(); + } + } + + private string GetResponseBody(object responseObject) + { + return _serializer.Serialize(responseObject); + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Error.cs b/src/JsonApiDotNetCore/Internal/Error.cs index 01c4a26de0..b9261324a5 100644 --- a/src/JsonApiDotNetCore/Internal/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Error.cs @@ -28,5 +28,8 @@ public Error(string status, string title, string detail) [JsonProperty("status")] public string Status { get; set; } + + [JsonIgnore] + public int StatusCode { get { return int.Parse(Status); } } } } diff --git a/src/JsonApiDotNetCore/Internal/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/ErrorCollection.cs index 6e5c375da1..bf0375843d 100644 --- a/src/JsonApiDotNetCore/Internal/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Internal/ErrorCollection.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Internal { @@ -20,7 +21,8 @@ public void Add(Error error) public string GetJson() { return JsonConvert.SerializeObject(this, new JsonSerializerSettings { - NullValueHandling = NullValueHandling.Ignore + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new CamelCasePropertyNamesContractResolver() }); } } diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs index 24a963599a..7a647bf60f 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs @@ -3,17 +3,22 @@ namespace JsonApiDotNetCore.Internal { - /// - /// Used to generate a generic operations processor when the types - /// are not know until runtime. The typical use case would be for - /// accessing relationship data. - /// - public static class GenericProcessorFactory + public class GenericProcessorFactory : IGenericProcessorFactory { - public static IGenericProcessor GetProcessor(Type type, DbContext dbContext) + private readonly DbContext _dbContext; + private readonly IServiceProvider _serviceProvider; + + public GenericProcessorFactory(DbContext dbContext, + IServiceProvider serviceProvider) + { + _dbContext = dbContext; + _serviceProvider = serviceProvider; + } + + public IGenericProcessor GetProcessor(Type type) { - var repositoryType = typeof(GenericProcessor<>).MakeGenericType(type); - return (IGenericProcessor)Activator.CreateInstance(repositoryType, dbContext); + var processorType = typeof(GenericProcessor<>).MakeGenericType(type); + return (IGenericProcessor)_serviceProvider.GetService(processorType); } } } diff --git a/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessorFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessorFactory.cs new file mode 100644 index 0000000000..ce959658c4 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessorFactory.cs @@ -0,0 +1,14 @@ +using System; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// Used to generate a generic operations processor when the types + /// are not know until runtime. The typical use case would be for + /// accessing relationship data. + /// + public interface IGenericProcessorFactory + { + IGenericProcessor GetProcessor(Type type); + } +} diff --git a/src/JsonApiDotNetCore/Internal/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/JsonApiException.cs index e5ecb56fd1..907b1db7fd 100644 --- a/src/JsonApiDotNetCore/Internal/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/JsonApiException.cs @@ -1,31 +1,52 @@ using System; +using System.Linq; namespace JsonApiDotNetCore.Internal { public class JsonApiException : Exception { - private string _statusCode; - private string _detail; - private string _message; + private ErrorCollection _errors = new ErrorCollection(); + + public JsonApiException(ErrorCollection errorCollection) + { + _errors = errorCollection; + } + + public JsonApiException(Error error) + : base(error.Title) + { + _errors.Add(error); + } public JsonApiException(string statusCode, string message) : base(message) { - _statusCode = statusCode; - _message = message; + _errors.Add(new Error(statusCode, message, null)); } public JsonApiException(string statusCode, string message, string detail) : base(message) { - _statusCode = statusCode; - _message = message; - _detail = detail; + _errors.Add(new Error(statusCode, message, detail)); } - public Error GetError() + public ErrorCollection GetError() { - return new Error(_statusCode, _message, _detail); + return _errors; + } + + public int GetStatusCode() + { + if(_errors.Errors.Count == 1) + return _errors.Errors[0].StatusCode; + + if(_errors.Errors.FirstOrDefault(e => e.StatusCode >= 500) != null) + return 500; + + if(_errors.Errors.FirstOrDefault(e => e.StatusCode >= 400) != null) + return 400; + + return 500; } } } diff --git a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs index 864251e4a4..58f1c189f1 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs @@ -19,6 +19,7 @@ public QuerySet( _jsonApiContext = jsonApiContext; PageQuery = new PageQuery(); Filters = new List(); + Fields = new List(); BuildQuerySet(query); } @@ -26,6 +27,7 @@ public QuerySet( public PageQuery PageQuery { get; set; } public List SortParameters { get; set; } public List IncludedRelationships { get; set; } + public List Fields { get; set; } private void BuildQuerySet(IQueryCollection query) { @@ -55,6 +57,12 @@ private void BuildQuerySet(IQueryCollection query) continue; } + if (pair.Key.StartsWith("fields")) + { + Fields = ParseFieldsQuery(pair.Key, pair.Value); + continue; + } + throw new JsonApiException("400", $"{pair} is not a valid query."); } } @@ -160,6 +168,29 @@ private List ParseIncludedRelationships(string value) .ToList(); } + private List ParseFieldsQuery(string key, string value) + { + // expected: fields[TYPE]=prop1,prop2 + var typeName = key.Split('[', ']')[1]; + + var includedFields = new List { "Id" }; + + if(typeName != _jsonApiContext.RequestEntity.EntityName.Dasherize()) + return includedFields; + + var fields = value.Split(','); + foreach(var field in fields) + { + var internalAttrName = _jsonApiContext.RequestEntity + .Attributes + .SingleOrDefault(attr => attr.PublicAttributeName == field) + .InternalAttributeName; + includedFields.Add(internalAttrName); + } + + return includedFields; + } + private AttrAttribute GetAttribute(string propertyName) { return _jsonApiContext.RequestEntity.Attributes diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9f0d438389..a222271081 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,7 +1,7 @@  - 1.1.1 + 1.2.0 netcoreapp1.0 JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs index 479a947e5e..ee038e7902 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs @@ -23,7 +23,7 @@ public void OnException(ExceptionContext context) var error = jsonApiException.GetError(); var result = new ObjectResult(error); - result.StatusCode = Convert.ToInt16(error.Status); + result.StatusCode = jsonApiException.GetStatusCode(); context.Result = result; } } diff --git a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs index 13e4a9efad..445b82c22a 100644 --- a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs @@ -1,4 +1,4 @@ -using System; +using System.Reflection; namespace JsonApiDotNetCore.Models { @@ -9,5 +9,14 @@ public HasManyAttribute(string publicName) { PublicRelationshipName = publicName; } + + public override void SetValue(object entity, object newValue) + { + var propertyInfo = entity + .GetType() + .GetProperty(InternalRelationshipName); + + propertyInfo.SetValue(entity, newValue); + } } } diff --git a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs index e5670eae29..29661de485 100644 --- a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -1,3 +1,5 @@ +using System.Reflection; + namespace JsonApiDotNetCore.Models { public class HasOneAttribute : RelationshipAttribute @@ -7,5 +9,18 @@ public HasOneAttribute(string publicName) { PublicRelationshipName = publicName; } + + public override void SetValue(object entity, object newValue) + { + var propertyName = (newValue.GetType() == Type) + ? InternalRelationshipName + : $"{InternalRelationshipName}Id"; + + var propertyInfo = entity + .GetType() + .GetProperty(propertyName); + + propertyInfo.SetValue(entity, newValue); + } } } diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index 45b3565592..5e02eaf1ef 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -1,9 +1,8 @@ using System; -using System.Reflection; namespace JsonApiDotNetCore.Models { - public class RelationshipAttribute : Attribute + public abstract class RelationshipAttribute : Attribute { protected RelationshipAttribute(string publicName) { @@ -16,13 +15,6 @@ protected RelationshipAttribute(string publicName) public bool IsHasMany { get { return this.GetType() == typeof(HasManyAttribute); } } public bool IsHasOne { get { return this.GetType() == typeof(HasOneAttribute); } } - public void SetValue(object entity, object newValue) - { - var propertyInfo = entity - .GetType() - .GetProperty(InternalRelationshipName); - - propertyInfo.SetValue(entity, newValue); - } + public abstract void SetValue(object entity, object newValue); } } diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs new file mode 100644 index 0000000000..02f84a747a --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Serialization +{ + public interface IJsonApiDeSerializer + { + object Deserialize(string requestBody); + object DeserializeRelationship(string requestBody); + List DeserializeList(string requestBody); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs new file mode 100644 index 0000000000..21eae09980 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCore.Serialization +{ + public interface IJsonApiSerializer + { + string Serialize(object entity); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 59772d9a76..367904f8dd 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -8,23 +8,33 @@ using JsonApiDotNetCore.Services; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Collections; -using JsonApiDotNetCore.Data; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Serialization { - public static class JsonApiDeSerializer + public class JsonApiDeSerializer : IJsonApiDeSerializer { - public static object Deserialize(string requestBody, IJsonApiContext context, - DbContext dbContext) + private readonly DbContext _dbContext; + private readonly IJsonApiContext _jsonApiContext; + private readonly IGenericProcessorFactory _genericProcessorFactor; + + public JsonApiDeSerializer(DbContext dbContext, + IJsonApiContext jsonApiContext, + IGenericProcessorFactory genericProcessorFactory) + { + _dbContext = dbContext; + _jsonApiContext = jsonApiContext; + _genericProcessorFactor = genericProcessorFactory; + } + + public object Deserialize(string requestBody) { var document = JsonConvert.DeserializeObject(requestBody); - var entity = DataToObject(document.Data, context, dbContext); + var entity = DataToObject(document.Data); return entity; } - public static object DeserializeRelationship(string requestBody, IJsonApiContext context) + public object DeserializeRelationship(string requestBody) { var data = JToken.Parse(requestBody)["data"]; @@ -35,34 +45,31 @@ public static object DeserializeRelationship(string requestBody, IJsonApiContext } - public static List DeserializeList(string requestBody, IJsonApiContext context, - DbContext dbContext) + public List DeserializeList(string requestBody) { var documents = JsonConvert.DeserializeObject(requestBody); var deserializedList = new List(); foreach (var data in documents.Data) { - var entity = DataToObject(data, context, dbContext); + var entity = DataToObject(data); deserializedList.Add((TEntity)entity); } return deserializedList; } - private static object DataToObject(DocumentData data, - IJsonApiContext context, - DbContext dbContext) + private object DataToObject(DocumentData data) { var entityTypeName = data.Type.ToProperCase(); - var contextEntity = context.ContextGraph.GetContextEntity(entityTypeName); - context.RequestEntity = contextEntity; + var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(entityTypeName); + _jsonApiContext.RequestEntity = contextEntity; var entity = Activator.CreateInstance(contextEntity.EntityType); entity = _setEntityAttributes(entity, contextEntity, data.Attributes); - entity = _setRelationships(entity, contextEntity, data.Relationships, dbContext); + entity = _setRelationships(entity, contextEntity, data.Relationships); var identifiableEntity = (IIdentifiable)entity; @@ -72,7 +79,7 @@ private static object DataToObject(DocumentData data, return identifiableEntity; } - private static object _setEntityAttributes( + private object _setEntityAttributes( object entity, ContextEntity contextEntity, Dictionary attributeValues) { if (attributeValues == null || attributeValues.Count == 0) @@ -98,11 +105,10 @@ private static object _setEntityAttributes( return entity; } - private static object _setRelationships( + private object _setRelationships( object entity, ContextEntity contextEntity, - Dictionary relationships, - DbContext context) + Dictionary relationships) { if (relationships == null || relationships.Count == 0) return entity; @@ -114,13 +120,13 @@ private static object _setRelationships( if (attr.IsHasOne) entity = _setHasOneRelationship(entity, entityProperties, attr, contextEntity, relationships); else - entity = _setHasManyRelationship(entity, entityProperties, attr, contextEntity, relationships, context); + entity = _setHasManyRelationship(entity, entityProperties, attr, contextEntity, relationships); } return entity; } - private static object _setHasOneRelationship(object entity, + private object _setHasOneRelationship(object entity, PropertyInfo[] entityProperties, RelationshipAttribute attr, ContextEntity contextEntity, @@ -135,24 +141,29 @@ private static object _setHasOneRelationship(object entity, if (relationships.TryGetValue(relationshipName, out RelationshipData relationshipData)) { + var relationshipAttr = _jsonApiContext.RequestEntity.Relationships + .SingleOrDefault(r => r.PublicRelationshipName == relationshipName); + var data = (Dictionary)relationshipData.ExposedData; if (data == null) return entity; var newValue = data["id"]; var convertedValue = TypeHelper.ConvertType(newValue, entityProperty.PropertyType); + + _jsonApiContext.RelationshipsToUpdate[relationshipAttr] = convertedValue; + entityProperty.SetValue(entity, convertedValue); } return entity; } - private static object _setHasManyRelationship(object entity, + private object _setHasManyRelationship(object entity, PropertyInfo[] entityProperties, RelationshipAttribute attr, ContextEntity contextEntity, - Dictionary relationships, - DbContext context) + Dictionary relationships) { var entityProperty = entityProperties.FirstOrDefault(p => p.Name == attr.InternalRelationshipName); @@ -167,7 +178,7 @@ private static object _setHasManyRelationship(object entity, if (data == null) return entity; - var genericProcessor = GenericProcessorFactory.GetProcessor(attr.Type, context); + var genericProcessor = _genericProcessorFactor.GetProcessor(attr.Type); var ids = relationshipData.ManyData.Select(r => r["id"]); genericProcessor.SetRelationships(entity, attr, ids); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializer.cs index 1fedd019a1..e4fb140d5b 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiSerializer.cs @@ -1,37 +1,87 @@ using System.Collections.Generic; using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization { - public static class JsonApiSerializer + public class JsonApiSerializer : IJsonApiSerializer { - public static string Serialize(object entity, IJsonApiContext jsonApiContext) + private readonly IDocumentBuilder _documentBuilder; + private readonly ILogger _logger; + private readonly IJsonApiContext _jsonApiContext; + + public JsonApiSerializer( + IJsonApiContext jsonApiContext, + IDocumentBuilder documentBuilder) { + _jsonApiContext = jsonApiContext; + _documentBuilder = documentBuilder; + } + + public JsonApiSerializer( + IJsonApiContext jsonApiContext, + IDocumentBuilder documentBuilder, + ILoggerFactory loggerFactory) + { + _jsonApiContext = jsonApiContext; + _documentBuilder = documentBuilder; + _logger = loggerFactory?.CreateLogger(); + } + + public string Serialize(object entity) + { + if (entity == null) + return GetNullDataResponse(); + + if (entity.GetType() == typeof(ErrorCollection) || _jsonApiContext.RequestEntity == null) + return GetErrorJson(entity, _logger); + if (entity is IEnumerable) - return _serializeDocuments(entity, jsonApiContext); - return _serializeDocument(entity, jsonApiContext); + return SerializeDocuments(entity); + + return SerializeDocument(entity); + } + + private string GetNullDataResponse() + { + return JsonConvert.SerializeObject(new Document + { + Data = null + }); + } + + private string GetErrorJson(object responseObject, ILogger logger) + { + if (responseObject is ErrorCollection errorCollection) + { + return errorCollection.GetJson(); + } + else + { + logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); + return JsonConvert.SerializeObject(responseObject); + } } - private static string _serializeDocuments(object entity, IJsonApiContext jsonApiContext) + private string SerializeDocuments(object entity) { - var documentBuilder = new DocumentBuilder(jsonApiContext); var entities = entity as IEnumerable; - var documents = documentBuilder.Build(entities); + var documents = _documentBuilder.Build(entities); return _serialize(documents); } - private static string _serializeDocument(object entity, IJsonApiContext jsonApiContext) + private string SerializeDocument(object entity) { - var documentBuilder = new DocumentBuilder(jsonApiContext); var identifiableEntity = entity as IIdentifiable; - var document = documentBuilder.Build(identifiableEntity); + var document = _documentBuilder.Build(identifiableEntity); return _serialize(document); } - private static string _serialize(object obj) + private string _serialize(object obj) { return JsonConvert.SerializeObject(obj, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore diff --git a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs index 1757109b71..2860c3eb74 100644 --- a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; +using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Services { @@ -17,6 +19,8 @@ public interface IJsonApiContext List IncludedRelationships { get; set; } bool IsRelationshipPath { get; } PageManager PageManager { get; set; } - + IMetaBuilder MetaBuilder { get; set; } + IGenericProcessorFactory GenericProcessorFactory { get; set; } + Dictionary RelationshipsToUpdate { get; set; } } } diff --git a/src/JsonApiDotNetCore/Services/IRequestMeta.cs b/src/JsonApiDotNetCore/Services/IRequestMeta.cs new file mode 100644 index 0000000000..7dd5fdcada --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IRequestMeta.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Services +{ + public interface IRequestMeta + { + Dictionary GetMeta(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index 5ecd72872d..9cc244677d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.Services @@ -14,11 +15,16 @@ public class JsonApiContext : IJsonApiContext public JsonApiContext( IContextGraph contextGraph, IHttpContextAccessor httpContextAccessor, - JsonApiOptions options) + JsonApiOptions options, + IMetaBuilder metaBuilder, + IGenericProcessorFactory genericProcessorFactory) { ContextGraph = contextGraph; _httpContextAccessor = httpContextAccessor; Options = options; + MetaBuilder = metaBuilder; + GenericProcessorFactory = genericProcessorFactory; + RelationshipsToUpdate = new Dictionary(); } public JsonApiOptions Options { get; set; } @@ -30,6 +36,9 @@ public JsonApiContext( public bool IsRelationshipPath { get; private set; } public List IncludedRelationships { get; set; } public PageManager PageManager { get; set; } + public IMetaBuilder MetaBuilder { get; set; } + public IGenericProcessorFactory GenericProcessorFactory { get; set; } + public Dictionary RelationshipsToUpdate { get; set; } public IJsonApiContext ApplyContext() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorTests.cs new file mode 100644 index 0000000000..ce2b541f5b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorTests.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Serialization; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility +{ + public class CustomErrorTests + { + [Fact] + public void Can_Return_Custom_Error_Types() + { + // arrange + var error = new CustomError("507", "title", "detail", "custom"); + var errorCollection = new ErrorCollection(); + errorCollection.Add(error); + + var expectedJson = JsonConvert.SerializeObject(new { + errors = new dynamic[] { + new { + myCustomProperty = "custom", + title = "title", + detail = "detail", + status = "507" + } + } + }); + + // act + var result = new JsonApiSerializer(null, null, null) + .Serialize(errorCollection); + + // assert + Assert.Equal(expectedJson, result); + + } + + class CustomError : Error { + public CustomError(string status, string title, string detail, string myProp) + : base(status, title, detail) + { + MyCustomProperty = myProp; + } + public string MyCustomProperty { get; set; } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs index 7c7b145830..847d6eefff 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs @@ -57,7 +57,7 @@ public async Task Total_Record_Count_Included() // act var response = await client.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(responseBody, jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(responseBody); // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs new file mode 100644 index 0000000000..a1b597f7d5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Xunit; +using JsonApiDotNetCoreExample.Models; +using DotNetCoreDocs; +using JsonApiDotNetCoreExample; +using DotNetCoreDocs.Writers; +using Newtonsoft.Json; +using JsonApiDotNetCore.Models; +using System.Collections; +using System.Diagnostics; +using System.Threading; +using JsonApiDotNetCoreExampleTests.Startups; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility +{ + [Collection("WebHostCollection")] + public class RequestMetaTests + { + private DocsFixture _fixture; + + public RequestMetaTests(DocsFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Injecting_IRequestMeta_Adds_Meta_Data() + { + // arrange + var person = new Person(); + var expectedMeta = person.GetMeta(null); + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/people"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(documents.Meta); + Assert.NotNull(expectedMeta); + Assert.NotEmpty(expectedMeta); + + foreach(var hash in expectedMeta) + { + if(hash.Value is IList) + { + var listValue = (IList)hash.Value; + for(var i=0; i < listValue.Count; i++) + Assert.Equal(listValue[i].ToString(), ((IList)documents.Meta[hash.Key])[i].ToString()); + } + else + { + Assert.Equal(hash.Value, documents.Meta[hash.Key]); + } + } + Assert.Equal("request-meta-value", documents.Meta["request-meta"]); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index 25e2e3d6fe..52b399191f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -156,7 +156,7 @@ public async Task Can_Create_Entity_With_Client_Defined_Id_If_Configured() // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); @@ -208,7 +208,7 @@ public async Task Can_Create_Guid_Identifiable_Entity_With_Client_Defined_Id_If_ // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItemCollection)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); + var deserializedBody = (TodoItemCollection)_fixture.GetService().Deserialize(body); // assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); @@ -269,7 +269,7 @@ public async Task Can_Create_And_Set_HasMany_Relationships() // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItemCollection)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, context); + var deserializedBody = (TodoItemCollection)_fixture.GetService().Deserialize(body); var newId = deserializedBody.Id; var contextCollection = context.TodoItemCollections .Include(c => c.Owner) @@ -312,7 +312,7 @@ public async Task ShouldReceiveLocationHeader_InResponse() // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs index e024786252..4fb7693d4c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -10,11 +10,9 @@ using Xunit; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; -using System.Linq; using JsonApiDotNetCoreExampleTests.Startups; using JsonApiDotNetCoreExample.Models; using System.Collections; -using System; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 7c1339f86c..7ff0b05e85 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -2,8 +2,10 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Bogus; using DotNetCoreDocs; using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample; @@ -13,6 +15,7 @@ using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -21,11 +24,19 @@ public class FetchingDataTests { private DocsFixture _fixture; private IJsonApiContext _jsonApiContext; + private Faker _todoItemFaker; + private Faker _personFaker; public FetchingDataTests(DocsFixture fixture) { _fixture = fixture; _jsonApiContext = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); } [Fact] @@ -50,7 +61,7 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() // act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -60,5 +71,38 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() context.Dispose(); } + + [Fact] + public async Task Included_Records_Contain_Relationship_Links() + { + // arrange + var context = _fixture.GetService(); + var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); + todoItem.Owner = person; + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items/{todoItem.Id}?include=owner"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonConvert.DeserializeObject(body); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(person.StringId, deserializedBody.Included[0].Id); + Assert.NotNull(deserializedBody.Included[0].Relationships); + Assert.Equal($"http://localhost/api/v1/people/{person.Id}/todo-items", deserializedBody.Included[0].Relationships["todo-items"].Links.Related); + Assert.Equal($"http://localhost/api/v1/people/{person.Id}/relationships/todo-items", deserializedBody.Included[0].Relationships["todo-items"].Links.Self); + context.Dispose(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs new file mode 100644 index 0000000000..2b0be2dc59 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -0,0 +1,93 @@ +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Xunit; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCoreExample.Models; +using System.Linq; +using Microsoft.AspNetCore.Hosting; +using System.Net.Http; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class SparseFieldSetTests + { + private DocsFixture _fixture; + private readonly AppDbContext _dbContext; + + public SparseFieldSetTests(DocsFixture fixture) + { + _fixture = fixture; + _dbContext = fixture.GetService(); + } + + [Fact] + public async Task Can_Select_Sparse_Fieldsets() + { + // arrange + var fields = new string[] { "Id", "Description" }; + var todoItem = new TodoItem { + Description = "description", + Ordinal = 1 + }; + _dbContext.TodoItems.Add(todoItem); + await _dbContext.SaveChangesAsync(); + var expectedSql = $@"SELECT 't'.'Id', 't'.'Description' + FROM 'TodoItems' AS 't' + WHERE 't'.'Id' = {todoItem.Id}".Normalize(); + + // act + var query = _dbContext + .TodoItems + .Where(t=>t.Id == todoItem.Id) + .Select(fields); + + var resultSql = query.ToSql().Normalize(); + var result = await query.FirstAsync(); + + // assert + Assert.Equal(0, result.Ordinal); + Assert.Equal(todoItem.Description, result.Description); + Assert.Equal(expectedSql, resultSql); + } + + [Fact] + public async Task Fields_Query_Selects_Sparse_Field_Sets() + { + // arrange + var todoItem = new TodoItem { + Description = "description", + Ordinal = 1 + }; + _dbContext.TodoItems.Add(todoItem); + await _dbContext.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/todo-items/{todoItem.Id}?fields[todo-items]=description"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + // assert + Assert.Equal(todoItem.StringId, deserializeBody.Data.Id); + Assert.Equal(1, deserializeBody.Data.Attributes.Count); + Assert.Equal(todoItem.Description, deserializeBody.Data.Attributes["description"]); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index c87683fa2f..a5ad8f6a7a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -7,15 +6,15 @@ using Bogus; using DotNetCoreDocs; using DotNetCoreDocs.Writers; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -25,14 +24,18 @@ public class UpdatingDataTests private DocsFixture _fixture; private AppDbContext _context; private Faker _todoItemFaker; + private Faker _personFaker; public UpdatingDataTests(DocsFixture fixture) { _fixture = fixture; _context = fixture.GetService(); - _todoItemFaker = new Faker() + _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); } [Fact] @@ -73,5 +76,62 @@ public async Task Respond_404_If_EntityDoesNotExist() // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task Can_Patch_Entity_And_HasOne_Relationships() + { + // arrange + var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); + _context.People.Add(person); + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new + { + type = "todo-items", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = person.Id.ToString() + } + } + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{todoItem.Id}"; + var request = new HttpRequestMessage(httpMethod, route); + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + var updatedTodoItem = _context.TodoItems.AsNoTracking() + .Include(t => t.Owner) + .SingleOrDefault(t => t.Id == todoItem.Id); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(person.Id, updatedTodoItem.OwnerId); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index dfbd862232..ffb336e2c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; using Bogus; using DotNetCoreDocs; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 0e9449ad86..84e398d4b9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -56,7 +56,7 @@ public async Task Can_Get_TodoItems() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -85,7 +85,7 @@ public async Task Can_Paginate_TodoItems() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -114,7 +114,7 @@ public async Task Can_Filter_TodoItems() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -144,7 +144,7 @@ public async Task Can_Filter_TodoItems_Using_Like_Operator() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -182,7 +182,7 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -224,7 +224,7 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Descending() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -258,7 +258,7 @@ public async Task Can_Get_TodoItem_ById() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -288,7 +288,7 @@ public async Task Can_Get_TodoItem_WithOwner() // Act var response = await _fixture.MakeRequest(description, httpMethod, route); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -343,7 +343,7 @@ public async Task Can_Post_TodoItem() // Act var response = await _fixture.MakeRequest(description, request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); @@ -390,7 +390,7 @@ public async Task Can_Patch_TodoItem() // Act var response = await _fixture.MakeRequest(description, request); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext, _fixture.GetService()); + var deserializedBody = (TodoItem)_fixture.GetService().Deserialize(body); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/IQueryableExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000000..a40dfb4a5a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/IQueryableExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Remotion.Linq.Parsing.Structure; +using Database = Microsoft.EntityFrameworkCore.Storage.Database; + +namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions +{ + + public static class IQueryableExtensions + { + private static readonly TypeInfo QueryCompilerTypeInfo = typeof(QueryCompiler).GetTypeInfo(); + + private static readonly FieldInfo QueryCompilerField = typeof(EntityQueryProvider).GetTypeInfo().DeclaredFields.First(x => x.Name == "_queryCompiler"); + + private static readonly PropertyInfo NodeTypeProviderField = QueryCompilerTypeInfo.DeclaredProperties.Single(x => x.Name == "NodeTypeProvider"); + + private static readonly MethodInfo CreateQueryParserMethod = QueryCompilerTypeInfo.DeclaredMethods.First(x => x.Name == "CreateQueryParser"); + + private static readonly FieldInfo DataBaseField = QueryCompilerTypeInfo.DeclaredFields.Single(x => x.Name == "_database"); + + private static readonly FieldInfo QueryCompilationContextFactoryField = typeof(Database).GetTypeInfo().DeclaredFields.Single(x => x.Name == "_queryCompilationContextFactory"); + + public static string ToSql(this IQueryable query) where TEntity : class + { + if (!(query is EntityQueryable) && !(query is InternalDbSet)) + throw new ArgumentException("Invalid query"); + + var queryCompiler = (IQueryCompiler)QueryCompilerField.GetValue(query.Provider); + var nodeTypeProvider = (INodeTypeProvider)NodeTypeProviderField.GetValue(queryCompiler); + var parser = (IQueryParser)CreateQueryParserMethod.Invoke(queryCompiler, new object[] { nodeTypeProvider }); + var queryModel = parser.GetParsedQuery(query.Expression); + var database = DataBaseField.GetValue(queryCompiler); + var queryCompilationContextFactory = (IQueryCompilationContextFactory)QueryCompilationContextFactoryField.GetValue(database); + var queryCompilationContext = queryCompilationContextFactory.Create(false); + var modelVisitor = (RelationalQueryModelVisitor)queryCompilationContext.CreateQueryModelVisitor(); + modelVisitor.CreateQueryExecutor(queryModel); + var sql = modelVisitor.Queries.First().ToString(); + + return sql; + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..19c7491d2a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions +{ + + public static class StringExtensions + { + public static string Normalize(this string input) + { + return Regex.Replace(input, @"\s+", string.Empty) + .ToUpper() + .Replace('"', '\''); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Repositories/AuthorizedTodoItemsRepository.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Repositories/AuthorizedTodoItemsRepository.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/Repositories/AuthorizedTodoItemsRepository.cs rename to test/JsonApiDotNetCoreExampleTests/Helpers/Repositories/AuthorizedTodoItemsRepository.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Services/IAuthorizationService.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Services/IAuthorizationService.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/Services/IAuthorizationService.cs rename to test/JsonApiDotNetCoreExampleTests/Helpers/Services/IAuthorizationService.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Services/MetaService.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Services/MetaService.cs new file mode 100644 index 0000000000..91de8fda5e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Services/MetaService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCoreExampleTests.Services +{ + public class MetaService : IRequestMeta + { + public Dictionary GetMeta() + { + return new Dictionary { + { "request-meta", "request-meta-value" } + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AuthorizedStartup.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Startups/AuthorizedStartup.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/Startups/AuthorizedStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Helpers/Startups/AuthorizedStartup.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/ClientGeneratedIdsStartup.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Startups/ClientGeneratedIdsStartup.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/Startups/ClientGeneratedIdsStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Helpers/Startups/ClientGeneratedIdsStartup.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Startups/MetaStartup.cs similarity index 90% rename from test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Helpers/Startups/MetaStartup.cs index 0c84733632..b23fded9ba 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Startups/MetaStartup.cs @@ -7,6 +7,8 @@ using DotNetCoreDocs.Configuration; using System; using JsonApiDotNetCoreExample; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExampleTests.Services; namespace JsonApiDotNetCoreExampleTests.Startups { @@ -38,6 +40,7 @@ public override IServiceProvider ConfigureServices(IServiceCollection services) }); services.AddDocumentationConfiguration(Config); + services.AddScoped(); return services.BuildServiceProvider(); } diff --git a/test/JsonApiDotNetCoreExampleTests/Unit/Builders/MetaBuilderTests.cs b/test/JsonApiDotNetCoreExampleTests/Unit/Builders/MetaBuilderTests.cs new file mode 100644 index 0000000000..5cd0b765de --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Unit/Builders/MetaBuilderTests.cs @@ -0,0 +1,73 @@ +using Xunit; +using JsonApiDotNetCore.Builders; +using System.Collections.Generic; + +namespace JsonApiDotNetCoreExampleTests.Unit.Builders +{ + public class MetaBuilderTests + { + [Fact] + public void Can_Add_Key_Value() + { + // arrange + var builder = new MetaBuilder(); + var key = "test"; + var value = "testValue"; + + // act + builder.Add(key, value); + var result = builder.Build(); + + // assert + Assert.NotEmpty(result); + Assert.Equal(value, result[key]); + } + + [Fact] + public void Can_Add_Multiple_Values() + { + // arrange + var builder = new MetaBuilder(); + var input = new Dictionary { + { "key1", "value1" }, + { "key2", "value2" } + }; + + // act + builder.Add(input); + var result = builder.Build(); + + // assert + Assert.NotEmpty(result); + foreach (var entry in input) + Assert.Equal(input[entry.Key], result[entry.Key]); + } + + [Fact] + public void When_Adding_Duplicate_Values_Keep_Newest() + { + // arrange + var builder = new MetaBuilder(); + + var key = "key"; + var oldValue = "oldValue"; + var newValue = "newValue"; + + builder.Add(key, oldValue); + + var input = new Dictionary { + { key, newValue }, + { "key2", "value2" } + }; + + // act + builder.Add(input); + var result = builder.Build(); + + // assert + Assert.NotEmpty(result); + Assert.Equal(input.Count, result.Count); + Assert.Equal(input[key], result[key]); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Unit/Extensions/IServiceCollectionExtensionsTests.cs b/test/JsonApiDotNetCoreExampleTests/Unit/Extensions/IServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..71a952994a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Unit/Extensions/IServiceCollectionExtensionsTests.cs @@ -0,0 +1,54 @@ +using Xunit; +using JsonApiDotNetCore.Builders; +using Microsoft.Extensions.DependencyInjection; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using Microsoft.AspNetCore.Http; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.Caching.Memory; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Formatters; + +namespace JsonApiDotNetCoreExampleTests.Unit.Extensions +{ + public class IServiceCollectionExtensionsTests + { + [Fact] + public void AddJsonApiInternals_Adds_All_Required_Services() + { + // arrange + var services = new ServiceCollection(); + var jsonApiOptions = new JsonApiOptions(); + + services.AddDbContext(options => + { + options.UseMemoryCache(new MemoryCache(new MemoryCacheOptions())); + }, ServiceLifetime.Transient); + + // act + services.AddJsonApiInternals(jsonApiOptions); + var provider = services.BuildServiceProvider(); + + // assert + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService(typeof(IEntityRepository))); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService(typeof(GenericProcessor))); + } + } +}