Skip to content

Commit 622332a

Browse files
committed
feat: Add structured configuration for API endpoints, JSON serialization, Marten event store, and projections via new extension methods.
1 parent 29bb1f9 commit 622332a

7 files changed

Lines changed: 400 additions & 264 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Microsoft.Extensions.Options;
2+
3+
namespace BookStore.ApiService.Infrastructure.Extensions;
4+
5+
/// <summary>
6+
/// Extension methods for configuring application services
7+
/// </summary>
8+
public static class ApplicationServicesExtensions
9+
{
10+
/// <summary>
11+
/// Configures all application services including pagination, OpenAPI, versioning, localization, etc.
12+
/// </summary>
13+
public static IServiceCollection AddApplicationServices(
14+
this IServiceCollection services,
15+
IConfiguration configuration,
16+
IHostEnvironment environment)
17+
{
18+
// Problem details for error handling
19+
services.AddProblemDetails();
20+
21+
// Configure pagination options
22+
services.Configure<Models.PaginationOptions>(
23+
configuration.GetSection(Models.PaginationOptions.SectionName));
24+
25+
// Configure OpenAPI with metadata
26+
services.AddOpenApi(options => options.AddBookStoreApiDocumentation());
27+
28+
// Configure API Versioning (header-based)
29+
AddApiVersioning(services);
30+
31+
// Configure localization
32+
AddLocalization(services, configuration);
33+
34+
// Add SignalR for real-time notifications
35+
services.AddSignalR();
36+
37+
// Add Blob Storage service
38+
services.AddSingleton<Services.BlobStorageService>();
39+
40+
// Add Marten health checks
41+
services.AddHealthChecks()
42+
.AddNpgSql(configuration.GetConnectionString("bookstore")!);
43+
44+
// Add response caching for performance
45+
services.AddResponseCaching();
46+
services.AddOutputCache();
47+
48+
return services;
49+
}
50+
51+
static void AddApiVersioning(IServiceCollection services)
52+
{
53+
services.AddApiVersioning(options =>
54+
{
55+
options.DefaultApiVersion = new Asp.Versioning.ApiVersion(1, 0);
56+
options.AssumeDefaultVersionWhenUnspecified = true;
57+
options.ReportApiVersions = true;
58+
options.ApiVersionReader = new Asp.Versioning.HeaderApiVersionReader("api-version");
59+
});
60+
}
61+
62+
static void AddLocalization(IServiceCollection services, IConfiguration configuration)
63+
{
64+
// Configure localization from appsettings.json
65+
services.Configure<Models.LocalizationOptions>(
66+
configuration.GetSection(Models.LocalizationOptions.SectionName));
67+
68+
services.AddLocalization();
69+
services.AddOptions<RequestLocalizationOptions>()
70+
.Configure<IOptions<Models.LocalizationOptions>>((options, localizationOptions) =>
71+
{
72+
var localization = localizationOptions.Value;
73+
74+
_ = options.SetDefaultCulture(localization.DefaultCulture)
75+
.AddSupportedCultures(localization.SupportedCultures)
76+
.AddSupportedUICultures(localization.SupportedCultures);
77+
});
78+
}
79+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using BookStore.ApiService.Endpoints;
2+
using BookStore.ApiService.Endpoints.Admin;
3+
using Scalar.AspNetCore;
4+
5+
namespace BookStore.ApiService.Infrastructure.Extensions;
6+
7+
/// <summary>
8+
/// Extension methods for mapping API endpoints
9+
/// </summary>
10+
public static class EndpointMappingExtensions
11+
{
12+
/// <summary>
13+
/// Maps all API endpoints including public, admin, and system endpoints
14+
/// </summary>
15+
public static WebApplication MapApiEndpoints(this WebApplication app)
16+
{
17+
// Root endpoint
18+
_ = app.MapGet("/", () => "Book Store API is running. Visit /api-reference for API documentation.")
19+
.ExcludeFromDescription();
20+
21+
// Create API version set for v1
22+
var apiVersionSet = app.NewApiVersionSet()
23+
.HasApiVersion(new Asp.Versioning.ApiVersion(1))
24+
.ReportApiVersions()
25+
.Build();
26+
27+
// Map public and admin endpoints
28+
MapPublicEndpoints(app, apiVersionSet);
29+
MapAdminEndpoints(app, apiVersionSet);
30+
31+
// Map default endpoints (health checks, metrics, etc.)
32+
app.MapDefaultEndpoints();
33+
34+
// Map SignalR hub for real-time notifications
35+
app.MapHub<Wolverine.SignalR.WolverineHub>("/hub/bookstore");
36+
37+
return app;
38+
}
39+
40+
static void MapPublicEndpoints(WebApplication app, Asp.Versioning.Builder.ApiVersionSet apiVersionSet)
41+
{
42+
// Public API endpoints (v1)
43+
var publicApi = app.MapGroup("/api")
44+
.WithApiVersionSet(apiVersionSet);
45+
46+
_ = publicApi.MapGroup("/books")
47+
.MapBookEndpoints()
48+
.WithTags("Books");
49+
50+
_ = publicApi.MapGroup("/authors")
51+
.MapAuthorEndpoints()
52+
.WithTags("Authors");
53+
54+
_ = publicApi.MapGroup("/categories")
55+
.MapCategoryEndpoints()
56+
.WithTags("Categories");
57+
58+
_ = publicApi.MapGroup("/publishers")
59+
.MapPublisherEndpoints()
60+
.WithTags("Publishers");
61+
}
62+
63+
static void MapAdminEndpoints(WebApplication app, Asp.Versioning.Builder.ApiVersionSet apiVersionSet)
64+
{
65+
// Admin API endpoints (v1)
66+
var adminApi = app.MapGroup("/api/admin")
67+
.WithApiVersionSet(apiVersionSet);
68+
69+
_ = adminApi.MapGroup("/books")
70+
.MapAdminBookEndpoints()
71+
.WithTags("Admin - Books");
72+
73+
_ = adminApi.MapGroup("/authors")
74+
.MapAdminAuthorEndpoints()
75+
.WithTags("Admin - Authors");
76+
77+
_ = adminApi.MapGroup("/categories")
78+
.MapAdminCategoryEndpoints()
79+
.WithTags("Admin - Categories");
80+
81+
_ = adminApi.MapGroup("/publishers")
82+
.MapAdminPublisherEndpoints()
83+
.WithTags("Admin - Publishers");
84+
85+
_ = adminApi.MapGroup("/projections")
86+
.MapProjectionEndpoints()
87+
.WithTags("Admin - System");
88+
}
89+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace BookStore.ApiService.Infrastructure.Extensions;
2+
3+
/// <summary>
4+
/// Extension methods for configuring JSON serialization
5+
/// </summary>
6+
public static class JsonConfigurationExtensions
7+
{
8+
/// <summary>
9+
/// Configures JSON serialization options for HTTP responses
10+
/// </summary>
11+
public static IServiceCollection AddJsonConfiguration(
12+
this IServiceCollection services,
13+
IHostEnvironment environment)
14+
{
15+
services.ConfigureHttpJsonOptions(options =>
16+
{
17+
// Use web defaults (camelCase properties)
18+
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
19+
20+
// Serialize enums as strings (not integers) for better readability and API evolution
21+
options.SerializerOptions.Converters.Add(
22+
new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase));
23+
24+
// Pretty print in development for easier debugging
25+
options.SerializerOptions.WriteIndented = environment.IsDevelopment();
26+
27+
// ISO 8601 date/time format is default in System.Text.Json
28+
// DateTimeOffset automatically serializes as: "2025-12-26T17:16:09.123Z"
29+
});
30+
31+
return services;
32+
}
33+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using BookStore.ApiService.Projections;
2+
using JasperFx.Events;
3+
using JasperFx.Events.Projections;
4+
using Marten;
5+
using Marten.Events.Daemon;
6+
using Marten.Events.Projections;
7+
using Weasel.Core;
8+
using Wolverine.Marten;
9+
10+
namespace BookStore.ApiService.Infrastructure.Extensions;
11+
12+
/// <summary>
13+
/// Extension methods for configuring Marten event store
14+
/// </summary>
15+
public static class MartenConfigurationExtensions
16+
{
17+
/// <summary>
18+
/// Configures Marten for event sourcing with projections and indexes
19+
/// </summary>
20+
public static IServiceCollection AddMartenEventStore(
21+
this IServiceCollection services,
22+
IConfiguration configuration)
23+
{
24+
_ = services.AddMarten(sp =>
25+
{
26+
// Get connection string from Aspire
27+
var connectionString = configuration.GetConnectionString("bookstore")!;
28+
29+
var options = new StoreOptions();
30+
options.Connection(connectionString);
31+
32+
ConfigureEventMetadata(options);
33+
ConfigureJsonSerialization(options);
34+
RegisterEventTypes(options);
35+
RegisterProjections(options);
36+
ConfigureIndexes(options);
37+
38+
return options;
39+
})
40+
.UseLightweightSessions()
41+
.IntegrateWithWolverine(cfg => cfg.UseWolverineManagedEventSubscriptionDistribution = true);
42+
43+
return services;
44+
}
45+
46+
static void ConfigureEventMetadata(StoreOptions options)
47+
{
48+
// Enable metadata storage for correlation/causation tracking
49+
options.Events.MetadataConfig.CorrelationIdEnabled = true;
50+
options.Events.MetadataConfig.CausationIdEnabled = true;
51+
options.Events.MetadataConfig.HeadersEnabled = true;
52+
}
53+
54+
static void ConfigureJsonSerialization(StoreOptions options)
55+
{
56+
// Configure JSON serialization for Marten (database storage)
57+
// Enums stored as strings for readability and camelCase for JSON properties
58+
options.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase);
59+
60+
// Enable NGram search with unaccent for multilingual text search
61+
// This automatically enables pg_trgm and unaccent extensions
62+
options.Advanced.UseNGramSearchWithUnaccent = true;
63+
}
64+
65+
static void RegisterEventTypes(StoreOptions options)
66+
{
67+
// Book events
68+
_ = options.Events.AddEventType<Events.BookAdded>();
69+
_ = options.Events.AddEventType<Events.BookUpdated>();
70+
_ = options.Events.AddEventType<Events.BookSoftDeleted>();
71+
_ = options.Events.AddEventType<Events.BookRestored>();
72+
_ = options.Events.AddEventType<Events.BookCoverUpdated>();
73+
74+
// Author events
75+
_ = options.Events.AddEventType<Events.AuthorAdded>();
76+
_ = options.Events.AddEventType<Events.AuthorUpdated>();
77+
_ = options.Events.AddEventType<Events.AuthorSoftDeleted>();
78+
_ = options.Events.AddEventType<Events.AuthorRestored>();
79+
80+
// Category events
81+
_ = options.Events.AddEventType<Events.CategoryAdded>();
82+
_ = options.Events.AddEventType<Events.CategoryUpdated>();
83+
_ = options.Events.AddEventType<Events.CategorySoftDeleted>();
84+
_ = options.Events.AddEventType<Events.CategoryRestored>();
85+
86+
// Publisher events
87+
_ = options.Events.AddEventType<Events.PublisherAdded>();
88+
_ = options.Events.AddEventType<Events.PublisherUpdated>();
89+
_ = options.Events.AddEventType<Events.PublisherSoftDeleted>();
90+
_ = options.Events.AddEventType<Events.PublisherRestored>();
91+
}
92+
93+
static void RegisterProjections(StoreOptions options)
94+
{
95+
// Configure projections - using AddAsync for async projections managed by Wolverine
96+
// Register projection builders explicitly with async lifecycle
97+
options.Projections.Add<AuthorProjectionBuilder>(ProjectionLifecycle.Async);
98+
options.Projections.Add<CategoryProjectionBuilder>(ProjectionLifecycle.Async);
99+
options.Projections.Add<PublisherProjectionBuilder>(ProjectionLifecycle.Async);
100+
options.Projections.Add<BookSearchProjectionBuilder>(ProjectionLifecycle.Async);
101+
}
102+
103+
static void ConfigureIndexes(StoreOptions options)
104+
{
105+
// Configure indexes for search performance
106+
// Note: Trigram indexes for fuzzy search will be created via SQL migration
107+
108+
// BookSearchProjection indexes
109+
_ = options.Schema.For<BookSearchProjection>()
110+
.Index(x => x.PublisherId) // Standard B-tree index for exact matches
111+
.Index(x => x.Title) // B-tree index for sorting
112+
.GinIndexJsonData() // GIN index for JSON fields
113+
.NgramIndex(x => x.Title) // NGram search on title
114+
.NgramIndex(x => x.AuthorNames); // NGram search on authors
115+
116+
// AuthorProjection indexes
117+
_ = options.Schema.For<AuthorProjection>()
118+
.Index(x => x.Name) // B-tree index for sorting
119+
.NgramIndex(x => x.Name); // NGram search on author name
120+
121+
// Note: CategoryProjection no longer has a Name field - uses Translations dictionary
122+
// No indexes configured for CategoryProjection
123+
124+
// PublisherProjection indexes
125+
_ = options.Schema.For<PublisherProjection>()
126+
.Index(x => x.Name) // B-tree index for sorting
127+
.NgramIndex(x => x.Name); // NGram search on publisher name
128+
}
129+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Wolverine;
2+
using Wolverine.SignalR;
3+
4+
namespace BookStore.ApiService.Infrastructure.Extensions;
5+
6+
/// <summary>
7+
/// Extension methods for configuring Wolverine messaging
8+
/// </summary>
9+
public static class WolverineConfigurationExtensions
10+
{
11+
/// <summary>
12+
/// Configures Wolverine with command/handler pattern and SignalR integration
13+
/// </summary>
14+
public static IServiceCollection AddWolverineMessaging(this IServiceCollection services)
15+
{
16+
_ = services.AddWolverine(opts =>
17+
{
18+
// Auto-discover handlers in this assembly
19+
_ = opts.Discovery.IncludeAssembly(typeof(Program).Assembly);
20+
21+
// Explicitly include static handler classes for discovery
22+
RegisterHandlers(opts);
23+
24+
// Enable SignalR transport for real-time notifications
25+
_ = opts.UseSignalR();
26+
27+
// Route domain event notifications to SignalR
28+
ConfigureEventPublishing(opts);
29+
30+
// Policies for automatic behavior
31+
opts.Policies.AutoApplyTransactions();
32+
});
33+
34+
return services;
35+
}
36+
37+
static void RegisterHandlers(WolverineOptions opts)
38+
{
39+
_ = opts.Discovery.IncludeType(typeof(Handlers.Authors.AuthorHandlers));
40+
_ = opts.Discovery.IncludeType(typeof(Handlers.Books.BookHandlers));
41+
_ = opts.Discovery.IncludeType(typeof(Handlers.Books.BookCoverHandlers));
42+
_ = opts.Discovery.IncludeType(typeof(Handlers.Categories.CategoryHandlers));
43+
_ = opts.Discovery.IncludeType(typeof(Handlers.Publishers.PublisherHandlers));
44+
}
45+
46+
static void ConfigureEventPublishing(WolverineOptions opts)
47+
{
48+
opts.Publish(x =>
49+
{
50+
x.MessagesImplementing<Events.Notifications.IDomainEventNotification>();
51+
_ = x.ToSignalR();
52+
});
53+
}
54+
}

0 commit comments

Comments
 (0)