Description
Start Date: N/A
Status: WIP
RFC PR: N/A
Summary
Allow models to map to more than 1 resource.
Terminology
- Entity refers to the database model. For EF users this is the POCO class that corresponds to a
DbSet<>
on theDbContext
- Resource refers to the model exposed by the API
Motivation
Currently, there is a 1:1 relationship between the database entity and the exposed resource. There may be a need for different forms of an entity to be exposed. I see two different needs:
1
entity :N
resources (one-to-many)N
entities :1
resource (many-to-one)
I believe the second need is already supported since this would require a custom implementation at the repository layer. The first however should be supported out-of-the-box and it currently is not.
Detailed Design
I think this is a relatively simple solution and that is to change the current dependency graph from:
|- Controller<TEntity>
|--- IResourceService<TEntity>
|------ IRepository<TEntity>
to
|- Controller<TResource>
|--- IResourceService<TResource, TEntity>
|------ IRepository<TEntity>
This shows that mapping would take place in the service layer. An application could define a mapping that, if exists, would be used to map the final entity result (post-database call) into the resource. For this, I think it is safe to choose AutoMapper as the standard mapping tool. Users would then define their maps using the ContextGraphBuilder
:
options.BuildContextGraph((builder) => {
builder.UseAutoMapper(AutoMapperConfig.GetMapper());
});
and the controller might look like:
public class UsersController : JsonApiController<User>
{
public UsersController(
IJsonApiContext jsonApiContext,
IResourceService<User, Person> resourceService,
ILoggerFactory loggerFactory)
: base(jsonApiContext, resourceService, loggerFactory)
{ }
}
ContextGraphBuilder
The ContextGraphBuilder will have a dictionary of mappings:
Dictionary<Type, List<IResourceMap>> _resourceMaps;
Calling builder.UseAutoMapper(AutoMapperConfig.GetMapper()
will create the resource maps for every entity. All ContextEntities will be assigned an EmptyMapping
which will handle calls for mapping TResource
to TEntity
when TResource == TEntity
:
_resourceMaps[typeof(UnMappedEntity)] = new List<IResourceMap> { new EmptyMapping() };
As the ContextEntities are created, they will be assigned their maps:
_entities.Add(new ContextEntity
{
Mappings = _resourceMaps[entityType],
// ...
});
Resource Services
In order to maintain backwards compatibility, the current resource service definitions with generic parameters should remain available. We can define an additional generic parameter overload:
public interface IResourceService<TEntity> : IResourceService<TEntity, int>
{}
public interface IResourceService<TEntity, TId> : IResourceService<TEntity, TEntity, TId>
{}
public interface IResourceService<TResource, TEntity, TId>
{
// ...
}
A IResourceService
implementation should then perform the mapping prior to returning the resource:
public EntityResourceService(
IJsonApiContext jsonApiContext,
// ...
)
{
_jsonApiContext = jsonApiContext;
_mapper = jsonApiContext.ContextGraph.GetMapper<TResource, TEntity>();
// ...
}
public async Task<T> GetAsync(TId id)
{
// ...
var entity = //...
var resource = _mapper.Map(entity);
return entity;
}
Automapper IResourceMap
public class AutoMapperService<TResource, TEntity>
: IResourceMap<TResource, TEntity>
{
private readonly IMapper _mapper;
public AutoMapperService(IMapper mapper) {
_mapper = mapper;
}
public TResource Map(TEntity entity)
=> _mapper.Map<TEntity, TResource>(entity);
public IEnumerable<TResource> Map(IEnumerable<TEntity> entities)
=> _mapper.Map<IEnumerable<TEntity>, IEnumerable<TResource>>(entities);
public TEntity Map(TResource resource)
=> _mapper.Map<TResource, TEntity>(resource);
public string GetEntityRelationshipName(string resourceRelationshipName) {
// ...
}
}
Unspecified Mappings
If a user defines a controller but no mapping has been defined, a 500 error will be returned.
Relationships
Mappings will be used to translate relationship names. In the following example, a request for include=owner
would map to Entity.User
:
Mapper.Initialize(cfg =>
cfg.CreateMap<Entity, Resource>()
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.User)));
// ...
public class Entity : Identifiable {
public virtual Person User { get; set; }
}
public class Resource : Identifiable {
[HasOne("owner")]
public Person Owner { get; set; }
}
Validation of these definitions should occur on app startup.
Constraints
The types TResource
and TEntity
MUST implement IIdentifiable<TId>
where TId
is of the same type for both classes:
public interface IResourceService<TResource, TEntity, TId>
where TResource : class, IIdentifiable<TId>
where TEntity : class, IIdentifiable<TId>