diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..4c276999f1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +language: csharp +sudo: required +dist: trusty +env: + - CLI_VERSION=latest +addons: + apt: + packages: + - gettext + - libcurl4-openssl-dev + - libicu-dev + - libssl-dev + - libunwind8 + - zlib1g +mono: + - 4.2.3 +os: + - linux + - osx +osx_image: xcode7.1 +branches: + only: + - master +before_install: + - if test "$TRAVIS_OS_NAME" = "osx"; then brew update; brew install openssl; ln -s /usr/local/opt/openssl/lib/libcrypto.1.0.0.dylib /usr/local/lib/; ln -s /usr/local/opt/openssl/lib/libssl.1.0.0.dylib /usr/local/lib/; fi +install: + - export DOTNET_INSTALL_DIR="$PWD/.dotnetcli" + - curl -sSL https://raw.githubusercontent.com/dotnet/cli/rel/1.0.0/scripts/obtain/dotnet-install.sh | bash /dev/stdin --version "$CLI_VERSION" --install-dir "$DOTNET_INSTALL_DIR" + - export PATH="$DOTNET_INSTALL_DIR:$PATH" +script: + - ./build.sh diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 0000000000..fb3f5a6317 --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,64 @@ +<# +.SYNOPSIS + You can add this to you build script to ensure that psbuild is available before calling + Invoke-MSBuild. If psbuild is not available locally it will be downloaded automatically. +#> +function EnsurePsbuildInstalled{ + [cmdletbinding()] + param( + [string]$psbuildInstallUri = 'https://raw.githubusercontent.com/ligershark/psbuild/master/src/GetPSBuild.ps1' + ) + process{ + if(-not (Get-Command "Invoke-MsBuild" -errorAction SilentlyContinue)){ + 'Installing psbuild from [{0}]' -f $psbuildInstallUri | Write-Verbose + (new-object Net.WebClient).DownloadString($psbuildInstallUri) | iex + } + else{ + 'psbuild already loaded, skipping download' | Write-Verbose + } + + # make sure it's loaded and throw if not + if(-not (Get-Command "Invoke-MsBuild" -errorAction SilentlyContinue)){ + throw ('Unable to install/load psbuild from [{0}]' -f $psbuildInstallUri) + } + } +} + +# Taken from psake https://github.com/psake/psake + +<# +.SYNOPSIS + This is a helper function that runs a scriptblock and checks the PS variable $lastexitcode + to see if an error occcured. If an error is detected then an exception is thrown. + This function allows you to run command-line programs without having to + explicitly check the $lastexitcode variable. +.EXAMPLE + exec { svn info $repository_trunk } "Error executing SVN. Please verify SVN command-line client is installed" +#> +function Exec +{ + [CmdletBinding()] + param( + [Parameter(Position=0,Mandatory=1)][scriptblock]$cmd, + [Parameter(Position=1,Mandatory=0)][string]$errorMessage = ($msgs.error_bad_command -f $cmd) + ) + & $cmd + if ($lastexitcode -ne 0) { + throw ("Exec: " + $errorMessage) + } +} + +if(Test-Path .\artifacts) { Remove-Item .\artifacts -Force -Recurse } + +EnsurePsbuildInstalled + +exec { & dotnet restore } + +Invoke-MSBuild + +$revision = @{ $true = $env:APPVEYOR_BUILD_NUMBER; $false = 1 }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL]; +$revision = "{0:D4}" -f [convert]::ToInt32($revision, 10) + +exec { & dotnet test .\JsonApiDotNetCoreTests -c Release } + +exec { & dotnet pack .\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$revision } diff --git a/JsonApiDotNetCore/Abstractions/IJsonApiContext.cs b/JsonApiDotNetCore/Abstractions/IJsonApiContext.cs new file mode 100644 index 0000000000..7c3bdcc410 --- /dev/null +++ b/JsonApiDotNetCore/Abstractions/IJsonApiContext.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Routing; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Abstractions +{ + public interface IJsonApiContext + { + JsonApiModelConfiguration Configuration { get; } + object DbContext { get; } + HttpContext HttpContext { get; } + Route Route { get; } + string GetEntityName(); + Type GetEntityType(); + Type GetJsonApiResourceType(); + } +} diff --git a/JsonApiDotNetCore/Abstractions/JsonApiContext.cs b/JsonApiDotNetCore/Abstractions/JsonApiContext.cs index 9a18bf0375..d2eb00f819 100644 --- a/JsonApiDotNetCore/Abstractions/JsonApiContext.cs +++ b/JsonApiDotNetCore/Abstractions/JsonApiContext.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Abstractions { - public class JsonApiContext + public class JsonApiContext : IJsonApiContext { public HttpContext HttpContext { get; } public Route Route { get; } @@ -24,7 +24,7 @@ public JsonApiContext(HttpContext httpContext, Route route, object dbContext, Js public Type GetJsonApiResourceType() { - return Configuration.ResourceMapDefinitions[Route.BaseModelType]; + return Configuration.ResourceMapDefinitions[Route.BaseModelType].Item1; } public string GetEntityName() diff --git a/JsonApiDotNetCore/Configuration/IJsonApiModelConfiguration.cs b/JsonApiDotNetCore/Configuration/IJsonApiModelConfiguration.cs index 583a5f359d..cdb9aefeb5 100644 --- a/JsonApiDotNetCore/Configuration/IJsonApiModelConfiguration.cs +++ b/JsonApiDotNetCore/Configuration/IJsonApiModelConfiguration.cs @@ -1,13 +1,41 @@ using System; using System.Collections.Generic; using AutoMapper; +using JsonApiDotNetCore.Abstractions; namespace JsonApiDotNetCore.Configuration { public interface IJsonApiModelConfiguration { + /// + /// The database context to use + /// + /// + /// void UseContext(); + + /// + /// The request namespace. + /// + /// + /// api/v1 void SetDefaultNamespace(string ns); - void DefineResourceMapping(Action> mapping); + + /// + /// Define explicit mapping of a model to a class that implements IJsonApiResource + /// + /// + /// + /// + /// + void AddResourceMapping(Action mappingExpression); + + /// + /// Specifies a controller override class for a particular model type. + /// + /// + /// + /// + void UseController(); } } diff --git a/JsonApiDotNetCore/Configuration/JsonApiConfigurationBuilder.cs b/JsonApiDotNetCore/Configuration/JsonApiConfigurationBuilder.cs new file mode 100644 index 0000000000..4debb4d227 --- /dev/null +++ b/JsonApiDotNetCore/Configuration/JsonApiConfigurationBuilder.cs @@ -0,0 +1,96 @@ +using System; +using System.Reflection; +using JsonApiDotNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Linq.Expressions; +using AutoMapper; +using JsonApiDotNetCore.Abstractions; +using JsonApiDotNetCore.Attributes; + +namespace JsonApiDotNetCore.Configuration +{ + public class JsonApiConfigurationBuilder + { + private readonly Action _configurationAction; + private JsonApiModelConfiguration Config { get; set; } + + public JsonApiConfigurationBuilder(Action configurationAction) + { + Config = new JsonApiModelConfiguration(); + _configurationAction = configurationAction; + } + + public JsonApiModelConfiguration Build() + { + Config = new JsonApiModelConfiguration(); + _configurationAction.Invoke(Config); + CheckIsValidConfiguration(); + LoadModelRoutesFromContext(); + SetupResourceMaps(); + return Config; + } + + private void CheckIsValidConfiguration() + { + if (Config.ContextType == null) + throw new NullReferenceException("DbContext is not specified"); + } + + private void LoadModelRoutesFromContext() + { + // Assumption: all DbSet<> types should be included in the route list + var properties = Config.ContextType.GetProperties().ToList(); + + properties.ForEach(property => + { + if (property.PropertyType.GetTypeInfo().IsGenericType && + property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + { + + var modelType = property.PropertyType.GetGenericArguments()[0]; + + var route = new RouteDefinition + { + ModelType = modelType, + PathString = RouteBuilder.BuildRoute(Config.Namespace, property.Name), + ContextPropertyName = property.Name + }; + + Config.Routes.Add(route); + } + }); + } + + private void SetupResourceMaps() + { + LoadDefaultResourceMaps(); + var mapConfiguration = new MapperConfiguration(cfg => + { + foreach (var definition in Config.ResourceMapDefinitions) + { + var mappingExpression = cfg.CreateMap(definition.Key, definition.Value.Item1); + definition.Value.Item2?.Invoke(mappingExpression); + } + }); + Config.ResourceMapper = mapConfiguration.CreateMapper(); + } + + private void LoadDefaultResourceMaps() + { + var resourceAttribute = typeof(JsonApiResourceAttribute); + var modelTypes = Assembly.GetEntryAssembly().DefinedTypes.Where(t => t.GetCustomAttributes(resourceAttribute).Count() == 1); + + foreach (var modelType in modelTypes) + { + var resourceType = ((JsonApiResourceAttribute)modelType.GetCustomAttribute(resourceAttribute)).JsonApiResourceType; + + // do not overwrite custom definitions + if(!Config.ResourceMapDefinitions.ContainsKey(modelType.UnderlyingSystemType)) + { + Config.ResourceMapDefinitions.Add(modelType.UnderlyingSystemType, new Tuple>(resourceType, null)); + } + } + } + } +} diff --git a/JsonApiDotNetCore/Configuration/JsonApiModelConfiguration.cs b/JsonApiDotNetCore/Configuration/JsonApiModelConfiguration.cs index e4c194908b..ad7feb42c4 100644 --- a/JsonApiDotNetCore/Configuration/JsonApiModelConfiguration.cs +++ b/JsonApiDotNetCore/Configuration/JsonApiModelConfiguration.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Reflection; using AutoMapper; +using JsonApiDotNetCore.Abstractions; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.JsonApi; using JsonApiDotNetCore.Routing; using Microsoft.AspNetCore.Http; @@ -16,59 +18,42 @@ public class JsonApiModelConfiguration : IJsonApiModelConfiguration public IMapper ResourceMapper; public Type ContextType { get; set; } public List Routes = new List(); - public Dictionary ResourceMapDefinitions = new Dictionary(); + public Dictionary>> ResourceMapDefinitions = new Dictionary>>(); + public Dictionary ControllerOverrides = new Dictionary(); public void SetDefaultNamespace(string ns) { Namespace = ns; } - public void DefineResourceMapping(Action> mapping) + public void AddResourceMapping(Action mappingExpression) { - mapping.Invoke(ResourceMapDefinitions); + var resourceType = typeof(TResource); + var modelType = typeof(TModel); - var mapConfiguration = new MapperConfiguration(cfg => - { - foreach (var definition in ResourceMapDefinitions) - { - cfg.CreateMap(definition.Key, definition.Value); - } - }); + if (!resourceType.GetInterfaces().Contains(typeof(IJsonApiResource))) + throw new ArgumentException("Specified type does not implement IJsonApiResource", nameof(resourceType)); - ResourceMapper = mapConfiguration.CreateMapper(); + ResourceMapDefinitions.Add(modelType, new Tuple>(resourceType, mappingExpression)); } - public void UseContext() - { - // TODO: assert the context is of type DbContext - ContextType = typeof(T); - LoadModelRoutesFromContext(); - } - - private void LoadModelRoutesFromContext() + public void UseController() { - // Assumption: all DbSet<> types should be included in the route list - var properties = ContextType.GetProperties().ToList(); + var modelType = typeof(TModel); + var controllerType = typeof(TController); - properties.ForEach(property => - { - if (property.PropertyType.GetTypeInfo().IsGenericType && - property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) - { + if (!controllerType.GetInterfaces().Contains(typeof(IJsonApiController))) + throw new ArgumentException("Specified type does not implement IJsonApiController", nameof(controllerType)); - var modelType = property.PropertyType.GetGenericArguments()[0]; + ControllerOverrides[modelType] = controllerType; + } - var route = new RouteDefinition - { - ModelType = modelType, - PathString = RouteBuilder.BuildRoute(Namespace, property.Name), - ContextPropertyName = property.Name - }; + public void UseContext() + { + ContextType = typeof(T); - Routes.Add(route); - } - }); + if (!typeof(DbContext).IsAssignableFrom(ContextType)) + throw new ArgumentException("Context Type must derive from DbContext", nameof(T)); } - } } diff --git a/JsonApiDotNetCore/Controllers/ControllerBuilder.cs b/JsonApiDotNetCore/Controllers/ControllerBuilder.cs index 78745bca8b..68d2d62b74 100644 --- a/JsonApiDotNetCore/Controllers/ControllerBuilder.cs +++ b/JsonApiDotNetCore/Controllers/ControllerBuilder.cs @@ -1,13 +1,45 @@ -using JsonApiDotNetCore.Abstractions; +using System; +using System.Reflection; +using System.Linq; +using JsonApiDotNetCore.Abstractions; using JsonApiDotNetCore.Data; namespace JsonApiDotNetCore.Controllers { public class ControllerBuilder : IControllerBuilder { + private JsonApiContext _context { get; set; } public IJsonApiController BuildController(JsonApiContext context) { - return new JsonApiController(context, new ResourceRepository(context)); + _context = context; + var overrideController = GetOverrideController(); + return overrideController ?? new JsonApiController(_context, new ResourceRepository(_context)); + } + + public IJsonApiController GetOverrideController() + { + Type controllerType; + return _context.Configuration.ControllerOverrides.TryGetValue(_context.GetEntityType(), out controllerType) ? + InstantiateController(controllerType) : null; + } + + private IJsonApiController InstantiateController(Type controllerType) + { + var constructor = controllerType.GetConstructors()[0]; + var parameters = constructor.GetParameters(); + var services = + parameters.Select(param => GetService(param.ParameterType)).ToArray(); + return (IJsonApiController) Activator.CreateInstance(controllerType, services); + } + + private object GetService(Type serviceType) + { + if(serviceType == typeof(ResourceRepository)) + return new ResourceRepository(_context); + if (serviceType == typeof(JsonApiContext)) + return _context; + + return _context.HttpContext.RequestServices.GetService(serviceType); } } } diff --git a/JsonApiDotNetCore/Controllers/JsonApiController.cs b/JsonApiDotNetCore/Controllers/JsonApiController.cs index a776e85ed0..0b6f14ba7a 100644 --- a/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -8,25 +8,25 @@ namespace JsonApiDotNetCore.Controllers { public class JsonApiController : IJsonApiController { - protected readonly JsonApiContext JsonApiContext; + protected readonly IJsonApiContext JsonApiContext; private readonly ResourceRepository _resourceRepository; - public JsonApiController(JsonApiContext jsonApiContext, ResourceRepository resourceRepository) + public JsonApiController(IJsonApiContext jsonApiContext, ResourceRepository resourceRepository) { JsonApiContext = jsonApiContext; _resourceRepository = resourceRepository; } - public ObjectResult Get() + public virtual ObjectResult Get() { var entities = _resourceRepository.Get(); - if(entities == null || entities.Count == 0) { + if(entities == null) { return new NotFoundObjectResult(null); } return new OkObjectResult(entities); } - public ObjectResult Get(string id) + public virtual ObjectResult Get(string id) { var entity = _resourceRepository.Get(id); if(entity == null) { @@ -35,14 +35,14 @@ public ObjectResult Get(string id) return new OkObjectResult(entity); } - public ObjectResult Post(object entity) + public virtual ObjectResult Post(object entity) { _resourceRepository.Add(entity); _resourceRepository.SaveChanges(); return new CreatedResult(JsonApiContext.HttpContext.Request.Path, entity); } - public ObjectResult Patch(string id, Dictionary entityPatch) + public virtual ObjectResult Patch(string id, Dictionary entityPatch) { var entity = _resourceRepository.Get(id); if(entity == null) { @@ -55,7 +55,7 @@ public ObjectResult Patch(string id, Dictionary entityPatc return new OkObjectResult(entity); } - public ObjectResult Delete(string id) + public virtual ObjectResult Delete(string id) { _resourceRepository.Delete(id); _resourceRepository.SaveChanges(); diff --git a/JsonApiDotNetCore/Data/GenericDataAccess.cs b/JsonApiDotNetCore/Data/GenericDataAccess.cs index ef36114349..d1f8d6c8db 100644 --- a/JsonApiDotNetCore/Data/GenericDataAccess.cs +++ b/JsonApiDotNetCore/Data/GenericDataAccess.cs @@ -1,22 +1,60 @@ using System; -using System.Collections.Generic; +using System.Reflection; using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Abstractions; -using System.Reflection; using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Routing; using Microsoft.EntityFrameworkCore; -public class GenericDataAccess +namespace JsonApiDotNetCore.Data { - public DbSet GetDbSet(DbContext context) where T : class + public class GenericDataAccess { - return context.Set(); - } + public DbSet GetDbSet(DbContext context) where T : class + { + return context.Set(); + } - public IQueryable IncludeEntity(IQueryable queryable, string includedEntityName) where T : class - { - return queryable.Include(includedEntityName); + public IQueryable IncludeEntity(IQueryable queryable, string includedEntityName) where T : class + { + return queryable.Include(includedEntityName); + } + + public T SingleOrDefault(object query, string param, object value) + { + var queryable = (IQueryable) query; + var expression = GetEqualityExpressionForProperty(queryable, param, value); + return queryable.SingleOrDefault(expression); + } + + public IQueryable Where(object query, string param, object value) + { + var queryable = (IQueryable) query; + var expression = GetEqualityExpressionForProperty(queryable, param, value); + return queryable.Where(expression); + } + + private Expression> GetEqualityExpressionForProperty(IQueryable query, string param, object value) + { + var currentType = query.ElementType; + var property = currentType.GetProperty(param); + + if (property == null) + { + throw new ArgumentException($"'{param}' is not a valid property of '{currentType}'"); + } + + // convert the incoming value to the target value type + // "1" -> 1 + var convertedValue = Convert.ChangeType(value, property.PropertyType); + // {model} + var prm = Expression.Parameter(currentType, "model"); + // {model.Id} + var left = Expression.PropertyOrField(prm, property.Name); + // {1} + var right = Expression.Constant(convertedValue, property.PropertyType); + // {model.Id == 1} + var body = Expression.Equal(left, right); + return Expression.Lambda>(body, prm); + } } } diff --git a/JsonApiDotNetCore/Data/GenericDataAccessAbstraction.cs b/JsonApiDotNetCore/Data/GenericDataAccessAbstraction.cs new file mode 100644 index 0000000000..eecc29cd5a --- /dev/null +++ b/JsonApiDotNetCore/Data/GenericDataAccessAbstraction.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Data +{ + public class GenericDataAccessAbstraction + { + private GenericDataAccess _dataAccessorInstance; + private DbContext _dbContext; + private Type _modelType; + private string _includedRelationship; + public GenericDataAccessAbstraction(object dbContext, Type modelType, string includedRelationship) + { + _dataAccessorInstance = (GenericDataAccess)Activator.CreateInstance(typeof(GenericDataAccess)); + _dbContext = (DbContext) dbContext; + _modelType = modelType; + _includedRelationship = includedRelationship?.ToProperCase(); + } + + public object SingleOrDefault(string propertyName, string value) + { + var dbSet = GetDbSet(); + return InvokeGenericDataAccessMethod("SingleOrDefault", new[] { dbSet, propertyName, value }); + } + + public IQueryable Filter(string propertyName, string value) + { + var dbSet = GetDbSet(); + return (IQueryable)InvokeGenericDataAccessMethod("Where", new[] { dbSet, propertyName, value }); + } + + private object InvokeGenericDataAccessMethod(string methodName, params object[] propertyValues) + { + var dataAccessorMethod = _dataAccessorInstance.GetType().GetMethod(methodName); + var genericDataAccessorMethod = dataAccessorMethod.MakeGenericMethod(_modelType); + return genericDataAccessorMethod.Invoke(_dataAccessorInstance, propertyValues); + } + + public object GetDbSet() + { + var dataAccessorGetDbSetMethod = _dataAccessorInstance.GetType().GetMethod("GetDbSet"); + var genericGetDbSetMethod = dataAccessorGetDbSetMethod.MakeGenericMethod(_modelType); + var dbSet = genericGetDbSetMethod.Invoke(_dataAccessorInstance, new [] { _dbContext }); + if (!string.IsNullOrEmpty(_includedRelationship)) + { + dbSet = IncludeRelationshipInContext(dbSet); + } + return dbSet; + } + + private object IncludeRelationshipInContext(object dbSet) + { + var includeMethod = _dataAccessorInstance.GetType().GetMethod("IncludeEntity"); + var genericIncludeMethod = includeMethod.MakeGenericMethod(_modelType); + return genericIncludeMethod.Invoke(_dataAccessorInstance, new []{ dbSet, _includedRelationship }); + } + + } +} diff --git a/JsonApiDotNetCore/Data/ResourceRepository.cs b/JsonApiDotNetCore/Data/ResourceRepository.cs index 51eb628a3c..6b5ed59611 100644 --- a/JsonApiDotNetCore/Data/ResourceRepository.cs +++ b/JsonApiDotNetCore/Data/ResourceRepository.cs @@ -1,12 +1,12 @@ using System; +using System.Reflection; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using JsonApiDotNetCore.Abstractions; -using System.Reflection; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Routing; using Microsoft.EntityFrameworkCore; +using System.Collections; namespace JsonApiDotNetCore.Data { @@ -21,53 +21,48 @@ public ResourceRepository(JsonApiContext context) public List Get() { - return (GetDbSetFromContext(_context.Route.BaseRouteDefinition.ContextPropertyName) as IEnumerable)?.ToList(); + IQueryable dbSet; + var filter = _context.Route.Query.Filter; + if(filter != null) { + dbSet = FilterEntities(_context.Route.BaseModelType, filter.PropertyName, filter.PropertyValue, null); + } + else { + dbSet = GetDbSet(_context.Route.BaseModelType, null); + } + return ((IEnumerable)dbSet).ToList(); } public object Get(string id) { - if (_context.Route is RelationalRoute) - { - return GetRelated(id, _context.Route as RelationalRoute); - } - return GetEntityById(_context.Route.BaseModelType, id, null); + var route = _context.Route as RelationalRoute; + return route != null ? GetRelated(id, route) : GetEntityById(_context.Route.BaseModelType, id, null); } private object GetRelated(string id, RelationalRoute relationalRoute) { - // HACK: this would rely on lazy loading to work...will probably fail var entity = GetEntityById(relationalRoute.BaseModelType, id, relationalRoute.RelationshipName); - return relationalRoute.BaseModelType.GetProperties().FirstOrDefault(pi => pi.Name.ToCamelCase() == relationalRoute.RelationshipName.ToCamelCase()).GetValue(entity); + return relationalRoute.BaseModelType.GetProperties().FirstOrDefault(pi => pi.Name.ToCamelCase() == relationalRoute.RelationshipName.ToCamelCase())?.GetValue(entity); } - private IQueryable GetDbSetFromContext(string propName) + private IQueryable GetDbSet(Type modelType, string includedRelationship) { var dbContext = _context.DbContext; - return (IQueryable)dbContext.GetType().GetProperties().FirstOrDefault(pI => pI.Name.ToProperCase() == propName.ToProperCase())?.GetValue(dbContext, null); + return (IQueryable)new GenericDataAccessAbstraction(_context.DbContext, modelType, includedRelationship).GetDbSet(); } private object GetEntityById(Type modelType, string id, string includedRelationship) { - // HACK: I _believe_ by casting to IEnumerable, we are loading all records into memory, if so... find a better way... - // Also, we are making a BIG assumption that the resource has an attribute Id and not ResourceId which is allowed by EF - var dataAccessorInstance = Activator.CreateInstance(typeof(GenericDataAccess)); - var dataAccessorMethod = dataAccessorInstance.GetType().GetMethod("GetDbSet"); - var genericMethod = dataAccessorMethod.MakeGenericMethod(modelType); - var dbSet = genericMethod.Invoke(dataAccessorInstance, new [] {((DbContext) _context.DbContext) }); - - if (!string.IsNullOrEmpty(includedRelationship)) - { - var includeMethod = dataAccessorInstance.GetType().GetMethod("IncludeEntity"); - var genericIncludeMethod = includeMethod.MakeGenericMethod(modelType); - dbSet = genericIncludeMethod.Invoke(dataAccessorInstance, new []{ dbSet, includedRelationship.ToProperCase() }); - } + return new GenericDataAccessAbstraction(_context.DbContext, modelType, includedRelationship).SingleOrDefault("Id", id); + } - return (dbSet as IEnumerable).SingleOrDefault(x => x.Id.ToString() == id); + private IQueryable FilterEntities(Type modelType, string property, string value, string includedRelationship) + { + return new GenericDataAccessAbstraction(_context.DbContext, modelType, includedRelationship).Filter(property, value); } public void Add(object entity) { - var dbSet = GetDbSetFromContext(_context.Route.BaseRouteDefinition.ContextPropertyName); + var dbSet = GetDbSet(_context.Route.BaseModelType, null); var dbSetAddMethod = dbSet.GetType().GetMethod("Add"); dbSetAddMethod.Invoke(dbSet, new [] { entity }); } @@ -75,7 +70,7 @@ public void Add(object entity) public void Delete(string id) { var entity = Get(id); - var dbSet = GetDbSetFromContext(_context.Route.BaseRouteDefinition.ContextPropertyName); + var dbSet = GetDbSet(_context.Route.BaseModelType, null); var dbSetAddMethod = dbSet.GetType().GetMethod("Remove"); dbSetAddMethod.Invoke(dbSet, new [] { entity }); } diff --git a/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 4a95896824..308c3358b0 100644 --- a/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -11,14 +11,10 @@ public static class IServiceCollectionExtensions { public static void AddJsonApi(this IServiceCollection services, Action configurationAction) { - var config = new JsonApiModelConfiguration(); - configurationAction.Invoke(config); - - if (config.ResourceMapper == null) - { - config.ResourceMapper = new MapperConfiguration(cfg => {}).CreateMapper(); - } - services.AddSingleton(_ => new Router(config, new RouteBuilder(config), new ControllerBuilder())); + var configBuilder = new JsonApiConfigurationBuilder(configurationAction); + var config = configBuilder.Build(); + IRouter router = new Router(config, new RouteBuilder(config), new ControllerBuilder()); + services.AddSingleton(_ => router); } } } diff --git a/JsonApiDotNetCore/JsonApiDotNetCore.xproj b/JsonApiDotNetCore/JsonApiDotNetCore.xproj new file mode 100644 index 0000000000..cc9985e128 --- /dev/null +++ b/JsonApiDotNetCore/JsonApiDotNetCore.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + f26e89b2-d2fd-406f-baf6-e6c67513c30b + JsonApiDotNetCore + .\obj + .\bin\ + + + + 2.0 + + + \ No newline at end of file diff --git a/JsonApiDotNetCore/LICENSE b/JsonApiDotNetCore/LICENSE new file mode 100644 index 0000000000..1b22e225f2 --- /dev/null +++ b/JsonApiDotNetCore/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Children's Research Institute + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index ddf90a057f..6cfd048888 100644 --- a/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -29,7 +29,9 @@ public async Task Invoke(HttpContext context) _logger.LogInformation("Passing request to JsonApiService: " + context.Request.Path); if(context.Request.ContentType == "application/vnd.api+json") { - _router.HandleJsonApiRoute(context, _serviceProvider); + var routeWasHandled = _router.HandleJsonApiRoute(context, _serviceProvider); + if(!routeWasHandled) + RespondNotFound(context); } else { @@ -46,5 +48,11 @@ private void RespondUnsupportedMediaType(HttpContext context) context.Response.StatusCode = 415; context.Response.Body.Flush(); } + + private void RespondNotFound(HttpContext context) + { + context.Response.StatusCode = 404; + context.Response.Body.Flush(); + } } } diff --git a/JsonApiDotNetCore/Routing/Query/FilterQuery.cs b/JsonApiDotNetCore/Routing/Query/FilterQuery.cs new file mode 100644 index 0000000000..e4525bd0df --- /dev/null +++ b/JsonApiDotNetCore/Routing/Query/FilterQuery.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.Routing.Query +{ + public class FilterQuery + { + public FilterQuery(string propertyName, string propertyValue) + { + PropertyName = propertyName; + PropertyValue = propertyValue; + } + public string PropertyName { get; set; } + public string PropertyValue { get; set; } + } +} diff --git a/JsonApiDotNetCore/Routing/Query/QuerySet.cs b/JsonApiDotNetCore/Routing/Query/QuerySet.cs new file mode 100644 index 0000000000..c3c62f8d96 --- /dev/null +++ b/JsonApiDotNetCore/Routing/Query/QuerySet.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Extensions; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Routing.Query +{ + public class QuerySet + { + public QuerySet (IQueryCollection query) + { + BuildQuerySet(query); + } + public FilterQuery Filter { get; set; } + public List SortParameters { get; set; } + + private void BuildQuerySet(IQueryCollection query) + { + foreach (var pair in query) + { + if(pair.Key.StartsWith("filter")) + { + Filter = ParseFilterQuery(pair.Key, pair.Value); + continue; + } + + if(pair.Key.StartsWith("sort")){ + SortParameters = ParseSortParameters(pair.Value); + } + } + } + + private FilterQuery ParseFilterQuery(string key, string value) + { + // expected input = filter[id]=1 + var propertyName = key.Split('[', ']')[1].ToProperCase(); + return new FilterQuery(propertyName, value); + } + + // sort=id,name + // sort=-id + private List ParseSortParameters(string value) + { + var sortParameters = new List(); + value.Split(',').ToList().ForEach(p => { + var direction = SortDirection.Ascending; + if(p[0] == '-') + { + direction = SortDirection.Descending; + p = p.Substring(1); + } + sortParameters.Add(new SortParameter(direction, p.ToProperCase())); + }); + + return sortParameters; + } + } +} diff --git a/JsonApiDotNetCore/Routing/Query/SortDirection.cs b/JsonApiDotNetCore/Routing/Query/SortDirection.cs new file mode 100644 index 0000000000..8f724b89a4 --- /dev/null +++ b/JsonApiDotNetCore/Routing/Query/SortDirection.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Routing.Query +{ + public enum SortDirection + { + Ascending = 1, + Descending = 2 + } +} diff --git a/JsonApiDotNetCore/Routing/Query/SortParameter.cs b/JsonApiDotNetCore/Routing/Query/SortParameter.cs new file mode 100644 index 0000000000..ec2af7a5ad --- /dev/null +++ b/JsonApiDotNetCore/Routing/Query/SortParameter.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.Routing.Query +{ + public class SortParameter + { + public SortParameter(SortDirection direction, string propertyName) + { + Direction = direction; + PropertyName = propertyName; + } + public SortDirection Direction { get; set; } + public string PropertyName { get; set; } + } +} diff --git a/JsonApiDotNetCore/Routing/RelationalRoute.cs b/JsonApiDotNetCore/Routing/RelationalRoute.cs index 9b7d1f3a68..24aacfc98a 100644 --- a/JsonApiDotNetCore/Routing/RelationalRoute.cs +++ b/JsonApiDotNetCore/Routing/RelationalRoute.cs @@ -1,11 +1,12 @@ using System; +using JsonApiDotNetCore.Routing.Query; namespace JsonApiDotNetCore.Routing { public class RelationalRoute : Route { - public RelationalRoute(Type baseModelType, string requestMethod, string resourceId, RouteDefinition baseRouteDefinition, Type relationalType, string relationshipName) - : base(baseModelType, requestMethod, resourceId, baseRouteDefinition) + public RelationalRoute(Type baseModelType, string requestMethod, string resourceId, RouteDefinition baseRouteDefinition, QuerySet querySet, Type relationalType, string relationshipName) + : base(baseModelType, requestMethod, resourceId, baseRouteDefinition, querySet) { RelationalType = relationalType; RelationshipName = relationshipName; diff --git a/JsonApiDotNetCore/Routing/Route.cs b/JsonApiDotNetCore/Routing/Route.cs index 33cbcfbb08..e6ec551de3 100644 --- a/JsonApiDotNetCore/Routing/Route.cs +++ b/JsonApiDotNetCore/Routing/Route.cs @@ -1,20 +1,23 @@ using System; +using JsonApiDotNetCore.Routing.Query; namespace JsonApiDotNetCore.Routing { public class Route { - public Route(Type baseModelType, string requestMethod, string resourceId, RouteDefinition baseRouteDefinition) + public Route(Type baseModelType, string requestMethod, string resourceId, RouteDefinition baseRouteDefinition, QuerySet query) { BaseModelType = baseModelType; RequestMethod = requestMethod; ResourceId = resourceId; BaseRouteDefinition = baseRouteDefinition; + Query = query; } public Type BaseModelType { get; set; } public string RequestMethod { get; set; } public RouteDefinition BaseRouteDefinition { get; set; } public string ResourceId { get; set; } + public QuerySet Query { get; set; } } } diff --git a/JsonApiDotNetCore/Routing/RouteBuilder.cs b/JsonApiDotNetCore/Routing/RouteBuilder.cs index a36d1a95ef..167d5b9abc 100644 --- a/JsonApiDotNetCore/Routing/RouteBuilder.cs +++ b/JsonApiDotNetCore/Routing/RouteBuilder.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Abstractions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Routing.Query; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.Routing @@ -19,17 +20,22 @@ public RouteBuilder(JsonApiModelConfiguration configuration) public Route BuildFromRequest(HttpRequest request) { - var remainingPathString = SetBaseRouteDefinition(request.Path); + PathString remainingPathString; + _baseRouteDefinition = SetBaseRouteDefinition(request.Path, out remainingPathString); + + if(_baseRouteDefinition == null) return null; + + var querySet = new QuerySet(request.Query); if (PathStringIsEmpty(remainingPathString)) { // {baseResource} - return new Route(_baseRouteDefinition.ModelType, request.Method, null, _baseRouteDefinition); + return new Route(_baseRouteDefinition.ModelType, request.Method, null, _baseRouteDefinition, querySet); } remainingPathString = SetBaseResourceId(remainingPathString); if (PathStringIsEmpty(remainingPathString)) { // {baseResource}/{baseResourceId} - return new Route(_baseRouteDefinition.ModelType, request.Method, _baseResourceId, _baseRouteDefinition); + return new Route(_baseRouteDefinition.ModelType, request.Method, _baseResourceId, _baseRouteDefinition, querySet); } // { baseResource}/{ baseResourceId}/{relatedResourceName} @@ -41,7 +47,7 @@ public Route BuildFromRequest(HttpRequest request) } var relationshipType = GetTypeOfRelatedResource(relatedResource); - return new RelationalRoute(_baseRouteDefinition.ModelType, request.Method, _baseResourceId, _baseRouteDefinition, relationshipType, relatedResource); + return new RelationalRoute(_baseRouteDefinition.ModelType, request.Method, _baseResourceId, _baseRouteDefinition, querySet, relationshipType, relatedResource); } private bool PathStringIsEmpty(PathString pathString) @@ -49,18 +55,18 @@ private bool PathStringIsEmpty(PathString pathString) return pathString.HasValue ? string.IsNullOrEmpty(pathString.ToString().TrimStart('/')) : true; } - private PathString SetBaseRouteDefinition(PathString path) + private RouteDefinition SetBaseRouteDefinition(PathString path, out PathString remainingPath) { + PathString remainingPathTemp; foreach (var rte in _configuration.Routes) { - PathString remainingPathString; - if (path.StartsWithSegments(new PathString(rte.PathString), StringComparison.OrdinalIgnoreCase, out remainingPathString)) + if (path.StartsWithSegments(new PathString(rte.PathString), StringComparison.OrdinalIgnoreCase, out remainingPathTemp)) { - _baseRouteDefinition = rte; - return remainingPathString; + remainingPath = remainingPathTemp; + return rte; } } - throw new Exception("Route is not defined."); + return null; } private PathString SetBaseResourceId(PathString remainPathString) diff --git a/JsonApiDotNetCore/project.json b/JsonApiDotNetCore/project.json index f55c1423f1..66a3123f23 100644 --- a/JsonApiDotNetCore/project.json +++ b/JsonApiDotNetCore/project.json @@ -1,5 +1,9 @@ { - "version": "1.0.0-*", + "version": "0.1.0-alpha-*", + "packOptions": { + "licenseUrl": "https://github.com/Research-Institute/json-api-dotnet-core/tree/master/JsonApiDotNetCore/LICENSE", + "projectUrl": "https://github.com/Research-Institute/json-api-dotnet-core/" + }, "dependencies": { "NETStandard.Library": "1.6.0", "Microsoft.AspNetCore.Mvc": "1.0.0", @@ -15,13 +19,13 @@ "System.Reflection.Extensions": "4.0.1" }, "frameworks": { + "net451": {}, "netstandard1.6": { "imports": "dnxcore50", "dependencies": { "System.Reflection.TypeExtensions": "4.1.0" } - }, - "net452": {} + } }, "tooling": { "defaultNamespace": "JsonApiDotNetCore" diff --git a/JsonApiDotNetCoreExample/.vscode/launch.json b/JsonApiDotNetCoreExample/.vscode/launch.json new file mode 100644 index 0000000000..739e3e85e9 --- /dev/null +++ b/JsonApiDotNetCoreExample/.vscode/launch.json @@ -0,0 +1,52 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceRoot}/bin/Debug/netcoreapp1.0/JsonApiDotNetCoreExample.dll", + "args": [], + "cwd": "${workspaceRoot}", + "stopAtEntry": false, + "externalConsole": false + }, + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceRoot}/bin/Debug/netcoreapp1.0/JsonApiDotNetCoreExample.dll", + "args": [], + "cwd": "${workspaceRoot}", + "stopAtEntry": false, + "launchBrowser": { + "enabled": true, + "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}" + } + ] +} diff --git a/JsonApiDotNetCoreExample/.vscode/tasks.json b/JsonApiDotNetCoreExample/.vscode/tasks.json new file mode 100644 index 0000000000..33256db7ac --- /dev/null +++ b/JsonApiDotNetCoreExample/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "0.1.0", + "command": "dotnet", + "isShellCommand": true, + "args": [], + "tasks": [ + { + "taskName": "build", + "args": [ ], + "isBuildCommand": true, + "showOutput": "silent", + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs b/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs new file mode 100644 index 0000000000..e838d55dc8 --- /dev/null +++ b/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Abstractions; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Mvc; +using System.Linq; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public class TodoItemsController : JsonApiController, IJsonApiController + { + private readonly ApplicationDbContext _dbContext; + + public TodoItemsController(JsonApiContext jsonApiContext, ResourceRepository resourceRepository, ApplicationDbContext applicationDbContext) : base(jsonApiContext, resourceRepository) + { + _dbContext = applicationDbContext; + } + + public override ObjectResult Get() + { + return new OkObjectResult(_dbContext.TodoItems.ToList()); + } + } +} diff --git a/JsonApiDotNetCoreExample/Controllers/ValuesController.cs b/JsonApiDotNetCoreExample/Controllers/ValuesController.cs deleted file mode 100644 index cf076106ae..0000000000 --- a/JsonApiDotNetCoreExample/Controllers/ValuesController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [Route("api/[controller]")] - public class ValuesController : Controller - { - // GET api/values - [HttpGet] - public IEnumerable Get() - { - return new string[] { "value1", "value2" }; - } - - // GET api/values/5 - [HttpGet("{id}")] - public string Get(int id) - { - return "value"; - } - - // POST api/values - [HttpPost] - public void Post([FromBody]string value) - { - } - - // PUT api/values/5 - [HttpPut("{id}")] - public void Put(int id, [FromBody]string value) - { - } - - // DELETE api/values/5 - [HttpDelete("{id}")] - public void Delete(int id) - { - } - } -} diff --git a/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.xproj b/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.xproj new file mode 100644 index 0000000000..a39d8479f7 --- /dev/null +++ b/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + d1fc385d-412b-4474-8406-a39e9aefd5b0 + JsonApiDotNetCoreExample + .\obj + .\bin\ + + + + 2.0 + + + \ No newline at end of file diff --git a/JsonApiDotNetCoreExample/Startup.cs b/JsonApiDotNetCoreExample/Startup.cs index 4d5fb5d104..5593be0f12 100644 --- a/JsonApiDotNetCoreExample/Startup.cs +++ b/JsonApiDotNetCoreExample/Startup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExample.Resources; @@ -39,10 +40,10 @@ public void ConfigureServices(IServiceCollection services) services.AddJsonApi(config => { config.SetDefaultNamespace("api/v1"); config.UseContext(); - config.DefineResourceMapping(dictionary => + config.UseController(); + config.AddResourceMapping(map => { - dictionary.Add(typeof(TodoItem), typeof(TodoItemResource)); - dictionary.Add(typeof(Person), typeof(PersonResource)); + map.ForMember("Name", opt => opt.MapFrom(src => $"{((Person)src).Name}_1")); }); }); } diff --git a/JsonApiDotNetCoreExample/project.json b/JsonApiDotNetCoreExample/project.json index 9a96a2251d..5d78b38044 100644 --- a/JsonApiDotNetCoreExample/project.json +++ b/JsonApiDotNetCoreExample/project.json @@ -1,9 +1,5 @@ { "dependencies": { - "Microsoft.NETCore.App": { - "version": "1.0.0", - "type": "platform" - }, "Microsoft.AspNetCore.Mvc": "1.0.0", "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0", "Microsoft.AspNetCore.Server.Kestrel": "1.0.0", @@ -15,7 +11,7 @@ "Microsoft.Extensions.Logging.Console": "1.0.0", "Microsoft.Extensions.Logging.Debug": "1.0.0", "Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0", - "JsonApiDotNetCore": "1.0.0", + "JsonApiDotNetCore": "0.1.0", "Npgsql.EntityFrameworkCore.PostgreSQL": "1.0.0", "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final" }, @@ -30,7 +26,14 @@ } }, "frameworks": { + "net451": {}, "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0", + "type": "platform" + } + }, "imports": [ "dotnet5.6", "portable-net45+win8" @@ -42,7 +45,9 @@ "preserveCompilationContext": true, "debugType": "portable", "copyToOutput": { - "include": ["appsettings.json"] + "include": [ + "appsettings.json" + ] } }, "runtimeOptions": { diff --git a/JsonApiDotNetCoreTests/Data/TestData/TestContext.cs b/JsonApiDotNetCoreTests/Data/TestData/TestContext.cs new file mode 100644 index 0000000000..a36a00c428 --- /dev/null +++ b/JsonApiDotNetCoreTests/Data/TestData/TestContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.Data.TestData +{ + public class TestContext : DbContext + { + public TestContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet TodoItems { get; set; } + } +} diff --git a/JsonApiDotNetCoreTests/Data/TestData/TodoItem.cs b/JsonApiDotNetCoreTests/Data/TestData/TodoItem.cs new file mode 100644 index 0000000000..9acbf979d8 --- /dev/null +++ b/JsonApiDotNetCoreTests/Data/TestData/TodoItem.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCoreTests.Data.TestData +{ + public class TodoItem + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/JsonApiDotNetCoreTests/Data/UnitTests/GenericDataAccessTests.cs b/JsonApiDotNetCoreTests/Data/UnitTests/GenericDataAccessTests.cs new file mode 100644 index 0000000000..d7419e719d --- /dev/null +++ b/JsonApiDotNetCoreTests/Data/UnitTests/GenericDataAccessTests.cs @@ -0,0 +1,66 @@ +using JsonApiDotNetCoreTests.Data.TestData; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; +using JsonApiDotNetCore.Data; +using System.Linq; +using System.Collections.Generic; + +namespace JsonApiDotNetCoreTests.Data.UnitTests +{ + public class GenericDataAccessTests + { + [Fact] + public void SingleOrDefault_Fetches_SingleItemFromContext() + { + // arrange + var data = new List + { + new TodoItem { Id = 1, Name = "AAA" }, + new TodoItem { Id = 2, Name = "BBB" } + }.AsQueryable(); + + var mockSet = new Mock>(); + mockSet.As>().Setup(m => m.Provider).Returns(data.Provider); + mockSet.As>().Setup(m => m.Expression).Returns(data.Expression); + mockSet.As>().Setup(m => m.ElementType).Returns(data.ElementType); + mockSet.As>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator()); + + var genericDataAccess = new GenericDataAccess(); + + // act + var item1 = genericDataAccess.SingleOrDefault(mockSet.Object, "Id", 1); + var item2 = genericDataAccess.SingleOrDefault(mockSet.Object, "Name", "BBB"); + + // assert + Assert.Equal(1, item1.Id); + Assert.Equal(2, item2.Id); + } + + [Fact] + public void Where_FetchesRecords_WherePropertyValueEquals_ProvidedValue() + { + // arrange + var data = new List + { + new TodoItem { Id = 1, Name = "AAA" }, + new TodoItem { Id = 2, Name = "AAA" }, + new TodoItem { Id = 3, Name = "BBB" } + }.AsQueryable(); + + var mockSet = new Mock>(); + mockSet.As>().Setup(m => m.Provider).Returns(data.Provider); + mockSet.As>().Setup(m => m.Expression).Returns(data.Expression); + mockSet.As>().Setup(m => m.ElementType).Returns(data.ElementType); + mockSet.As>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator()); + + var genericDataAccess = new GenericDataAccess(); + + // act + var items = genericDataAccess.Where(mockSet.Object, "Name", "AAA"); + + // assert + Assert.Equal(2, items.Count()); + } + } +} diff --git a/JsonApiDotNetCoreTests/Extensions/IServiceCollectionExtensionsTests.cs b/JsonApiDotNetCoreTests/Extensions/IServiceCollectionExtensionsTests.cs deleted file mode 100644 index 2aa6ff4a0d..0000000000 --- a/JsonApiDotNetCoreTests/Extensions/IServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Xunit; -using JsonApiDotNetCore.Routing; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCoreTests.Helpers; - -namespace JsonApiDotNetCoreTests.Extensions.UnitTests -{ - // see example explanation on xUnit.net website: - // https://xunit.github.io/docs/getting-started-dotnet-core.html - public class IServiceCollectionExtensionsTests - { - [Fact] - public void AddJsonApi_AddsRouterToServiceCollection() - { - // arrange - var serviceCollection = new ServiceCollection(); - - // act - serviceCollection.AddJsonApi(config => {}); - - // assert - Assert.True(serviceCollection.ContainsType(typeof(Router))); - } - } -} diff --git a/JsonApiDotNetCoreTests/Extensions/UnitTests/IServiceCollectionExtensionsTests.cs b/JsonApiDotNetCoreTests/Extensions/UnitTests/IServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..2d25871641 --- /dev/null +++ b/JsonApiDotNetCoreTests/Extensions/UnitTests/IServiceCollectionExtensionsTests.cs @@ -0,0 +1,44 @@ +using Xunit; +using JsonApiDotNetCore.Routing; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCoreTests.Helpers; +using Microsoft.EntityFrameworkCore; +using System; + +namespace JsonApiDotNetCoreTests.Extensions.UnitTests +{ + public class IServiceCollectionExtensionsTests + { + [Fact] + public void AddJsonApi_AddsRouterToServiceCollection() + { + // arrange + var serviceCollection = new ServiceCollection(); + + // act + serviceCollection.AddJsonApi(config => + { + config.UseContext(); + }); + + // assert + Assert.True(serviceCollection.ContainsType(typeof(IRouter))); + } + + [Fact] + public void AddJsonApi_ThrowsException_IfContextIsNotDefined() + { + // arrange + var serviceCollection = new ServiceCollection(); + + // act + var testAction = new Action(() => + { + serviceCollection.AddJsonApi(config => { }); + }); + + // assert + Assert.Throws(testAction); + } + } +} diff --git a/JsonApiDotNetCoreTests/Extensions/UnitTests/PathStringExtensionsTests.cs b/JsonApiDotNetCoreTests/Extensions/UnitTests/PathStringExtensionsTests.cs new file mode 100644 index 0000000000..e8926a6f07 --- /dev/null +++ b/JsonApiDotNetCoreTests/Extensions/UnitTests/PathStringExtensionsTests.cs @@ -0,0 +1,28 @@ +using Xunit; +using JsonApiDotNetCore.Extensions; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCoreTests.Extensions.UnitTests +{ + public class PathStringExtensionsTests + { + [Theory] + [InlineData("/todoItems", "todoItems", "/")] + [InlineData("/todoItems/1", "todoItems", "/1")] + [InlineData("/1/relationships/person", "1", "/relationships/person")] + [InlineData("/relationships/person", "relationships", "/person")] + public void ExtractFirstSegment_Removes_And_Returns_FirstSegementInPathString(string path, string expectedFirstSegment, string expectedRemainder) + { + // arrange + var pathString = new PathString(path); + + // act + PathString remainingPath; + var firstSegment = pathString.ExtractFirstSegment(out remainingPath); + + // assert + Assert.Equal(expectedFirstSegment, firstSegment); + Assert.Equal(expectedRemainder, remainingPath); + } + } +} diff --git a/JsonApiDotNetCoreTests/Extensions/UnitTests/StringExtensionsTests.cs b/JsonApiDotNetCoreTests/Extensions/UnitTests/StringExtensionsTests.cs new file mode 100644 index 0000000000..9d9b3710cd --- /dev/null +++ b/JsonApiDotNetCoreTests/Extensions/UnitTests/StringExtensionsTests.cs @@ -0,0 +1,32 @@ +using Xunit; +using JsonApiDotNetCore.Extensions; + +namespace JsonApiDotNetCoreTests.Extensions.UnitTests +{ + public class StringExtensionsTests + { + [Theory] + [InlineData("TodoItem", "todoItem")] + public void ToCamelCase_ConvertsString_ToCamelCase(string input, string expectedOutput) + { + // arrange + // act + var result = input.ToCamelCase(); + + // assert + Assert.Equal(expectedOutput, result); + } + + [Theory] + [InlineData("todoItem", "TodoItem")] + public void ToProperCase_ConvertsString_ToProperCase(string input, string expectedOutput) + { + // arrange + // act + var result = input.ToProperCase(); + + // assert + Assert.Equal(expectedOutput, result); + } + } +} diff --git a/JsonApiDotNetCoreTests/Routing/UnitTests/Query/QuerySetTests.cs b/JsonApiDotNetCoreTests/Routing/UnitTests/Query/QuerySetTests.cs new file mode 100644 index 0000000000..ce4e4a797a --- /dev/null +++ b/JsonApiDotNetCoreTests/Routing/UnitTests/Query/QuerySetTests.cs @@ -0,0 +1,34 @@ +using Xunit; +using JsonApiDotNetCore.Routing.Query; +using Microsoft.AspNetCore.Http.Internal; +using System.Collections.Generic; +using Microsoft.Extensions.Primitives; +using System.Linq; + +namespace JsonApiDotNetCoreTests.Routing.UnitTests.Query +{ + public class QuerySetTests + { + [Fact] + public void QuerySetConstructor_BuildsObject_FromQueryCollection() + { + // arrange + var queries = new Dictionary(); + queries.Add("filter[id]", "1"); + queries.Add("sort", new StringValues(new string[] { "-id", "name" })); + var queryCollection = new QueryCollection(queries); + + // act + var querySet = new QuerySet(queryCollection); + + // assert + Assert.NotNull(querySet.Filter); + Assert.Equal("Id", querySet.Filter.PropertyName); + Assert.Equal("1", querySet.Filter.PropertyValue); + + Assert.NotNull(querySet.SortParameters); + Assert.NotNull(querySet.SortParameters.SingleOrDefault(x=> x.Direction == SortDirection.Descending && x.PropertyName == "Id")); + Assert.NotNull(querySet.SortParameters.SingleOrDefault(x=> x.Direction == SortDirection.Ascending && x.PropertyName == "Name")); + } + } +} diff --git a/JsonApiDotNetCoreTests/Routing/UnitTests/RouterTests.cs b/JsonApiDotNetCoreTests/Routing/UnitTests/RouterTests.cs index a09b25d0cb..064027fbf7 100644 --- a/JsonApiDotNetCoreTests/Routing/UnitTests/RouterTests.cs +++ b/JsonApiDotNetCoreTests/Routing/UnitTests/RouterTests.cs @@ -46,7 +46,7 @@ public void HandleJsonApiRoute_CallsGetMethod_ForGetRequest() httpResponseMock.Setup(r => r.Body).Returns(new MemoryStream()); httpContextMock.Setup(c => c.Response).Returns(httpResponseMock.Object); - var route = new Route(null, "GET", null, null); + var route = new Route(null, "GET", null, null, null); var routeBuilderMock = new Mock(); routeBuilderMock.Setup(rb => rb.BuildFromRequest(null)).Returns(route); @@ -79,7 +79,7 @@ public void HandleJsonApiRoute_CallsGetIdMethod_ForGetIdRequest() httpResponseMock.Setup(r => r.Body).Returns(new MemoryStream()); httpContextMock.Setup(c => c.Response).Returns(httpResponseMock.Object); - var route = new Route(null, "GET", resourceId, null); + var route = new Route(null, "GET", resourceId, null, null); var routeBuilderMock = new Mock(); routeBuilderMock.Setup(rb => rb.BuildFromRequest(null)).Returns(route); diff --git a/JsonApiDotNetCoreTests/project.json b/JsonApiDotNetCoreTests/project.json index 1722f539a4..92e9ee614d 100644 --- a/JsonApiDotNetCoreTests/project.json +++ b/JsonApiDotNetCoreTests/project.json @@ -1,8 +1,8 @@ { - "version": "1.0.0-*", + "version": "1.0.0-alpha", "testRunner": "xunit", "dependencies": { - "JsonApiDotNetCore": "1.0.0", + "JsonApiDotNetCore": "0.1.0", "dotnet-test-xunit": "2.2.0-preview2-build1029", "xunit": "2.2.0-beta2-build3300", "moq": "4.6.38-alpha" @@ -32,7 +32,7 @@ "defaultNamespace": "JsonApiDotNetCoreTests" }, "tools": { - "Microsoft.DotNet.Watcher.Tools": { + "Microsoft.DotNet.Watcher.Tools": { "version": "1.0.0-*", "imports": "portable-net451+win8" } diff --git a/README.md b/README.md index 0c3c41d2b0..25f44364d3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,22 @@ # JSON API .Net Core +[![Build status](https://ci.appveyor.com/api/projects/status/9fvgeoxdikwkom10?svg=true)](https://ci.appveyor.com/project/jaredcnance/json-api-dotnet-core) +[![Travis](https://img.shields.io/travis/Research-Institute/json-api-dotnet-core.svg?maxAge=3600&label=travis)](https://travis-ci.org/Research-Institute/json-api-dotnet-core) + JSON API Spec Conformance: **Non Conforming** -Go [here](https://github.com/Research-Institute/json-api-dotnet-core/wiki/Request-Examples) to see examples of HTTP requests and responses +## Installation + +For pre-releases, add the [MyGet](https://www.myget.org/feed/Details/research-institute) package feed +(https://www.myget.org/F/research-institute/api/v3/index.json) +to your nuget configuration. + +NuGet packages will be published at v0.1.0. ## Usage +Go [here](https://github.com/Research-Institute/json-api-dotnet-core/wiki/Request-Examples) to see examples of HTTP requests and responses + - Configure the service: ``` @@ -16,11 +27,6 @@ services.AddDbContext(options => services.AddJsonApi(config => { config.SetDefaultNamespace("api/v1"); config.UseContext(); - config.DefineResourceMapping(dictionary => - { - dictionary.Add(typeof(TodoItem), typeof(TodoItemResource)); - dictionary.Add(typeof(Person), typeof(PersonResource)); - }); }); ``` @@ -30,12 +36,92 @@ services.AddJsonApi(config => { app.UseJsonApi(); ``` +## Specifying The Presenter / ViewModel + + - When you define a model, you **MUST** specify the associated resource class using the `JsonApiResource` attribute. + - The specified resource class **MUST** implement `IJsonApiResource`. + +The resource class defines how the model will be exposed to client applications. + +For example: + +``` +[JsonApiResource(typeof(PersonResource))] +public class Person +{ + public int Id { get; set; } + public string Name { get; set; } + public string SomethingSecret { get; set; } + public virtual List TodoItems { get; set; } +} + +public class PersonResource : IJsonApiResource +{ + public string Id { get; set; } + public string Name { get; set; } +} +``` + +We use [AutoMapper](http://automapper.org/) to map from the model class to the resource class. +The below snippet shows how you can specify a custom mapping expression in your `Startup` class that will append `_1` to the resource name. +Check out [AutoMapper's Wiki](https://github.com/AutoMapper/AutoMapper/wiki) for detailed mapping options. + +``` +services.AddJsonApi(config => { + ... + config.AddResourceMapping(map => + { + // resource.Name = model.Name + "_1" + map.ForMember("Name", opt => opt.MapFrom(src => $"{((Person)src).Name}_1")); + }); + ... +}); +``` + +## Overriding controllers + +You can define your own controllers that implement the `IJsonApiController` like so: + +``` +services.AddJsonApi(config => { + ... + config.UseController(); + ... +}); +``` + +The controller **MUST** implement `IJsonApiController`, and it **MAY** inherit from [JsonApiController](https://github.com/Research-Institute/json-api-dotnet-core/blob/master/JsonApiDotNetCore/Controllers/JsonApiController.cs). +Constructor dependency injection will work like normal. +Any services added in your `Startup.ConfigureServices()` method will be injected into the constructor parameters. + +``` +public class TodoItemsController : JsonApiController, IJsonApiController +{ + private ApplicationDbContext _dbContext; + + public TodoItemsController(IJsonApiContext jsonApiContext, ResourceRepository resourceRepository, ApplicationDbContext applicationDbContext) + : base(jsonApiContext, resourceRepository) + { + _dbContext = applicationDbContext; + } + + public override ObjectResult Get() + { + return new OkObjectResult(_dbContext.TodoItems.ToList()); + } +} +``` + +You can access the HttpContext from [IJsonApiContext](https://github.com/Research-Institute/json-api-dotnet-core/blob/master/JsonApiDotNetCore/Abstractions/IJsonApiContext.cs). + + ## References [JsonApi Specification](http://jsonapi.org/) -## Current Assumptions +## Current Entity Requirements - Using Entity Framework - All entities in the specified context should have controllers - All entities are served from the same namespace (i.e. 'api/v1') - All entities have a primary key "Id" and not "EntityId" +- All entity names are proper case, "Id" not "id" diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000000..32995500a4 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,29 @@ +version: '{build}' +pull_requests: + do_not_increment_build_number: true +branches: + only: + - master +nuget: + disable_publish_on_pr: true +build_script: +- ps: .\Build.ps1 +test: off +artifacts: +- path: .\artifacts\**\*.nupkg + name: NuGet +deploy: +- provider: NuGet + server: https://www.myget.org/F/research-institute/api/v2/package + api_key: + secure: 6CeYcZ4Ze+57gxfeuHzqP6ldbUkPtF6pfpVM1Gw/K2jExFrAz763gNAQ++tiacq3 + skip_symbols: true + on: + branch: master +- provider: NuGet + name: production + api_key: + secure: /fsEOgG4EdtNd6DPmko9h3NxQwx1IGDcFreGTKd2KA56U2KEkpX/L/pCGpCIEf2s + on: + branch: master + appveyor_repo_tag: true diff --git a/build.sh b/build.sh new file mode 100755 index 0000000000..e9eddc94a9 --- /dev/null +++ b/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +#exit if any command fails +set -e + +artifactsFolder="./artifacts" + +if [ -d $artifactsFolder ]; then + rm -R $artifactsFolder +fi + +dotnet restore + +dotnet test ./JsonApiDotNetCoreTests -c Release -f netcoreapp1.0 + +revision=${TRAVIS_JOB_ID:=1} +revision=$(printf "%04d" $revision) + +dotnet pack ./JsonApiDotNetCore -c Release -o ./artifacts --version-suffix=$revision