Skip to content

Commit d608196

Browse files
authored
Replace AutoMapper with hand-written entity mappers (#136)
1 parent 9663514 commit d608196

21 files changed

Lines changed: 495 additions & 159 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ Guidance for agents working in this repository.
1010
| `src/Czertainly.Auth/Controllers` | REST API controllers — `UsersController`, `RolesController`, `PermissionsController`, `ResourcesController`, `ActionsController`. |
1111
| `src/Czertainly.Auth/Services` | Business logic behind the controllers (`UserService`, `RoleService`, `PermissionService`, `ResourceService`, `ActionService`) and their interfaces. |
1212
| `src/Czertainly.Auth/Data` | `AuthDbContext`; repository contracts (`Data/Contracts`) and implementations (`Data/Repositiories`, sic); EF Core migrations (`Data/Migrations`). |
13-
| `src/Czertainly.Auth/Models` | DTOs (`Models/Dto`), EF Core entities and fluent configurations (`Models/Entities`, `Models/Entities/Configurations`), AutoMapper profiles (`Models/Mappings`), options classes (`Models/Config`). |
14-
| `src/Czertainly.Auth/Common` | Cross-cutting concerns: paging/query-filter abstractions (`Common/Data`), domain exceptions plus the global exception middleware (`Common/Exceptions`), model-validation filters (`Common/Filters`), extension methods (`Common/Extensions`), display-name helpers (`Common/Helpers`), shared DTO/entity base types (`Common/Models`), and the generic `CrudService<TEntity, TResponseDto, TDetailResponseDto>` base (`Common/Services`). |
13+
| `src/Czertainly.Auth/Models` | DTOs (`Models/Dto`), EF Core entities and fluent configurations (`Models/Entities`, `Models/Entities/Configurations`), hand-written entity/DTO mappers and their `IEntityMapper` adapters (`Models/Mappings`), options classes (`Models/Config`). |
14+
| `src/Czertainly.Auth/Common` | Cross-cutting concerns: paging/query-filter abstractions (`Common/Data`), domain exceptions plus the global exception middleware (`Common/Exceptions`), model-validation filters (`Common/Filters`), extension methods (`Common/Extensions`), display-name helpers (`Common/Helpers`), shared DTO/entity base types (`Common/Models`), query/paging mappers (`Common/Mappings`), and the generic `CrudService<TEntity, TResponseDto, TDetailResponseDto>` base plus its `IEntityMapper` abstraction (`Common/Services`). |
1515
| `src/Czertainly.Auth/Properties/launchSettings.json` | Local `dotnet run` launch profiles. |
1616
| `docker/` | Files copied into the runtime image: `entry.sh` (container entrypoint), `update-cacerts.sh`, `static-functions`. |
1717
| `hooks/` | Legacy Docker Hub automated-build hook scripts (`build`, `post_push`). |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Czertainly.Auth.Common.Data;
2+
using Czertainly.Auth.Common.Exceptions;
3+
using Czertainly.Auth.Common.Models.Dto;
4+
5+
namespace Czertainly.Auth.Common.Mappings
6+
{
7+
public static class CommonMapper
8+
{
9+
/// <summary>
10+
/// Translates the wire level query request into repository query parameters. A leading '-' in SortBy requests
11+
/// descending order and the remainder is capitalized, because the sorting is applied through a dynamic
12+
/// <c>OrderBy</c> that addresses the entity property by its CLR (PascalCase) name.
13+
/// </summary>
14+
/// <remarks>
15+
/// An absent SortBy - null, empty or whitespace-only - is a legitimate request for no ordering and yields a null
16+
/// sort field, which the repository reads as "do not order". A SortBy that is present but cannot name a property,
17+
/// which is only a '-' with nothing but whitespace behind it, is malformed rather than absent and is rejected.
18+
/// </remarks>
19+
/// <exception cref="InvalidFormatException">
20+
/// SortBy carries the descending prefix but no property name behind it.
21+
/// </exception>
22+
public static QueryStringParameters ToQueryStringParameters(this IQueryRequestDto dto)
23+
{
24+
var sortBy = dto.SortBy ?? string.Empty;
25+
var descending = sortBy.StartsWith('-');
26+
var sortField = descending ? sortBy[1..] : sortBy;
27+
28+
// Line endings are stripped because the message reaches the log through the exception middleware.
29+
if (descending && string.IsNullOrWhiteSpace(sortField))
30+
{
31+
throw new InvalidFormatException($"Invalid sortBy value '{sortBy.ReplaceLineEndings(string.Empty)}': the descending prefix '-' must be followed by a property name.");
32+
}
33+
34+
return new QueryStringParameters
35+
{
36+
Page = dto.Page,
37+
PageSize = dto.PageSize,
38+
SortBy = string.IsNullOrWhiteSpace(sortField) ? null : char.ToUpper(sortField[0]) + sortField[1..],
39+
SortAscending = !descending,
40+
};
41+
}
42+
43+
public static PagingMetadata ToPagingMetadata(this IPagedList pagedList)
44+
{
45+
return new PagingMetadata
46+
{
47+
CurrentPage = pagedList.CurrentPage,
48+
PageSize = pagedList.PageSize,
49+
TotalCount = pagedList.TotalCount,
50+
TotalPages = pagedList.TotalPages,
51+
HasPrevious = pagedList.HasPrevious,
52+
HasNext = pagedList.HasNext,
53+
};
54+
}
55+
}
56+
}

src/Czertainly.Auth/Common/Services/CrudService.cs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
using AutoMapper;
2-
using Czertainly.Auth.Common.Data;
3-
using Czertainly.Auth.Common.Data.Repositories;
1+
using Czertainly.Auth.Common.Data.Repositories;
2+
using Czertainly.Auth.Common.Mappings;
43
using Czertainly.Auth.Common.Models.Dto;
54
using Czertainly.Auth.Common.Models.Entities;
65
using Czertainly.Auth.Data.Contracts;
@@ -12,12 +11,12 @@ public abstract class CrudService<TEntity, TResponseDto, TDetailResponseDto> : I
1211
where TResponseDto : ICrudResponseDto, new()
1312
where TDetailResponseDto : ICrudResponseDto, new()
1413
{
15-
protected readonly IMapper _mapper;
14+
protected readonly IEntityMapper<TEntity, TResponseDto, TDetailResponseDto> _mapper;
1615
protected readonly ILogger _logger;
1716
protected readonly IBaseRepository<TEntity> _repository;
1817
protected readonly IRepositoryManager _repositoryManager;
1918

20-
public CrudService(IRepositoryManager repositoryManager, IBaseRepository<TEntity> repository, IMapper mapper, ILogger logger)
19+
protected CrudService(IRepositoryManager repositoryManager, IBaseRepository<TEntity> repository, IEntityMapper<TEntity, TResponseDto, TDetailResponseDto> mapper, ILogger logger)
2120
{
2221
_mapper = mapper;
2322
_logger = logger;
@@ -26,42 +25,42 @@ public CrudService(IRepositoryManager repositoryManager, IBaseRepository<TEntity
2625
}
2726
public virtual async Task<PagedResponse<TResponseDto>> GetAsync(IQueryRequestDto dto)
2827
{
29-
var queryParams = _mapper.Map<QueryStringParameters>(dto);
30-
var users = await _repository.GetAllAsync(queryParams);
28+
var queryParams = dto.ToQueryStringParameters();
29+
var entities = await _repository.GetAllAsync(queryParams);
3130

3231
return new PagedResponse<TResponseDto>
3332
{
34-
Data = _mapper.Map<List<TResponseDto>>(users),
35-
Links = _mapper.Map<PagingMetadata>(users),
33+
Data = entities.Select(entity => _mapper.ToDto(entity)).ToList(),
34+
Links = entities.ToPagingMetadata(),
3635
};
3736
}
3837

3938
public virtual async Task<TDetailResponseDto> CreateAsync(ICrudRequestDto dto)
4039
{
41-
var entity = _mapper.Map<TEntity>(dto);
40+
var entity = _mapper.ToEntity(dto);
4241
_repository.Create(entity);
4342
await _repositoryManager.SaveAsync();
4443

4544
entity = await _repository.GetByKeyAsync(entity.Uuid);
46-
return _mapper.Map<TDetailResponseDto>(entity);
45+
return _mapper.ToDetailDto(entity);
4746
}
4847

4948
public virtual async Task<TDetailResponseDto> GetDetailAsync(Guid key)
5049
{
5150
var entity = await _repository.GetByKeyAsync(key);
5251

53-
return _mapper.Map<TDetailResponseDto>(entity);
52+
return _mapper.ToDetailDto(entity);
5453
}
5554

5655
public virtual async Task<TDetailResponseDto> UpdateAsync(Guid key, ICrudRequestDto dto)
5756
{
5857
var entity = await _repository.GetByKeyAsync(key);
59-
_mapper.Map(dto, entity);
58+
_mapper.ApplyUpdate(dto, entity);
6059

6160
//await _repository.UpdateAsync(key, entity);
6261
await _repositoryManager.SaveAsync();
6362

64-
return _mapper.Map<TDetailResponseDto>(entity);
63+
return _mapper.ToDetailDto(entity);
6564
}
6665

6766
public virtual async Task DeleteAsync(Guid key)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Czertainly.Auth.Common.Models.Dto;
2+
using Czertainly.Auth.Common.Models.Entities;
3+
4+
namespace Czertainly.Auth.Common.Services
5+
{
6+
/// <summary>
7+
/// Entity specific mapping operations needed by <see cref="CrudService{TEntity, TResponseDto, TDetailResponseDto}"/>.
8+
/// Implementations are stateless adapters over the hand-written static mappers, so the generic CRUD base does not
9+
/// need to know any concrete entity or DTO type.
10+
/// </summary>
11+
/// <remarks>
12+
/// The two response DTOs are covariant because they only ever flow out of this interface. TEntity cannot be, since
13+
/// <see cref="ApplyUpdate"/> consumes it while <see cref="ToEntity"/> produces it. Neither response DTO carries a
14+
/// <c>new()</c> constraint: nothing here constructs them, so requiring instantiability would only narrow the type
15+
/// arguments a covariant conversion can reach.
16+
/// </remarks>
17+
public interface IEntityMapper<TEntity, out TResponseDto, out TDetailResponseDto>
18+
where TEntity : class, IBaseEntity, new()
19+
where TResponseDto : ICrudResponseDto
20+
where TDetailResponseDto : ICrudResponseDto
21+
{
22+
/// <summary>
23+
/// Builds a new entity from a create request. Throws <see cref="ArgumentException"/> when the runtime type of
24+
/// the request is not the one the entity is created from.
25+
/// </summary>
26+
TEntity ToEntity(ICrudRequestDto dto);
27+
28+
/// <summary>
29+
/// Copies the updatable members of an update request onto an already loaded entity. Throws
30+
/// <see cref="ArgumentException"/> when the runtime type of the request is not the one the entity is updated from.
31+
/// </summary>
32+
void ApplyUpdate(ICrudRequestDto dto, TEntity entity);
33+
34+
TResponseDto ToDto(TEntity entity);
35+
36+
TDetailResponseDto ToDetailDto(TEntity entity);
37+
}
38+
}

src/Czertainly.Auth/Czertainly.Auth.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
</PropertyGroup>
1313

1414
<ItemGroup>
15-
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
1615
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" />
1716
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
1817
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.10" />
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Czertainly.Auth.Common.Models.Dto;
2+
using Czertainly.Auth.Common.Services;
3+
using Czertainly.Auth.Models.Dto;
4+
using ActionEntity = Czertainly.Auth.Models.Entities.Action;
5+
6+
namespace Czertainly.Auth.Models.Mappings
7+
{
8+
public static class ActionMapper
9+
{
10+
/// <summary>
11+
/// An action create request carries exactly the members an update request carries, so the field list lives in
12+
/// <see cref="ApplyTo"/> alone.
13+
/// </summary>
14+
public static ActionEntity ToEntity(this ActionRequestDto dto)
15+
{
16+
var action = new ActionEntity();
17+
dto.ApplyTo(action);
18+
19+
return action;
20+
}
21+
22+
public static void ApplyTo(this ActionRequestDto dto, ActionEntity action)
23+
{
24+
action.Name = dto.Name!;
25+
action.DisplayName = dto.DisplayName!;
26+
}
27+
28+
public static ActionDto ToDto(this ActionEntity action)
29+
{
30+
return new ActionDto
31+
{
32+
Uuid = action.Uuid,
33+
Name = action.Name,
34+
DisplayName = action.DisplayName,
35+
};
36+
}
37+
}
38+
39+
/// <summary>
40+
/// Actions have no separate detail representation, so the list and the detail response are the same DTO.
41+
/// </summary>
42+
public sealed class ActionEntityMapper : IEntityMapper<ActionEntity, ActionDto, ActionDto>
43+
{
44+
public static readonly ActionEntityMapper Instance = new();
45+
46+
public ActionEntity ToEntity(ICrudRequestDto dto)
47+
{
48+
if (dto is not ActionRequestDto actionRequestDto) throw new ArgumentException($"Cannot create action from '{dto.GetType().Name}'.", nameof(dto));
49+
50+
return ActionMapper.ToEntity(actionRequestDto);
51+
}
52+
53+
public void ApplyUpdate(ICrudRequestDto dto, ActionEntity entity)
54+
{
55+
if (dto is not ActionRequestDto actionRequestDto) throw new ArgumentException($"Cannot update action from '{dto.GetType().Name}'.", nameof(dto));
56+
57+
ActionMapper.ApplyTo(actionRequestDto, entity);
58+
}
59+
60+
public ActionDto ToDto(ActionEntity entity) => ActionMapper.ToDto(entity);
61+
62+
public ActionDto ToDetailDto(ActionEntity entity) => ActionMapper.ToDto(entity);
63+
}
64+
}

src/Czertainly.Auth/Models/Mappings/ActionProfile.cs

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/Czertainly.Auth/Models/Mappings/CommonProfile.cs

Lines changed: 0 additions & 19 deletions
This file was deleted.

src/Czertainly.Auth/Models/Mappings/PermissionProfile.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Czertainly.Auth.Common.Models.Dto;
2+
using Czertainly.Auth.Common.Services;
3+
using Czertainly.Auth.Models.Dto;
4+
using Czertainly.Auth.Models.Entities;
5+
6+
namespace Czertainly.Auth.Models.Mappings
7+
{
8+
public static class ResourceMapper
9+
{
10+
/// <summary>
11+
/// A resource create request carries exactly the members an update request carries, so the field list lives in
12+
/// <see cref="ApplyTo"/> alone.
13+
/// </summary>
14+
public static Resource ToEntity(this ResourceRequestDto dto)
15+
{
16+
var resource = new Resource();
17+
dto.ApplyTo(resource);
18+
19+
return resource;
20+
}
21+
22+
public static void ApplyTo(this ResourceRequestDto dto, Resource resource)
23+
{
24+
resource.Name = dto.Name!;
25+
resource.DisplayName = dto.DisplayName!;
26+
resource.ListObjectsEndpoint = dto.ListObjectsEndpoint;
27+
}
28+
29+
public static ResourceDto ToDto(this Resource resource)
30+
{
31+
return new ResourceDto
32+
{
33+
Uuid = resource.Uuid,
34+
Name = resource.Name,
35+
DisplayName = resource.DisplayName,
36+
ListObjectsEndpoint = resource.ListObjectsEndpoint,
37+
};
38+
}
39+
40+
public static ResourceDetailDto ToDetailDto(this Resource resource)
41+
{
42+
return new ResourceDetailDto
43+
{
44+
Uuid = resource.Uuid,
45+
Name = resource.Name,
46+
DisplayName = resource.DisplayName,
47+
ListObjectsEndpoint = resource.ListObjectsEndpoint,
48+
// Actions is loaded through the repository's detail includes or an explicit Include on every path
49+
// reaching this mapper - see UserMapper.ToDetailDto for the same reasoning.
50+
Actions = resource.Actions?.Select(action => action.ToDto()).ToList() ?? [],
51+
};
52+
}
53+
}
54+
55+
public sealed class ResourceEntityMapper : IEntityMapper<Resource, ResourceDto, ResourceDetailDto>
56+
{
57+
public static readonly ResourceEntityMapper Instance = new();
58+
59+
public Resource ToEntity(ICrudRequestDto dto)
60+
{
61+
if (dto is not ResourceRequestDto resourceRequestDto) throw new ArgumentException($"Cannot create resource from '{dto.GetType().Name}'.", nameof(dto));
62+
63+
return ResourceMapper.ToEntity(resourceRequestDto);
64+
}
65+
66+
public void ApplyUpdate(ICrudRequestDto dto, Resource entity)
67+
{
68+
if (dto is not ResourceRequestDto resourceRequestDto) throw new ArgumentException($"Cannot update resource from '{dto.GetType().Name}'.", nameof(dto));
69+
70+
ResourceMapper.ApplyTo(resourceRequestDto, entity);
71+
}
72+
73+
public ResourceDto ToDto(Resource entity) => ResourceMapper.ToDto(entity);
74+
75+
public ResourceDetailDto ToDetailDto(Resource entity) => ResourceMapper.ToDetailDto(entity);
76+
}
77+
}

0 commit comments

Comments
 (0)