Skip to content

RFC: Resource to Entity Mapping #112

Closed
@jaredcnance

Description

@jaredcnance

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 the DbContext
  • 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>

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCRequest for comments. These issues have major impact on the direction of the library.enhancementhelp wanted

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions