This commit is contained in:
2026-03-15 11:22:01 +01:00
commit 599ecd66a5
109 changed files with 3348 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
{
"realm": "mccn",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 30,
"accessTokenLifespan": 1800,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"clients": [
{
"clientId": "mccn-api",
"name": "Mccn API",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "change-this-secret",
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false
},
{
"clientId": "mccn-swagger",
"name": "Mccn Swagger UI",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": [
"http://localhost:5000/*",
"http://localhost:5001/*"
],
"webOrigins": [
"http://localhost:5000",
"http://localhost:5001"
]
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Regular user"
},
{
"name": "admin",
"description": "Administrator"
}
]
},
"users": [
{
"username": "service-account-mccn-api",
"enabled": true,
"serviceAccountClientId": "mccn-api",
"clientRoleMappings": {
"realm-management": [
{ "name": "manage-users" },
{ "name": "view-users" }
]
}
}
]
}

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
obj/
bin/
.vs/
*.user
.env
.containers/
.claude/
src/API/Mccn.Api/appsettings.Development.json
.idea/

8
Directory.Build.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

36
Mccn.slnx Normal file
View File

@@ -0,0 +1,36 @@
<Solution>
<Folder Name="/src/"/>
<Folder Name="/src/API/">
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj"/>
</Folder>
<Folder Name="/src/Common/">
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj"/>
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj"/>
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj"/>
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj"/>
</Folder>
<Folder Name="/src/Modules/"/>
<Folder Name="/src/Modules/Hello/">
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj"/>
<Project
Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj"/>
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj"/>
<Project
Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj"/>
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj"/>
</Folder>
<Folder Name="/src/Modules/Users/">
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj"/>
<Project
Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj"/>
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj"/>
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj"/>
<Project
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj"/>
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj"/>
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj"/>
</Folder>
<Folder Name="/test/">
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj"/>
</Folder>
</Solution>

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
mccn.database:
image: postgres:17
container_name: mccn.database
environment:
POSTGRES_DB: mccn
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- ./.containers/db:/var/lib/postgresql/data
ports:
- 5432:5432
mccn.identity:
image: quay.io/keycloak/keycloak:26.5.1
container_name: mccn.identity
command: start-dev --import-realm
environment:
KC_HEALTH_ENABLED: true
KC_HOSTNAME: localhost
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
volumes:
- ./.files:/opt/keycloak/data/import
ports:
- 18080:8080
- 9000:9000
mccn.seq:
image: datalust/seq:2025.2
container_name: mccn.seq
environment:
ACCEPT_EULA: Y
SEQ_FIRSTRUN_NOAUTHENTICATION: true # local dev only
ports:
- 5341:5341
- 9081:80
mccn.redis:
image: redis:latest
container_name: mccn.redis
ports:
- 6379:6379
mccn.jaeger:
image: jaegertracing/all-in-one:latest
container_name: mccn.jaeger
ports:
- 4317:4317
- 4318:4318
- 16686:16686

View File

@@ -0,0 +1,26 @@
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Api.Extensions;
internal static class MigrationExtensions
{
internal static async Task ApplyMigrationsAsync(this WebApplication app)
{
using IServiceScope scope = app.Services.CreateScope();
await MigrateAsync<UsersDbContext>(scope.ServiceProvider);
}
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
{
TContext dbContext = serviceProvider.GetRequiredService<TContext>();
// Use EnsureCreatedAsync when no migrations exist yet.
// Once you add migrations via `dotnet ef migrations add`, switch this to MigrateAsync().
IEnumerable<string> pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
await dbContext.Database.MigrateAsync();
else
await dbContext.Database.EnsureCreatedAsync();
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
<ProjectReference Include="..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj" />
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj" />
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
### Variables
@keycloak = http://localhost:18080
@realm = mccn
@client_id = mccn-swagger
@content_type = application/json
###############################################################################
# HEALTH
###############################################################################
### Health check
GET {{baseAddress}}/health
###############################################################################
# AUTH — get a token from Keycloak
# Uses the mccn-swagger public client with direct access grants (Resource Owner
# Password Credentials). Replace the username/password with a registered user.
###############################################################################
### Get access token
# @name login
POST {{keycloak}}/realms/{{realm}}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type = password &
client_id = {{client_id}} &
username = user@example.com &
password = Password1!
### Store the token for use in subsequent requests
@token = {{login.response.body.access_token}}
###############################################################################
# USERS
###############################################################################
### Register a new user (public)
# @name register
POST {{baseAddress}}/users/register
Content-Type: {{content_type}}
{
"email": "user@example.com",
"firstName": "Jane",
"lastName": "Doe",
"password": "Test1234!@#$anin149141"
}
### Get user profile (requires auth)
# Replace the GUID below with the ID returned from the register response
GET {{baseAddress}}/users/{{register.response.body.value}}
Authorization: Bearer {{token}}
###############################################################################
# HELLO
###############################################################################
### Say hello — no name (public)
GET {{baseAddress}}/hello
### Say hello — with a name (public)
GET {{baseAddress}}/hello?name=World
### Say hello to the authenticated user (requires auth)
GET {{baseAddress}}/hello/me
Authorization: Bearer {{token}}

View File

@@ -0,0 +1,61 @@
using Mccn.Api.Extensions;
using Mccn.Common.Application;
using Mccn.Common.Infrastructure;
using Mccn.Common.Presentation.Endpoints;
using Mccn.Modules.Hello.Application.Hello.SayHello;
using Mccn.Modules.Hello.Infrastructure;
using Mccn.Modules.Hello.Presentation;
using Mccn.Modules.Users.Application.Users.Register;
using Mccn.Modules.Users.Infrastructure;
using Mccn.Modules.Users.Presentation;
using Serilog;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(context.Configuration); });
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication(
typeof(RegisterUserCommand).Assembly,
typeof(SayHelloQuery).Assembly);
// Register modules.
builder.Services.AddUsersModule(builder.Configuration);
builder.Services.AddHelloModule(builder.Configuration);
// Register endpoints.
builder.Services.AddUsersPresentationServices();
builder.Services.AddHelloPresentationServices();
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
// Auto-apply migrations in development.
await app.ApplyMigrationsAsync();
}
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.MapEndpoints();
app.MapGet("health", () => Results.Ok(new { Status = "Healthy" }))
.WithTags("Health")
.AllowAnonymous();
app.Run();
// Make Program accessible to integration test projects.
public partial class Program;

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:8081;http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,55 @@
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.Seq"
],
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:9081"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId"
]
},
"Authentication": {
"Audience": "account",
"ValidIssuers": [
"http://mccn.identity:8080/realms/mccn",
"http://localhost:18080/realms/mccn"
],
"MetadataAddress": "http://localhost:18080/realms/mccn/.well-known/openid-configuration",
"RequireHttpsMetadata": false
},
"Observability": {
"OtlpEndpoint": "http://localhost:4317"
},
"ConnectionStrings": {
"Database": "Host=localhost;Port=5432;Database=mccn;Username=postgres;Password=postgres",
"Cache": "localhost:6379"
},
"Users": {
"Keycloak": {
"AdminUrl": "http://localhost:18080/admin/realms/mccn/",
"TokenUrl": "http://localhost:18080/realms/mccn/protocol/openid-connect/token",
"ClientId": "mccn-api",
"ClientSecret": "change-this-secret"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"dev": {
"baseAddress": "https://localhost:8081"
}
}

View File

@@ -0,0 +1,12 @@
using Mccn.Common.Domain.Abstractions;
using MediatR;
namespace Mccn.Common.Application.Abstractions;
public interface ICommand : IRequest<Result>
{
}
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
{
}

View File

@@ -0,0 +1,14 @@
using Mccn.Common.Domain.Abstractions;
using MediatR;
namespace Mccn.Common.Application.Abstractions;
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{
}
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{
}

View File

@@ -0,0 +1,8 @@
using Mccn.Common.Domain.Abstractions;
using MediatR;
namespace Mccn.Common.Application.Abstractions;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}

View File

@@ -0,0 +1,9 @@
using Mccn.Common.Domain.Abstractions;
using MediatR;
namespace Mccn.Common.Application.Abstractions;
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}

View File

@@ -0,0 +1,41 @@
using FluentValidation;
using FluentValidation.Results;
using Mccn.Common.Application.Exceptions;
using Mccn.Common.Domain.Abstractions;
using MediatR;
using ValidationException = Mccn.Common.Application.Exceptions.ValidationException;
namespace Mccn.Common.Application.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IBaseRequest
where TResponse : Result
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
ValidationContext<TRequest> context = new(request);
ValidationResult[] validationFailures = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
List<ValidationError> errors = validationFailures
.Where(r => !r.IsValid)
.SelectMany(r => r.Errors)
.Select(f => new ValidationError(f.PropertyName, f.ErrorMessage))
.ToList();
if (errors.Count != 0) throw new ValidationException(errors);
return await next();
}
}

View File

@@ -0,0 +1,23 @@
using System.Reflection;
using FluentValidation;
using Mccn.Common.Application.Behaviors;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Common.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(
this IServiceCollection services,
params Assembly[] moduleAssemblies)
{
services.AddMediatR(config => { config.RegisterServicesFromAssemblies(moduleAssemblies); });
services.AddValidatorsFromAssemblies(moduleAssemblies);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}

View File

@@ -0,0 +1,3 @@
namespace Mccn.Common.Application.Exceptions;
public sealed record ValidationError(string PropertyName, string ErrorMessage);

View File

@@ -0,0 +1,12 @@
namespace Mccn.Common.Application.Exceptions;
public sealed class ValidationException : Exception
{
public ValidationException(IEnumerable<ValidationError> errors)
: base("One or more validation failures has occurred.")
{
Errors = errors;
}
public IEnumerable<ValidationError> Errors { get; }
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2"/>
<PackageReference Include="MediatR" Version="12.4.1"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,15 @@
namespace Mccn.Common.Domain.Abstractions;
public abstract class Entity
{
protected Entity(Guid id)
{
Id = id;
}
protected Entity()
{
}
public Guid Id { get; init; }
}

View File

@@ -0,0 +1,35 @@
namespace Mccn.Common.Domain.Abstractions;
public sealed record Error(string Code, string Description, ErrorType Type)
{
public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure);
public static readonly Error NullValue = new("General.Null", "Null value was provided", ErrorType.Failure);
public static Error NotFound(string code, string description)
{
return new Error(code, description, ErrorType.NotFound);
}
public static Error Validation(string code, string description)
{
return new Error(code, description, ErrorType.Validation);
}
public static Error Conflict(string code, string description)
{
return new Error(code, description, ErrorType.Conflict);
}
public static Error Failure(string code, string description)
{
return new Error(code, description, ErrorType.Failure);
}
}
public enum ErrorType
{
Failure = 0,
Validation = 1,
NotFound = 2,
Conflict = 3
}

View File

@@ -0,0 +1,58 @@
namespace Mccn.Common.Domain.Abstractions;
public class Result
{
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException();
if (!isSuccess && error == Error.None)
throw new InvalidOperationException();
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success()
{
return new Result(true, Error.None);
}
public static Result Failure(Error error)
{
return new Result(false, error);
}
public static Result<TValue> Success<TValue>(TValue value)
{
return new Result<TValue>(value, true, Error.None);
}
public static Result<TValue> Failure<TValue>(Error error)
{
return new Result<TValue>(default, false, error);
}
}
public sealed class Result<TValue> : Result
{
private readonly TValue? _value;
internal Result(TValue? value, bool isSuccess, Error error) : base(isSuccess, error)
{
_value = value;
}
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("The value of a failure result cannot be accessed.");
public static implicit operator Result<TValue>(TValue? value)
{
return value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Common.Infrastructure.Authentication;
public static class AuthenticationExtensions
{
public static IServiceCollection AddJwtAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
services.ConfigureOptions<JwtBearerConfigureOptions>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace Mccn.Common.Infrastructure.Authentication;
internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBearerOptions>
{
private const string ConfigSection = "Authentication";
private readonly IConfiguration _configuration;
public JwtBearerConfigureOptions(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(JwtBearerOptions options)
{
_configuration.GetSection(ConfigSection).Bind(options);
}
public void Configure(string? name, JwtBearerOptions options)
{
Configure(options);
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Common.Infrastructure.Caching;
public static class CachingExtensions
{
public static IServiceCollection AddCaching(
this IServiceCollection services,
IConfiguration configuration)
{
string? redisConnectionString = configuration.GetConnectionString("Cache");
if (!string.IsNullOrEmpty(redisConnectionString))
services.AddStackExchangeRedisCache(options => { options.Configuration = redisConnectionString; });
else
services.AddDistributedMemoryCache();
return services;
}
}

View File

@@ -0,0 +1,31 @@
using Mccn.Common.Infrastructure.Authentication;
using Mccn.Common.Infrastructure.Caching;
using Mccn.Common.Infrastructure.ExceptionHandlers;
using Mccn.Common.Infrastructure.Observability;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Common.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddJwtAuthentication(configuration);
services.AddAuthorization();
services.AddCaching(configuration);
services.AddObservability("Mccn", configuration);
services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails();
services.AddHttpContextAccessor();
return services;
}
}

View File

@@ -0,0 +1,67 @@
using Mccn.Common.Application.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Mccn.Common.Infrastructure.ExceptionHandlers;
internal sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IWebHostEnvironment env)
{
_logger = logger;
_env = env;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception occurred");
if (exception is ValidationException validationException)
{
ValidationProblemDetails validationProblem = new()
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"
};
foreach (ValidationError error in validationException.Errors)
validationProblem.Errors[error.PropertyName] = [error.ErrorMessage];
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(validationProblem, cancellationToken);
}
else
{
ProblemDetails problem = new()
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
};
// Include exception details in development to make debugging easier.
if (_env.IsDevelopment())
{
problem.Detail = exception.Message;
problem.Extensions["exceptionType"] = exception.GetType().Name;
problem.Extensions["stackTrace"] = exception.StackTrace;
}
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
}
return true;
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Mccn.Common.Infrastructure.Observability;
public static class ObservabilityExtensions
{
public static IServiceCollection AddObservability(
this IServiceCollection services,
string serviceName,
IConfiguration configuration)
{
services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(serviceName))
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(
configuration["Observability:OtlpEndpoint"] ?? "http://localhost:4317");
});
});
return services;
}
}

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Common.Presentation.Endpoints;
public static class EndpointExtensions
{
public static IServiceCollection AddEndpoints(
this IServiceCollection services,
params Assembly[] assemblies)
{
IEnumerable<Type> endpointTypes = assemblies
.SelectMany(a => a.GetTypes())
.Where(t => t is { IsAbstract: false, IsInterface: false } &&
t.IsAssignableTo(typeof(IEndpoint)));
foreach (Type endpointType in endpointTypes) services.AddTransient(typeof(IEndpoint), endpointType);
return services;
}
public static IApplicationBuilder MapEndpoints(
this WebApplication app,
RouteGroupBuilder? routeGroupBuilder = null)
{
IEnumerable<IEndpoint> endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder;
foreach (IEndpoint endpoint in endpoints) endpoint.MapEndpoint(builder);
return app;
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Routing;
namespace Mccn.Common.Presentation.Endpoints;
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder app);
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Hello.Application;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Hello.Application;
public static class DependencyInjection
{
public static IServiceCollection AddHelloApplication(this IServiceCollection services)
{
return services;
}
}

View File

@@ -0,0 +1,3 @@
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
public sealed record HelloResponse(string Message);

View File

@@ -0,0 +1,5 @@
using Mccn.Common.Application.Abstractions;
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
public sealed record SayHelloQuery(string? Name) : IQuery<HelloResponse>;

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Application.Abstractions;
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
internal sealed class SayHelloQueryHandler : IQueryHandler<SayHelloQuery, HelloResponse>
{
public Task<Result<HelloResponse>> Handle(SayHelloQuery request, CancellationToken cancellationToken)
{
string greeting = string.IsNullOrWhiteSpace(request.Name)
? "Hello, World!"
: $"Hello, {request.Name}!";
return Task.FromResult(Result.Success(new HelloResponse(greeting)));
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using System.Reflection;
using Mccn.Modules.Hello.Application;
using DependencyInjection = Mccn.Modules.Hello.Infrastructure.DependencyInjection;
namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions;
public abstract class BaseTest
{
protected static readonly Assembly ApplicationAssembly =
AssemblyReference.Assembly;
protected static readonly Assembly InfrastructureAssembly =
typeof(DependencyInjection).Assembly;
protected static readonly Assembly PresentationAssembly =
Presentation.AssemblyReference.Assembly;
}

View File

@@ -0,0 +1,12 @@
using FluentAssertions;
using NetArchTest.Rules;
namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions;
internal static class TestResultExtensions
{
internal static void ShouldBeSuccessful(this TestResult testResult)
{
testResult.FailingTypes?.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,68 @@
using Mccn.Common.Application.Abstractions;
using Mccn.Modules.Hello.ArchitectureTests.Abstractions;
using NetArchTest.Rules;
namespace Mccn.Modules.Hello.ArchitectureTests.Application;
public class ApplicationTests : BaseTest
{
[Fact]
public void Query_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQuery<>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Query_ShouldHave_NameEndingWith_Query()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQuery<>))
.Should()
.HaveNameEndingWith("Query")
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_Should_NotBePublic()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.NotBePublic()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.HaveNameEndingWith("QueryHandler")
.GetResult()
.ShouldBeSuccessful();
}
}

View File

@@ -0,0 +1,37 @@
using Mccn.Modules.Hello.ArchitectureTests.Abstractions;
using NetArchTest.Rules;
namespace Mccn.Modules.Hello.ArchitectureTests.Layers;
public class LayerTests : BaseTest
{
[Fact]
public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
Types.InAssembly(ApplicationAssembly)
.Should()
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer()
{
Types.InAssembly(ApplicationAssembly)
.Should()
.NotHaveDependencyOn(PresentationAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
Types.InAssembly(PresentationAssembly)
.Should()
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj"/>
<ProjectReference Include="..\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Hello.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddHelloModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Hello module has no database/infrastructure dependencies.
// This is where you'd add them when the module grows.
return services;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using Bogus;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
[Collection(nameof(IntegrationTestCollection))]
public abstract class BaseIntegrationTest : IDisposable
{
protected static readonly Faker Faker = new();
private readonly IServiceScope _scope;
protected readonly HttpClient HttpClient;
protected readonly ISender Sender;
protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
{
_scope = factory.Services.CreateScope();
HttpClient = factory.CreateClient();
Sender = _scope.ServiceProvider.GetRequiredService<ISender>();
}
public void Dispose()
{
_scope.Dispose();
}
}

View File

@@ -0,0 +1,4 @@
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
[CollectionDefinition(nameof(IntegrationTestCollection))]
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;
using Testcontainers.Redis;
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
#pragma warning disable CS0618
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithDatabase("mccn")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
private readonly RedisContainer _redisContainer = new RedisBuilder()
.WithImage("redis:latest")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString());
Environment.SetEnvironmentVariable("Authentication:RequireHttpsMetadata", "false");
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
await _redisContainer.StartAsync();
}
public new async Task DisposeAsync()
{
await _dbContainer.StopAsync();
await _redisContainer.StopAsync();
}
}
#pragma warning restore CS0618

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Hello.Application.Hello.SayHello;
using Mccn.Modules.Hello.IntegrationTests.Abstractions;
namespace Mccn.Modules.Hello.IntegrationTests.Hello;
public class HelloTests : BaseIntegrationTest
{
public HelloTests(IntegrationTestWebAppFactory factory)
: base(factory)
{
}
[Fact]
public async Task GetHello_Should_ReturnOk_WithDefaultMessage()
{
// Act
HttpResponseMessage response = await HttpClient.GetAsync("hello");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
HelloResponse? body = await response.Content.ReadFromJsonAsync<HelloResponse>();
body!.Message.Should().Be("Hello, World!");
}
[Fact]
public async Task GetHello_Should_ReturnOk_WithNamedMessage()
{
// Arrange
string name = Faker.Name.FirstName();
// Act
HttpResponseMessage response = await HttpClient.GetAsync($"hello?name={name}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
HelloResponse? body = await response.Content.ReadFromJsonAsync<HelloResponse>();
body!.Message.Should().Be($"Hello, {name}!");
}
[Fact]
public async Task SayHello_Should_ReturnHelloWorld_WhenNoName()
{
// Act
Result<HelloResponse> result = await Sender.Send(new SayHelloQuery(null));
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Message.Should().Be("Hello, World!");
}
[Fact]
public async Task SayHello_Should_ReturnGreeting_WhenNameProvided()
{
// Arrange
string name = Faker.Name.FirstName();
// Act
Result<HelloResponse> result = await Sender.Send(new SayHelloQuery(name));
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Message.Should().Be($"Hello, {name}!");
}
[Fact]
public async Task GetHelloMe_Should_ReturnUnauthorized_WhenNotAuthenticated()
{
// Act
HttpResponseMessage response = await HttpClient.GetAsync("hello/me");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.5"/>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="mccn-realm-export.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
{
"realm": "mccn",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 30,
"accessTokenLifespan": 1800,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"clients": [
{
"clientId": "mccn-api",
"name": "Mccn API",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "change-this-secret",
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false
},
{
"clientId": "mccn-swagger",
"name": "Mccn Swagger UI",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": [
"http://localhost:5000/*",
"http://localhost:5001/*"
],
"webOrigins": [
"http://localhost:5000",
"http://localhost:5001"
]
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Regular user"
},
{
"name": "admin",
"description": "Administrator"
}
]
},
"users": [
{
"username": "service-account-mccn-api",
"enabled": true,
"serviceAccountClientId": "mccn-api",
"clientRoleMappings": {
"realm-management": [
{
"name": "manage-users"
},
{
"name": "view-users"
}
]
}
}
]
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Hello.Presentation;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -0,0 +1,13 @@
using Mccn.Common.Presentation.Endpoints;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Hello.Presentation;
public static class DependencyInjection
{
public static IServiceCollection AddHelloPresentationServices(this IServiceCollection services)
{
services.AddEndpoints(typeof(DependencyInjection).Assembly);
return services;
}
}

View File

@@ -0,0 +1,43 @@
using Mccn.Common.Domain.Abstractions;
using Mccn.Common.Presentation.Endpoints;
using Mccn.Modules.Hello.Application.Hello.SayHello;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Mccn.Modules.Hello.Presentation.Hello;
internal sealed class GetHello : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("hello", async (string? name, ISender sender) =>
{
SayHelloQuery query = new(name);
Result<HelloResponse> result = await sender.Send(query);
return Results.Ok(result.Value);
})
.AllowAnonymous()
.WithTags("Hello")
.WithName("GetHello")
.WithSummary("Say hello");
app.MapGet("hello/me", async (HttpContext context, ISender sender) =>
{
string name = context.User.FindFirst("given_name")?.Value
?? context.User.FindFirst("name")?.Value
?? "Authenticated User";
SayHelloQuery query = new(name);
Result<HelloResponse> result = await sender.Send(query);
return Results.Ok(result.Value);
})
.RequireAuthorization()
.WithTags("Hello")
.WithName("GetHelloAuthenticated")
.WithSummary("Say hello to the authenticated user");
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Users.Application.Abstractions;
public interface IIdentityProviderService
{
Task<Result<string>> RegisterUserAsync(
UserModel user,
CancellationToken cancellationToken = default);
}
public sealed record UserModel(
string Email,
string FirstName,
string LastName,
string Password);

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Users.Application.Abstractions;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Users.Application;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,5 @@
using Mccn.Common.Application.Abstractions;
namespace Mccn.Modules.Users.Application.Users.GetProfile;
public sealed record GetUserProfileQuery(Guid UserId) : IQuery<UserProfileResponse>;

View File

@@ -0,0 +1,30 @@
using Mccn.Common.Application.Abstractions;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Users.Domain.Users;
namespace Mccn.Modules.Users.Application.Users.GetProfile;
internal sealed class GetUserProfileQueryHandler : IQueryHandler<GetUserProfileQuery, UserProfileResponse>
{
private readonly IUserRepository _userRepository;
public GetUserProfileQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<UserProfileResponse>> Handle(
GetUserProfileQuery request,
CancellationToken cancellationToken)
{
User? user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null) return Result.Failure<UserProfileResponse>(UserErrors.NotFound(request.UserId));
return new UserProfileResponse(
user.Id,
user.Email,
user.FirstName,
user.LastName);
}
}

View File

@@ -0,0 +1,7 @@
namespace Mccn.Modules.Users.Application.Users.GetProfile;
public sealed record UserProfileResponse(
Guid Id,
string Email,
string FirstName,
string LastName);

View File

@@ -0,0 +1,9 @@
using Mccn.Common.Application.Abstractions;
namespace Mccn.Modules.Users.Application.Users.Register;
public sealed record RegisterUserCommand(
string Email,
string FirstName,
string LastName,
string Password) : ICommand<Guid>;

View File

@@ -0,0 +1,46 @@
using Mccn.Common.Application.Abstractions;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Users.Application.Abstractions;
using Mccn.Modules.Users.Domain.Users;
namespace Mccn.Modules.Users.Application.Users.Register;
internal sealed class RegisterUserCommandHandler(
IUserRepository userRepository,
IIdentityProviderService identityProviderService,
IUnitOfWork unitOfWork)
: ICommandHandler<RegisterUserCommand, Guid>
{
public async Task<Result<Guid>> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken)
{
if (await userRepository.ExistsByEmailAsync(request.Email, cancellationToken))
return Result.Failure<Guid>(UserErrors.EmailAlreadyExists(request.Email));
UserModel userModel = new(
request.Email,
request.FirstName,
request.LastName,
request.Password);
Result<string> identityResult = await identityProviderService.RegisterUserAsync(
userModel,
cancellationToken);
if (identityResult.IsFailure) return Result.Failure<Guid>(identityResult.Error);
User user = User.Create(
Guid.NewGuid(),
request.Email,
request.FirstName,
request.LastName,
identityResult.Value);
userRepository.Add(user);
await unitOfWork.SaveChangesAsync(cancellationToken);
return user.Id;
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace Mccn.Modules.Users.Application.Users.Register;
internal sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterUserCommandValidator()
{
RuleFor(c => c.Email).NotEmpty().EmailAddress();
RuleFor(c => c.FirstName).NotEmpty().MaximumLength(100);
RuleFor(c => c.LastName).NotEmpty().MaximumLength(100);
RuleFor(c => c.Password).NotEmpty().MinimumLength(8);
}
}

View File

@@ -0,0 +1,21 @@
using System.Reflection;
using Mccn.Modules.Users.Application;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure;
namespace Mccn.Modules.Users.ArchitectureTests.Abstractions;
public abstract class BaseTest
{
protected static readonly Assembly ApplicationAssembly =
AssemblyReference.Assembly;
protected static readonly Assembly DomainAssembly =
typeof(User).Assembly;
protected static readonly Assembly InfrastructureAssembly =
typeof(DependencyInjection).Assembly;
protected static readonly Assembly PresentationAssembly =
Presentation.AssemblyReference.Assembly;
}

View File

@@ -0,0 +1,12 @@
using FluentAssertions;
using NetArchTest.Rules;
namespace Mccn.Modules.Users.ArchitectureTests.Abstractions;
internal static class TestResultExtensions
{
internal static void ShouldBeSuccessful(this TestResult testResult)
{
testResult.FailingTypes?.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,175 @@
using FluentValidation;
using Mccn.Common.Application.Abstractions;
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
using NetArchTest.Rules;
namespace Mccn.Modules.Users.ArchitectureTests.Application;
public class ApplicationTests : BaseTest
{
[Fact]
public void Command_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(ICommand))
.Or()
.ImplementInterface(typeof(ICommand<>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Command_ShouldHave_NameEndingWith_Command()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(ICommand))
.Or()
.ImplementInterface(typeof(ICommand<>))
.Should()
.HaveNameEndingWith("Command")
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void CommandHandler_Should_NotBePublic()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(ICommandHandler<>))
.Or()
.ImplementInterface(typeof(ICommandHandler<,>))
.Should()
.NotBePublic()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void CommandHandler_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(ICommandHandler<>))
.Or()
.ImplementInterface(typeof(ICommandHandler<,>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void CommandHandler_ShouldHave_NameEndingWith_CommandHandler()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(ICommandHandler<>))
.Or()
.ImplementInterface(typeof(ICommandHandler<,>))
.Should()
.HaveNameEndingWith("CommandHandler")
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Query_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQuery<>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Query_ShouldHave_NameEndingWith_Query()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQuery<>))
.Should()
.HaveNameEndingWith("Query")
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_Should_NotBePublic()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.NotBePublic()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler()
{
Types.InAssembly(ApplicationAssembly)
.That()
.ImplementInterface(typeof(IQueryHandler<,>))
.Should()
.HaveNameEndingWith("QueryHandler")
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Validator_Should_NotBePublic()
{
Types.InAssembly(ApplicationAssembly)
.That()
.Inherit(typeof(AbstractValidator<>))
.Should()
.NotBePublic()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Validator_Should_BeSealed()
{
Types.InAssembly(ApplicationAssembly)
.That()
.Inherit(typeof(AbstractValidator<>))
.Should()
.BeSealed()
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void Validator_ShouldHave_NameEndingWith_Validator()
{
Types.InAssembly(ApplicationAssembly)
.That()
.Inherit(typeof(AbstractValidator<>))
.Should()
.HaveNameEndingWith("Validator")
.GetResult()
.ShouldBeSuccessful();
}
}

View File

@@ -0,0 +1,50 @@
using System.Reflection;
using FluentAssertions;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
using NetArchTest.Rules;
namespace Mccn.Modules.Users.ArchitectureTests.Domain;
public class DomainTests : BaseTest
{
[Fact]
public void Entities_ShouldHave_PrivateParameterlessConstructor()
{
IEnumerable<Type> entityTypes = Types.InAssembly(DomainAssembly)
.That()
.Inherit(typeof(Entity))
.GetTypes();
List<Type> failingTypes = new();
foreach (Type entityType in entityTypes)
{
ConstructorInfo[] constructors = entityType.GetConstructors(
BindingFlags.NonPublic | BindingFlags.Instance);
if (!constructors.Any(c => c.IsPrivate && c.GetParameters().Length == 0)) failingTypes.Add(entityType);
}
failingTypes.Should().BeEmpty();
}
[Fact]
public void Entities_ShouldOnlyHave_PrivateConstructors()
{
IEnumerable<Type> entityTypes = Types.InAssembly(DomainAssembly)
.That()
.Inherit(typeof(Entity))
.GetTypes();
List<Type> failingTypes = new();
foreach (Type entityType in entityTypes)
{
ConstructorInfo[] constructors = entityType.GetConstructors(
BindingFlags.Public | BindingFlags.Instance);
if (constructors.Any()) failingTypes.Add(entityType);
}
failingTypes.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,57 @@
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
using NetArchTest.Rules;
namespace Mccn.Modules.Users.ArchitectureTests.Layers;
public class LayerTests : BaseTest
{
[Fact]
public void DomainLayer_ShouldNotHaveDependencyOn_ApplicationLayer()
{
Types.InAssembly(DomainAssembly)
.Should()
.NotHaveDependencyOn(ApplicationAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void DomainLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
Types.InAssembly(DomainAssembly)
.Should()
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
Types.InAssembly(ApplicationAssembly)
.Should()
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer()
{
Types.InAssembly(ApplicationAssembly)
.Should()
.NotHaveDependencyOn(PresentationAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
[Fact]
public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
Types.InAssembly(PresentationAssembly)
.Should()
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
.GetResult()
.ShouldBeSuccessful();
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj"/>
<ProjectReference Include="..\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,9 @@
namespace Mccn.Modules.Users.Domain.Users;
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default);
void Add(User user);
}

View File

@@ -0,0 +1,32 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Users.Domain.Users;
public sealed class User : Entity
{
private User()
{
}
public string Email { get; private set; } = string.Empty;
public string FirstName { get; private set; } = string.Empty;
public string LastName { get; private set; } = string.Empty;
public string IdentityId { get; private set; } = string.Empty;
public static User Create(
Guid id,
string email,
string firstName,
string lastName,
string identityId)
{
return new User
{
Id = id,
Email = email,
FirstName = firstName,
LastName = lastName,
IdentityId = identityId
};
}
}

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Users.Domain.Users;
public static class UserErrors
{
public static Error NotFound(Guid userId)
{
return Error.NotFound("Users.NotFound", $"The user with the ID '{userId}' was not found.");
}
public static Error EmailAlreadyExists(string email)
{
return Error.Conflict("Users.EmailAlreadyExists", $"A user with email '{email}' already exists.");
}
}

View File

@@ -0,0 +1,76 @@
// <auto-generated />
using System;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
{
[DbContext(typeof(UsersDbContext))]
[Migration("20260315075515_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("users")
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("email");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("IdentityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("identity_id");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("IdentityId")
.IsUnique()
.HasDatabaseName("ix_users_identity_id");
b.ToTable("users", "users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "users");
migrationBuilder.CreateTable(
name: "users",
schema: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
identity_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_users_email",
schema: "users",
table: "users",
column: "email",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_users_identity_id",
schema: "users",
table: "users",
column: "identity_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "users",
schema: "users");
}
}
}

View File

@@ -0,0 +1,73 @@
// <auto-generated />
using System;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
{
[DbContext(typeof(UsersDbContext))]
partial class UsersDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("users")
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("email");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("IdentityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("identity_id");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("IdentityId")
.IsUnique()
.HasDatabaseName("ix_users_identity_id");
b.ToTable("users", "users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Users.Infrastructure.Database;
internal static class Schemas
{
internal const string Users = "users";
}

View File

@@ -0,0 +1,20 @@
using Mccn.Modules.Users.Domain.Users;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Users.Infrastructure.Database;
public sealed class UsersDbContext : DbContext
{
public UsersDbContext(DbContextOptions<UsersDbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(Schemas.Users);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UsersDbContext).Assembly);
}
}

View File

@@ -0,0 +1,45 @@
using Mccn.Modules.Users.Application.Abstractions;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Keycloak;
using Mccn.Modules.Users.Infrastructure.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Users.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddUsersModule(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<UsersDbContext>(options =>
{
options.UseNpgsql(
configuration.GetConnectionString("Database"),
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
.UseSnakeCaseNamingConvention();
});
services.Configure<KeycloakOptions>(
configuration.GetSection(KeycloakOptions.SectionName));
services.AddTransient<KeycloakAuthDelegatingHandler>();
services.AddHttpClient<IIdentityProviderService, KeycloakClient>((sp, client) =>
{
KeycloakOptions keycloakOptions = configuration
.GetSection(KeycloakOptions.SectionName)
.Get<KeycloakOptions>()!;
client.BaseAddress = new Uri(keycloakOptions.AdminUrl);
})
.AddHttpMessageHandler<KeycloakAuthDelegatingHandler>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
return services;
}
}

View File

@@ -0,0 +1,45 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
internal sealed class KeycloakAuthDelegatingHandler : DelegatingHandler
{
private readonly KeycloakOptions _options;
public KeycloakAuthDelegatingHandler(IOptions<KeycloakOptions> options)
{
_options = options.Value;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
string token = await GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
using HttpClient client = new();
FormUrlEncodedContent content = new(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret
});
HttpResponseMessage response = await client.PostAsync(_options.TokenUrl, content, cancellationToken);
response.EnsureSuccessStatusCode();
using JsonDocument doc = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(cancellationToken),
cancellationToken: cancellationToken);
return doc.RootElement.GetProperty("access_token").GetString()!;
}
}

View File

@@ -0,0 +1,60 @@
using System.Net.Http.Json;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Users.Application.Abstractions;
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
internal sealed class KeycloakClient : IIdentityProviderService
{
private readonly HttpClient _httpClient;
public KeycloakClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Result<string>> RegisterUserAsync(
UserModel user,
CancellationToken cancellationToken = default)
{
var userRepresentation = new
{
username = user.Email,
email = user.Email,
firstName = user.FirstName,
lastName = user.LastName,
enabled = true,
credentials = new[]
{
new
{
type = "password",
value = user.Password,
temporary = false
}
}
};
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(
"users",
userRepresentation,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Failure<string>(
Error.Failure("Keycloak.Register",
$"Keycloak returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}"));
}
// Extract user ID from Location header.
string? locationHeader = response.Headers.Location?.ToString();
if (string.IsNullOrEmpty(locationHeader))
return Result.Failure<string>(
Error.Failure("Keycloak.Register", "Could not determine new user identity ID."));
string identityId = locationHeader.Split('/').Last();
return identityId;
}
}

View File

@@ -0,0 +1,12 @@
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
public sealed class KeycloakOptions
{
public const string SectionName = "Users:Keycloak";
public string AdminUrl { get; init; } = string.Empty;
public string TokenUrl { get; init; } = string.Empty;
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string PublicClientId { get; init; } = "mccn-swagger";
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj" />
<ProjectReference Include="..\Mccn.Modules.Users.Application\Mccn.Modules.Users.Application.csproj" />
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Mccn.Modules.Users.Application.Abstractions;
using Mccn.Modules.Users.Infrastructure.Database;
namespace Mccn.Modules.Users.Infrastructure.Users;
internal sealed class UnitOfWork : IUnitOfWork
{
private readonly UsersDbContext _context;
public UnitOfWork(UsersDbContext context)
{
_context = context;
}
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,33 @@
using Mccn.Modules.Users.Domain.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Mccn.Modules.Users.Infrastructure.Users;
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Email)
.IsRequired()
.HasMaxLength(300);
builder.HasIndex(u => u.Email).IsUnique();
builder.Property(u => u.FirstName)
.IsRequired()
.HasMaxLength(100);
builder.Property(u => u.LastName)
.IsRequired()
.HasMaxLength(100);
builder.Property(u => u.IdentityId)
.IsRequired()
.HasMaxLength(100);
builder.HasIndex(u => u.IdentityId).IsUnique();
}
}

View File

@@ -0,0 +1,38 @@
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Users.Infrastructure.Users;
internal sealed class UserRepository : IUserRepository
{
private readonly UsersDbContext _context;
public UserRepository(UsersDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
}
public async Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _context.Users
.AnyAsync(u => u.Email == email, cancellationToken);
}
public void Add(User user)
{
_context.Users.Add(user);
}
}

View File

@@ -0,0 +1,72 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Bogus;
using Mccn.Modules.Users.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Keycloak;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
[Collection(nameof(IntegrationTestCollection))]
public abstract class BaseIntegrationTest : IDisposable
{
protected static readonly Faker Faker = new();
private readonly KeycloakOptions _keycloakOptions;
private readonly IServiceScope _scope;
protected readonly UsersDbContext DbContext;
protected readonly HttpClient HttpClient;
protected readonly ISender Sender;
protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
{
_scope = factory.Services.CreateScope();
HttpClient = factory.CreateClient();
Sender = _scope.ServiceProvider.GetRequiredService<ISender>();
DbContext = _scope.ServiceProvider.GetRequiredService<UsersDbContext>();
_keycloakOptions = _scope.ServiceProvider
.GetRequiredService<IOptions<KeycloakOptions>>().Value;
}
public void Dispose()
{
_scope.Dispose();
}
protected async Task CleanDatabaseAsync()
{
await DbContext.Database.ExecuteSqlRawAsync("DELETE FROM users.users;");
}
protected async Task<string> GetAccessTokenAsync(string email, string password)
{
using HttpClient client = new();
KeyValuePair<string, string>[] parameters = new KeyValuePair<string, string>[]
{
new("client_id", _keycloakOptions.PublicClientId),
new("grant_type", "password"),
new("scope", "openid"),
new("username", email),
new("password", password)
};
using FormUrlEncodedContent content = new(parameters);
using HttpRequestMessage request = new(HttpMethod.Post, new Uri(_keycloakOptions.TokenUrl));
request.Content = content;
using HttpResponseMessage response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
AuthToken? authToken = await response.Content.ReadFromJsonAsync<AuthToken>();
return authToken!.AccessToken;
}
internal sealed class AuthToken
{
[JsonPropertyName("access_token")] public string AccessToken { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,4 @@
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
[CollectionDefinition(nameof(IntegrationTestCollection))]
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.Keycloak;
using Testcontainers.PostgreSql;
using Testcontainers.Redis;
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
#pragma warning disable CS0618
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithDatabase("mccn")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
private readonly RedisContainer _redisContainer = new RedisBuilder()
.WithImage("redis:latest")
.Build();
private readonly KeycloakContainer _keycloakContainer = new KeycloakBuilder()
.WithImage("quay.io/keycloak/keycloak:26.5.1")
.WithResourceMapping(
new FileInfo("mccn-realm-export.json"),
new FileInfo("/opt/keycloak/data/import/realm.json"))
.WithCommand("--import-realm")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
string keycloakAddress = _keycloakContainer.GetBaseAddress();
string keycloakRealmUrl = $"{keycloakAddress}realms/mccn";
Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString());
Environment.SetEnvironmentVariable("Authentication:MetadataAddress",
$"{keycloakRealmUrl}/.well-known/openid-configuration");
Environment.SetEnvironmentVariable("Users:Keycloak:AdminUrl", $"{keycloakAddress}admin/realms/mccn/");
Environment.SetEnvironmentVariable("Users:Keycloak:TokenUrl",
$"{keycloakRealmUrl}/protocol/openid-connect/token");
Environment.SetEnvironmentVariable("Users:Keycloak:ClientId", "mccn-api");
Environment.SetEnvironmentVariable("Users:Keycloak:ClientSecret", "change-this-secret");
Environment.SetEnvironmentVariable("Users:Keycloak:PublicClientId", "mccn-swagger");
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
await _redisContainer.StartAsync();
await _keycloakContainer.StartAsync();
}
public new async Task DisposeAsync()
{
await _dbContainer.StopAsync();
await _redisContainer.StopAsync();
await _keycloakContainer.StopAsync();
}
}
#pragma warning restore CS0618

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.5"/>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="mccn-realm-export.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
using FluentAssertions;
using Mccn.Common.Domain.Abstractions;
using Mccn.Modules.Users.Application.Users.GetProfile;
using Mccn.Modules.Users.Application.Users.Register;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.IntegrationTests.Abstractions;
namespace Mccn.Modules.Users.IntegrationTests.Users;
public class GetUserProfileTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory)
{
[Fact]
public async Task Should_ReturnError_WhenUserDoesNotExist()
{
// Arrange
Guid userId = Guid.NewGuid();
// Act
Result<UserProfileResponse> result = await Sender.Send(new GetUserProfileQuery(userId));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(UserErrors.NotFound(userId));
}
[Fact]
public async Task Should_ReturnProfile_WhenUserExists()
{
// Arrange
RegisterUserCommand command = new(
Faker.Internet.Email(),
Faker.Name.FirstName(),
Faker.Name.LastName(),
"ValidPass1!");
Result<Guid> registerResult = await Sender.Send(command);
Guid userId = registerResult.Value;
// Act
Result<UserProfileResponse> result = await Sender.Send(new GetUserProfileQuery(userId));
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Id.Should().Be(userId);
}
}

View File

@@ -0,0 +1,81 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Mccn.Modules.Users.IntegrationTests.Abstractions;
namespace Mccn.Modules.Users.IntegrationTests.Users;
public class RegisterUserTests : BaseIntegrationTest
{
public static readonly TheoryData<string, string, string, string> InvalidRequests = new()
{
{ "", Faker.Internet.Password(), Faker.Name.FirstName(), Faker.Name.LastName() },
{ Faker.Internet.Email(), "", Faker.Name.FirstName(), Faker.Name.LastName() },
{ Faker.Internet.Email(), "short", Faker.Name.FirstName(), Faker.Name.LastName() },
{ Faker.Internet.Email(), Faker.Internet.Password(), "", Faker.Name.LastName() },
{ Faker.Internet.Email(), Faker.Internet.Password(), Faker.Name.FirstName(), "" }
};
public RegisterUserTests(IntegrationTestWebAppFactory factory)
: base(factory)
{
}
[Theory]
[MemberData(nameof(InvalidRequests))]
public async Task Should_ReturnBadRequest_WhenRequestIsNotValid(
string email, string password, string firstName, string lastName)
{
// Arrange
var request = new { Email = email, FirstName = firstName, LastName = lastName, Password = password };
// Act
HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Should_ReturnCreated_WhenRequestIsValid()
{
// Arrange
var request = new
{
Email = "register@test.com",
FirstName = Faker.Name.FirstName(),
LastName = Faker.Name.LastName(),
Password = "ValidPass1!"
};
// Act
HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact]
public async Task Should_ReturnAccessToken_WhenUserIsRegistered()
{
// Arrange
const string email = "token@test.com";
const string password = "ValidPass1!";
var request = new
{
Email = email,
FirstName = Faker.Name.FirstName(),
LastName = Faker.Name.LastName(),
Password = password
};
await HttpClient.PostAsJsonAsync("users/register", request);
// Act
string accessToken = await GetAccessTokenAsync(email, password);
// Assert
accessToken.Should().NotBeNullOrEmpty();
}
}

View File

@@ -0,0 +1,85 @@
{
"realm": "mccn",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 30,
"accessTokenLifespan": 1800,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"clients": [
{
"clientId": "mccn-api",
"name": "Mccn API",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "change-this-secret",
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false
},
{
"clientId": "mccn-swagger",
"name": "Mccn Swagger UI",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": [
"http://localhost:5000/*",
"http://localhost:5001/*"
],
"webOrigins": [
"http://localhost:5000",
"http://localhost:5001"
]
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Regular user"
},
{
"name": "admin",
"description": "Administrator"
}
]
},
"users": [
{
"username": "service-account-mccn-api",
"enabled": true,
"serviceAccountClientId": "mccn-api",
"clientRoleMappings": {
"realm-management": [
{
"name": "manage-users"
},
{
"name": "view-users"
}
]
}
}
]
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Users.Presentation;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -0,0 +1,14 @@
using Mccn.Common.Presentation.Endpoints;
using Microsoft.Extensions.DependencyInjection;
namespace Mccn.Modules.Users.Presentation;
public static class DependencyInjection
{
public static IServiceCollection AddUsersPresentationServices(
this IServiceCollection services)
{
services.AddEndpoints(typeof(DependencyInjection).Assembly);
return services;
}
}

Some files were not shown because too many files have changed in this diff Show More