Skip to content

Commit 1328834

Browse files
committed
refactor: migrate from AutoMapper to Mapperly
- Replace AutoMapper (runtime reflection) with Mapperly (compile-time source generation) - Add Riok.Mapperly 4.2.1 to Exceptionless.Web - Remove AutoMapper 14.0.0 from Exceptionless.Core Breaking changes: - Controllers now use abstract mapping methods instead of generic MapAsync<T> - Base controllers require derived classes to implement MapToModel, MapToViewModel, MapToViewModels Mapping structure: - Created dedicated mapper files per type in src/Exceptionless.Web/Mapping/ - OrganizationMapper: NewOrganization -> Organization, Organization -> ViewOrganization - ProjectMapper: NewProject -> Project, Project -> ViewProject - TokenMapper: NewToken -> Token, Token -> ViewToken - UserMapper: User -> ViewUser - WebHookMapper: NewWebHook -> WebHook - InvoiceMapper: Stripe.Invoice -> InvoiceGridModel - ApiMapper facade delegates to individual mappers Testing: - Added comprehensive unit tests for all mappers (29 tests) - Tests follow backend-testing skill patterns Benefits: - Compile-time type safety for mappings - Better performance (no runtime reflection) - Cleaner separation of concerns with per-type mappers
1 parent 4a4a2f3 commit 1328834

27 files changed

Lines changed: 967 additions & 120 deletions

src/Exceptionless.Core/Bootstrapper.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Text.Json;
2-
using AutoMapper;
32
using Exceptionless.Core.Authentication;
43
using Exceptionless.Core.Billing;
54
using Exceptionless.Core.Configuration;
@@ -197,21 +196,6 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
197196
services.AddSingleton<StackService>();
198197

199198
services.AddTransient<IDomainLoginProvider, ActiveDirectoryLoginProvider>();
200-
201-
services.AddTransient<Profile, CoreMappings>();
202-
services.AddSingleton<IMapper>(s =>
203-
{
204-
var profiles = s.GetServices<Profile>();
205-
var c = new MapperConfiguration(cfg =>
206-
{
207-
cfg.ConstructServicesUsing(s.GetRequiredService);
208-
209-
foreach (var profile in profiles)
210-
cfg.AddProfile(profile);
211-
});
212-
213-
return c.CreateMapper();
214-
});
215199
}
216200

217201
public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger)

src/Exceptionless.Core/Exceptionless.Core.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
<EmbeddedResource Include="Mail\Templates\user-password-reset.html" />
2121
</ItemGroup>
2222
<ItemGroup>
23-
<PackageReference Include="AutoMapper" Version="14.0.0" />
2423
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="4.0.1" />
2524
<PackageReference Include="FluentValidation" Version="12.1.1" />
2625
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta1.7" />

src/Exceptionless.Core/Models/CoreMappings.cs

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
using AutoMapper;
21
using Exceptionless.Core;
32
using Exceptionless.Core.Extensions;
43
using Exceptionless.Core.Jobs.WorkItemHandlers;
5-
using Exceptionless.Core.Models;
6-
using Exceptionless.Core.Models.Data;
74
using Exceptionless.Core.Queues.Models;
85
using Exceptionless.Web.Hubs;
9-
using Exceptionless.Web.Models;
6+
using Exceptionless.Web.Mapping;
107
using Foundatio.Extensions.Hosting.Startup;
118
using Foundatio.Jobs;
129
using Foundatio.Messaging;
13-
using Token = Exceptionless.Core.Models.Token;
1410

1511
namespace Exceptionless.Web;
1612

@@ -21,7 +17,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
2117
services.AddSingleton<WebSocketConnectionManager>();
2218
services.AddSingleton<MessageBusBroker>();
2319

24-
services.AddTransient<Profile, ApiMappings>();
20+
services.AddSingleton<ApiMapper>();
2521

2622
Core.Bootstrapper.RegisterServices(services, appOptions);
2723
Insulation.Bootstrapper.RegisterServices(services, appOptions, appOptions.RunJobsInProcess);
@@ -46,34 +42,4 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
4642
services.AddSingleton<EnqueueOrganizationNotificationOnPlanOverage>();
4743
services.AddStartupAction<EnqueueOrganizationNotificationOnPlanOverage>();
4844
}
49-
50-
public class ApiMappings : Profile
51-
{
52-
public ApiMappings(TimeProvider timeProvider)
53-
{
54-
CreateMap<UserDescription, EventUserDescription>();
55-
56-
CreateMap<NewOrganization, Organization>();
57-
CreateMap<Organization, ViewOrganization>().AfterMap((o, vo) =>
58-
{
59-
vo.IsOverMonthlyLimit = o.IsOverMonthlyLimit(timeProvider);
60-
});
61-
62-
CreateMap<Stripe.Invoice, InvoiceGridModel>().AfterMap((si, igm) =>
63-
{
64-
igm.Id = igm.Id.Substring(3);
65-
igm.Date = si.Created;
66-
});
67-
68-
CreateMap<NewProject, Project>();
69-
CreateMap<Project, ViewProject>().AfterMap((p, vp) => vp.HasSlackIntegration = p.Data is not null && p.Data.ContainsKey(Project.KnownDataKeys.SlackToken));
70-
71-
CreateMap<NewToken, Token>().ForMember(m => m.Type, m => m.Ignore());
72-
CreateMap<Token, ViewToken>();
73-
74-
CreateMap<User, ViewUser>();
75-
76-
CreateMap<NewWebHook, WebHook>();
77-
}
78-
}
7945
}

src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
1-
using AutoMapper;
2-
using Exceptionless.Core.Extensions;
1+
using Exceptionless.Core.Extensions;
32
using Exceptionless.Core.Models;
43
using Exceptionless.Core.Queries.Validation;
4+
using Exceptionless.Web.Mapping;
55
using Foundatio.Repositories;
66
using Foundatio.Repositories.Models;
77
using Microsoft.AspNetCore.Mvc;
88

99
namespace Exceptionless.Web.Controllers;
1010

11-
public abstract class ReadOnlyRepositoryApiController<TRepository, TModel, TViewModel> : ExceptionlessApiController where TRepository : ISearchableReadOnlyRepository<TModel> where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new()
11+
public abstract class ReadOnlyRepositoryApiController<TRepository, TModel, TViewModel> : ExceptionlessApiController
12+
where TRepository : ISearchableReadOnlyRepository<TModel>
13+
where TModel : class, IIdentity, new()
14+
where TViewModel : class, IIdentity, new()
1215
{
1316
protected readonly TRepository _repository;
1417
protected static readonly bool _isOwnedByOrganization = typeof(IOwnedByOrganization).IsAssignableFrom(typeof(TModel));
1518
protected static readonly bool _isOrganization = typeof(TModel) == typeof(Organization);
1619
protected static readonly bool _supportsSoftDeletes = typeof(ISupportSoftDeletes).IsAssignableFrom(typeof(TModel));
1720
protected static readonly IReadOnlyCollection<TModel> EmptyModels = new List<TModel>(0).AsReadOnly();
18-
protected readonly IMapper _mapper;
21+
protected readonly ApiMapper _mapper;
1922
protected readonly IAppQueryValidator _validator;
2023
protected readonly ILogger _logger;
2124

22-
public ReadOnlyRepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider)
25+
public ReadOnlyRepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider)
2326
{
2427
_repository = repository;
2528
_mapper = mapper;
@@ -38,9 +41,21 @@ protected async Task<ActionResult<TViewModel>> GetByIdImplAsync(string id)
3841

3942
protected virtual async Task<ActionResult<TViewModel>> OkModelAsync(TModel model)
4043
{
41-
return Ok(await MapAsync<TViewModel>(model, true));
44+
var viewModel = MapToViewModel(model);
45+
await AfterResultMapAsync([viewModel]);
46+
return Ok(viewModel);
4247
}
4348

49+
/// <summary>
50+
/// Maps a domain model to a view model. Override in derived controllers.
51+
/// </summary>
52+
protected abstract TViewModel MapToViewModel(TModel model);
53+
54+
/// <summary>
55+
/// Maps a collection of domain models to view models. Override in derived controllers.
56+
/// </summary>
57+
protected abstract List<TViewModel> MapToViewModels(IEnumerable<TModel> models);
58+
4459
protected virtual async Task<TModel?> GetModelAsync(string id, bool useCache = true)
4560
{
4661
if (String.IsNullOrEmpty(id))
@@ -69,24 +84,6 @@ protected virtual async Task<IReadOnlyCollection<TModel>> GetModelsAsync(string[
6984
return models;
7085
}
7186

72-
protected async Task<TDestination> MapAsync<TDestination>(object source, bool isResult = false)
73-
{
74-
var destination = _mapper.Map<TDestination>(source);
75-
if (isResult)
76-
await AfterResultMapAsync(new List<TDestination>(new[] { destination }));
77-
78-
return destination;
79-
}
80-
81-
protected async Task<ICollection<TDestination>> MapCollectionAsync<TDestination>(object source, bool isResult = false)
82-
{
83-
var destination = _mapper.Map<ICollection<TDestination>>(source);
84-
if (isResult)
85-
await AfterResultMapAsync<TDestination>(destination);
86-
87-
return destination;
88-
}
89-
9087
protected virtual Task AfterResultMapAsync<TDestination>(ICollection<TDestination> models)
9188
{
9289
foreach (var model in models.OfType<IData>())

src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
1-
using AutoMapper;
2-
using Exceptionless.Core.Extensions;
1+
using Exceptionless.Core.Extensions;
32
using Exceptionless.Core.Models;
43
using Exceptionless.Core.Queries.Validation;
54
using Exceptionless.Web.Extensions;
5+
using Exceptionless.Web.Mapping;
66
using Exceptionless.Web.Utility;
77
using Foundatio.Repositories;
88
using Foundatio.Repositories.Models;
99
using Microsoft.AspNetCore.Mvc;
1010

1111
namespace Exceptionless.Web.Controllers;
1212

13-
public abstract class RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel> : ReadOnlyRepositoryApiController<TRepository, TModel, TViewModel> where TRepository : ISearchableRepository<TModel> where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new() where TNewModel : class, new() where TUpdateModel : class, new()
13+
public abstract class RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel> : ReadOnlyRepositoryApiController<TRepository, TModel, TViewModel>
14+
where TRepository : ISearchableRepository<TModel>
15+
where TModel : class, IIdentity, new()
16+
where TViewModel : class, IIdentity, new()
17+
where TNewModel : class, new()
18+
where TUpdateModel : class, new()
1419
{
15-
public RepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator,
20+
public RepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator,
1621
TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) { }
1722

23+
/// <summary>
24+
/// Maps a new model (from API input) to a domain model. Override in derived controllers.
25+
/// </summary>
26+
protected abstract TModel MapToModel(TNewModel newModel);
27+
1828
protected async Task<ActionResult<TViewModel>> PostImplAsync(TNewModel value)
1929
{
2030
if (value is null)
2131
return BadRequest();
2232

23-
var mapped = await MapAsync<TModel>(value);
33+
var mapped = MapToModel(value);
2434
// if no organization id is specified, default to the user's 1st associated org.
2535
if (!_isOrganization && mapped is IOwnedByOrganization orgModel && String.IsNullOrEmpty(orgModel.OrganizationId) && GetAssociatedOrganizationIds().Count > 0)
2636
orgModel.OrganizationId = Request.GetDefaultOrganizationId()!;
@@ -32,7 +42,9 @@ protected async Task<ActionResult<TViewModel>> PostImplAsync(TNewModel value)
3242
var model = await AddModelAsync(mapped);
3343
await AfterAddAsync(model);
3444

35-
return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), await MapAsync<TViewModel>(model, true));
45+
var viewModel = MapToViewModel(model);
46+
await AfterResultMapAsync([viewModel]);
47+
return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), viewModel);
3648
}
3749

3850
protected async Task<ActionResult<TViewModel>> UpdateModelAsync(string id, Func<TModel, Task<TModel>> modelUpdateFunc)
@@ -50,7 +62,9 @@ protected async Task<ActionResult<TViewModel>> UpdateModelAsync(string id, Func<
5062
if (typeof(TViewModel) == typeof(TModel))
5163
return Ok(model);
5264

53-
return Ok(await MapAsync<TViewModel>(model, true));
65+
var viewModel = MapToViewModel(model);
66+
await AfterResultMapAsync([viewModel]);
67+
return Ok(viewModel);
5468
}
5569

5670
protected async Task<ActionResult<TViewModel>> UpdateModelsAsync(string[] ids, Func<TModel, Task<TModel>> modelUpdateFunc)
@@ -70,7 +84,9 @@ protected async Task<ActionResult<TViewModel>> UpdateModelsAsync(string[] ids, F
7084
if (typeof(TViewModel) == typeof(TModel))
7185
return Ok(models);
7286

73-
return Ok(await MapAsync<TViewModel>(models, true));
87+
var viewModels = MapToViewModels(models);
88+
await AfterResultMapAsync(viewModels);
89+
return Ok(viewModels);
7490
}
7591

7692
protected virtual string? GetEntityLink(string id)

src/Exceptionless.Web/Controllers/EventController.cs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Text;
2-
using AutoMapper;
32
using Exceptionless.Core;
43
using Exceptionless.Core.Authorization;
54
using Exceptionless.Core.Extensions;
@@ -16,6 +15,7 @@
1615
using Exceptionless.Core.Services;
1716
using Exceptionless.DateTimeExtensions;
1817
using Exceptionless.Web.Extensions;
18+
using Exceptionless.Web.Mapping;
1919
using Exceptionless.Web.Models;
2020
using Exceptionless.Web.Utility;
2121
using Exceptionless.Web.Utility.OpenApi;
@@ -60,7 +60,7 @@ public EventController(IEventRepository repository,
6060
FormattingPluginManager formattingPluginManager,
6161
ICacheClient cacheClient,
6262
JsonSerializerSettings jsonSerializerSettings,
63-
IMapper mapper,
63+
ApiMapper mapper,
6464
PersistentEventQueryValidator validator,
6565
AppOptions appOptions,
6666
TimeProvider timeProvider,
@@ -82,6 +82,11 @@ ILoggerFactory loggerFactory
8282
DefaultDateField = EventIndex.Alias.Date;
8383
}
8484

85+
// Mapping implementations - PersistentEvent uses itself as view model (no mapping needed)
86+
protected override PersistentEvent MapToModel(PersistentEvent newModel) => newModel;
87+
protected override PersistentEvent MapToViewModel(PersistentEvent model) => model;
88+
protected override List<PersistentEvent> MapToViewModels(IEnumerable<PersistentEvent> models) => models.ToList();
89+
8590
/// <summary>
8691
/// Count
8792
/// </summary>
@@ -811,9 +816,14 @@ public async Task<IActionResult> SetUserDescriptionAsync(string referenceId, Use
811816
// Set the project for the configuration response filter.
812817
Request.SetProject(project);
813818

814-
var eventUserDescription = await MapAsync<EventUserDescription>(description);
815-
eventUserDescription.ProjectId = project.Id;
816-
eventUserDescription.ReferenceId = referenceId;
819+
var eventUserDescription = new EventUserDescription
820+
{
821+
ProjectId = project.Id,
822+
ReferenceId = referenceId,
823+
EmailAddress = description.EmailAddress,
824+
Description = description.Description,
825+
Data = description.Data
826+
};
817827

818828
await _eventUserDescriptionQueue.EnqueueAsync(eventUserDescription);
819829
return StatusCode(StatusCodes.Status202Accepted);

src/Exceptionless.Web/Controllers/OrganizationController.cs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using AutoMapper;
2-
using Exceptionless.Core;
1+
using Exceptionless.Core;
32
using Exceptionless.Core.Authorization;
43
using Exceptionless.Core.Billing;
54
using Exceptionless.Core.Extensions;
@@ -12,6 +11,7 @@
1211
using Exceptionless.Core.Repositories.Queries;
1312
using Exceptionless.Core.Services;
1413
using Exceptionless.Web.Extensions;
14+
using Exceptionless.Web.Mapping;
1515
using Exceptionless.Web.Models;
1616
using Exceptionless.Web.Utility;
1717
using Foundatio.Caching;
@@ -55,7 +55,7 @@ public OrganizationController(
5555
UsageService usageService,
5656
IMailer mailer,
5757
IMessagePublisher messagePublisher,
58-
IMapper mapper,
58+
ApiMapper mapper,
5959
IAppQueryValidator validator,
6060
AppOptions options,
6161
TimeProvider timeProvider,
@@ -74,6 +74,11 @@ public OrganizationController(
7474
_options = options;
7575
}
7676

77+
// Mapping implementations
78+
protected override Organization MapToModel(NewOrganization newModel) => _mapper.MapToOrganization(newModel);
79+
protected override ViewOrganization MapToViewModel(Organization model) => _mapper.MapToViewOrganization(model);
80+
protected override List<ViewOrganization> MapToViewModels(IEnumerable<Organization> models) => _mapper.MapToViewOrganizations(models);
81+
7782
/// <summary>
7883
/// Get all
7984
/// </summary>
@@ -82,10 +87,11 @@ public OrganizationController(
8287
public async Task<ActionResult<IReadOnlyCollection<ViewOrganization>>> GetAllAsync(string? mode = null)
8388
{
8489
var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray());
85-
var viewOrganizations = await MapCollectionAsync<ViewOrganization>(organizations, true);
90+
var viewOrganizations = MapToViewModels(organizations);
91+
await AfterResultMapAsync(viewOrganizations);
8692

8793
if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase))
88-
return Ok(await PopulateOrganizationStatsAsync(viewOrganizations.ToList()));
94+
return Ok(await PopulateOrganizationStatsAsync(viewOrganizations));
8995

9096
return Ok(viewOrganizations);
9197
}
@@ -98,7 +104,8 @@ public async Task<ActionResult<IReadOnlyCollection<ViewOrganization>>> GetForAdm
98104
page = GetPage(page);
99105
limit = GetLimit(limit);
100106
var organizations = await _repository.GetByCriteriaAsync(criteria, o => o.PageNumber(page).PageLimit(limit), sort, paid, suspended);
101-
var viewOrganizations = (await MapCollectionAsync<ViewOrganization>(organizations.Documents, true)).ToList();
107+
var viewOrganizations = MapToViewModels(organizations.Documents);
108+
await AfterResultMapAsync(viewOrganizations);
102109

103110
if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase))
104111
return OkWithResourceLinks(await PopulateOrganizationStatsAsync(viewOrganizations), organizations.HasMore, page, organizations.Total);
@@ -127,7 +134,9 @@ public async Task<ActionResult<ViewOrganization>> GetAsync(string id, string? mo
127134
if (organization is null)
128135
return NotFound();
129136

130-
var viewOrganization = await MapAsync<ViewOrganization>(organization, true);
137+
var viewOrganization = MapToViewModel(organization);
138+
await AfterResultMapAsync<ViewOrganization>([viewOrganization]);
139+
131140
if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase))
132141
return Ok(await PopulateOrganizationStatsAsync(viewOrganization));
133142

@@ -306,7 +315,7 @@ public async Task<ActionResult<IReadOnlyCollection<InvoiceGridModel>>> GetInvoic
306315
var client = new StripeClient(_options.StripeOptions.StripeApiKey);
307316
var invoiceService = new InvoiceService(client);
308317
var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = limit + 1, EndingBefore = before, StartingAfter = after };
309-
var invoices = (await MapCollectionAsync<InvoiceGridModel>(await invoiceService.ListAsync(invoiceOptions), true)).ToList();
318+
var invoices = _mapper.MapToInvoiceGridModels(await invoiceService.ListAsync(invoiceOptions));
310319
return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit);
311320
}
312321

0 commit comments

Comments
 (0)