From 6ee9eb0b6f4bfe0672ef24915856f5dc3908fedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kuusik?= Date: Tue, 14 Apr 2026 15:18:26 +0300 Subject: [PATCH 1/3] Strongly Typed Id impl. --- fullstack2026BE/BLL/Services/UserService.cs | 6 +- .../Data.Access/Helpers/InsertHelper.cs | 18 +++- .../Repositories/IUserRepository.cs | 3 +- .../Repositories/UserRepository.cs | 3 +- .../Data.Model/BusinessStructs/UserId.cs | 8 ++ fullstack2026BE/Data.Model/DTO/UserDto.cs | 3 +- fullstack2026BE/Data.Model/Data.Model.csproj | 4 + .../Data.Model/Entities/BaseEntity.cs | 4 - .../Data.Model/Entities/UserEntity.cs | 10 ++- .../Helpers/StronglyTypedIdConverter.cs | 88 +++++++++++++++++++ .../Helpers/StronglyTypedIdHelper.cs | 56 ++++++++++++ .../Infrastructure/Models/StronglyTypedId.cs | 10 +++ .../UnitTests/BLL/Services/UserServiceTest.cs | 5 +- fullstack2026BE/UnitTests/UnitTests.csproj | 1 + .../WebApp/Controllers/v1/UserController.cs | 24 ++++- .../StronglyTypedIdNewtonsoftJsonConverter.cs | 74 ++++++++++++++++ .../Helpers/StronglyTypedIdSchemaFilter.cs | 31 +++++++ fullstack2026BE/WebApp/Program.cs | 26 ++++-- fullstack2026BE/WebApp/WebApp.csproj | 4 +- 19 files changed, 349 insertions(+), 29 deletions(-) create mode 100644 fullstack2026BE/Data.Model/BusinessStructs/UserId.cs create mode 100644 fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdConverter.cs create mode 100644 fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdHelper.cs create mode 100644 fullstack2026BE/Infrastructure/Models/StronglyTypedId.cs create mode 100644 fullstack2026BE/WebApp/Helpers/StronglyTypedIdNewtonsoftJsonConverter.cs create mode 100644 fullstack2026BE/WebApp/Helpers/StronglyTypedIdSchemaFilter.cs diff --git a/fullstack2026BE/BLL/Services/UserService.cs b/fullstack2026BE/BLL/Services/UserService.cs index 9eeaf1e..345409c 100644 --- a/fullstack2026BE/BLL/Services/UserService.cs +++ b/fullstack2026BE/BLL/Services/UserService.cs @@ -66,14 +66,14 @@ public async Task RegisterAsync(string username, string email, string p public async Task GenerateAndStoreRefreshTokenAsync(UserDto userDto) { var refreshToken = GenerateSecureRefreshToken(); - var userId = userDto.Id.GetValueOrDefault(); - if (userId == 0) throw new HttpException(HttpStatusCode.BadRequest, "User ID is required to generate refresh token"); + var userId = userDto.Id; + if (userId == null) throw new HttpException(HttpStatusCode.BadRequest, "User ID is required to generate refresh token"); var newRefreshTokenEntity = new RefreshTokenEntity { CreatedAt = DateTimeOffset.UtcNow, RefreshToken = refreshToken, ExpiresAt = DateTimeOffset.UtcNow.AddDays(1), - UserId = userId + UserId = userId.Value, }; await userRepository.DeleteAllWithUserIdAsync(userId); await userRepository.StoreRefreshTokenAsync(newRefreshTokenEntity); diff --git a/fullstack2026BE/Data.Access/Helpers/InsertHelper.cs b/fullstack2026BE/Data.Access/Helpers/InsertHelper.cs index 70ce678..fd11e93 100644 --- a/fullstack2026BE/Data.Access/Helpers/InsertHelper.cs +++ b/fullstack2026BE/Data.Access/Helpers/InsertHelper.cs @@ -1,16 +1,28 @@ using System.Reflection; using Data.Model.Entities; +using Infrastructure.Models; namespace Data.Access.Helpers; public abstract class InsertHelper { - public static Dictionary ToInsertDictionary(T entity) where T : BaseEntity + public static Dictionary ToInsertDictionary(T entity) { var dict = new Dictionary(); - foreach (var prop in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + var type = typeof(T); + var stronglyTypedIdType = typeof(StronglyTypedId<>); + + foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) { - if (prop.Name == nameof(BaseEntity.Id)) continue; + if (prop.Name == "Id") + { + var propType = prop.PropertyType; + // Check if Id is a strongly-typed ID (i.e., inherits from StronglyTypedId<>) + if (propType.IsGenericType && propType.GetGenericTypeDefinition() == stronglyTypedIdType) + { + continue; + } + } dict[prop.Name] = prop.GetValue(entity); } return dict; diff --git a/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs b/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs index 9d2f8f0..ff5a05e 100644 --- a/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs +++ b/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs @@ -1,3 +1,4 @@ +using Data.Model.BusinessStructs; using Data.Model.Entities; namespace Data.Access.Repositories; @@ -9,6 +10,6 @@ public interface IUserRepository Task AddAsync(UserEntity user); Task GetUserByTokensAsync(string refreshToken); Task StoreRefreshTokenAsync(RefreshTokenEntity entity); - Task DeleteAllWithUserIdAsync(int userId); + Task DeleteAllWithUserIdAsync(UserId userId); Task RevokeRefreshToken(string refreshToken); } \ No newline at end of file diff --git a/fullstack2026BE/Data.Access/Repositories/UserRepository.cs b/fullstack2026BE/Data.Access/Repositories/UserRepository.cs index 88c5555..70a7acb 100644 --- a/fullstack2026BE/Data.Access/Repositories/UserRepository.cs +++ b/fullstack2026BE/Data.Access/Repositories/UserRepository.cs @@ -1,5 +1,6 @@ using Data.Access.Helpers; +using Data.Model.BusinessStructs; using Data.Model.Entities; using Infrastructure.DependencyInjection; using Infrastructure.Helpers; @@ -54,7 +55,7 @@ await conn.SqlKata() .InsertAsync(InsertHelper.ToInsertDictionary(entity)); } - public async Task DeleteAllWithUserIdAsync(int userId) + public async Task DeleteAllWithUserIdAsync(UserId userId) { await using var conn = await MainDb.OpenConnectionAsync(); await conn.SqlKata() diff --git a/fullstack2026BE/Data.Model/BusinessStructs/UserId.cs b/fullstack2026BE/Data.Model/BusinessStructs/UserId.cs new file mode 100644 index 0000000..bc10a74 --- /dev/null +++ b/fullstack2026BE/Data.Model/BusinessStructs/UserId.cs @@ -0,0 +1,8 @@ +using Infrastructure.Models; + +namespace Data.Model.BusinessStructs; + +public record UserId(int Value) : StronglyTypedId(Value) +{ + public override string ToString() => Value.ToString(); +} diff --git a/fullstack2026BE/Data.Model/DTO/UserDto.cs b/fullstack2026BE/Data.Model/DTO/UserDto.cs index c1a2460..cf1192a 100644 --- a/fullstack2026BE/Data.Model/DTO/UserDto.cs +++ b/fullstack2026BE/Data.Model/DTO/UserDto.cs @@ -1,11 +1,12 @@ using System.Text.Json.Serialization; +using Data.Model.BusinessStructs; namespace Data.Model.DTO; public record UserDto { [JsonIgnore] - public int? Id { get; init; } + public UserId? Id { get; init; } public required string Username { get; set; } public required string Email { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/fullstack2026BE/Data.Model/Data.Model.csproj b/fullstack2026BE/Data.Model/Data.Model.csproj index 17b910f..8f46594 100644 --- a/fullstack2026BE/Data.Model/Data.Model.csproj +++ b/fullstack2026BE/Data.Model/Data.Model.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/fullstack2026BE/Data.Model/Entities/BaseEntity.cs b/fullstack2026BE/Data.Model/Entities/BaseEntity.cs index da3f9c2..0af0516 100644 --- a/fullstack2026BE/Data.Model/Entities/BaseEntity.cs +++ b/fullstack2026BE/Data.Model/Entities/BaseEntity.cs @@ -1,11 +1,7 @@ -using System.ComponentModel.DataAnnotations; - namespace Data.Model.Entities; public class BaseEntity { - [Key] - public int? Id { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } } \ No newline at end of file diff --git a/fullstack2026BE/Data.Model/Entities/UserEntity.cs b/fullstack2026BE/Data.Model/Entities/UserEntity.cs index db4af34..2a7f9dc 100644 --- a/fullstack2026BE/Data.Model/Entities/UserEntity.cs +++ b/fullstack2026BE/Data.Model/Entities/UserEntity.cs @@ -1,11 +1,15 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Data.Model.BusinessStructs; namespace Data.Model.Entities; [Table("Users")] public class UserEntity : BaseEntity { - public required string Username { get; set; } - public required string Password { get; set; } - public required string Email { get; set; } + [Key] + public UserId? Id { get; init; } + public required string Username { get; init; } + public required string Password { get; init; } + public required string Email { get; init; } } \ No newline at end of file diff --git a/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdConverter.cs b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdConverter.cs new file mode 100644 index 0000000..f96b65d --- /dev/null +++ b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdConverter.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Globalization; +using Infrastructure.Models; + +namespace Infrastructure.Helpers; + +public class StronglyTypedIdConverter(Type type) : TypeConverter + where TValue : notnull +{ + private static readonly TypeConverter IdValueConverter = GetIdValueConverter(); + + private static TypeConverter GetIdValueConverter() + { + var converter = TypeDescriptor.GetConverter(typeof(TValue)); + if (!converter.CanConvertFrom(typeof(string))) + throw new InvalidOperationException( + $"Type '{typeof(TValue)}' doesn't have a converter that can convert from string"); + return converter; + } + + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + return sourceType == typeof(string) + || sourceType == typeof(TValue) + || base.CanConvertFrom(context, sourceType); + } + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + { + return destinationType == typeof(string) + || destinationType == typeof(TValue) + || base.CanConvertTo(context, destinationType); + } + + public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is string s) + { + value = IdValueConverter.ConvertFrom(s) ?? throw new InvalidOperationException(); + } + + if (value is TValue idValue) + { + var factory = StronglyTypedIdHelper.GetFactory(type); + return factory(idValue); + } + + return base.ConvertFrom(context, culture, value) ?? throw new InvalidOperationException(); + } + + public override object ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) + { + ArgumentNullException.ThrowIfNull(value); + + var stronglyTypedId = (StronglyTypedId)value; + var idValue = stronglyTypedId.Value; + if (destinationType == typeof(string)) + return idValue.ToString() ?? throw new InvalidOperationException(); + return (destinationType == typeof(TValue) ? idValue : base.ConvertTo(context, culture, value, destinationType)) ?? throw new InvalidOperationException(); + } +} +public class StronglyTypedIdConverter(Type stronglyTypedIdType) : TypeConverter +{ + private static readonly ConcurrentDictionary ActualConverters = new(); + + private readonly TypeConverter _innerConverter = ActualConverters.GetOrAdd(stronglyTypedIdType, CreateActualConverter); + + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + _innerConverter.CanConvertFrom(context, sourceType); + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => + _innerConverter.CanConvertTo(context, destinationType); + public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => + _innerConverter.ConvertFrom(context, culture, value) ?? throw new InvalidOperationException(); + public override object ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => + _innerConverter.ConvertTo(context, culture, value, destinationType) ?? throw new InvalidOperationException(); + + + private static TypeConverter CreateActualConverter(Type stronglyTypedIdType) + { + if (!StronglyTypedIdHelper.IsStronglyTypedId(stronglyTypedIdType, out var idType)) + throw new InvalidOperationException($"The type '{stronglyTypedIdType}' is not a strongly typed id"); + + var actualConverterType = typeof(StronglyTypedIdConverter<>).MakeGenericType(idType); + var activator = Activator.CreateInstance(actualConverterType, stronglyTypedIdType) ?? throw new InvalidOperationException(); + return (TypeConverter)activator; + } +} \ No newline at end of file diff --git a/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdHelper.cs b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdHelper.cs new file mode 100644 index 0000000..73129e5 --- /dev/null +++ b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdHelper.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Infrastructure.Models; + +namespace Infrastructure.Helpers; + +public static class StronglyTypedIdHelper +{ + private static readonly ConcurrentDictionary StronglyTypedIdFactories = new(); + + public static Func GetFactory(Type stronglyTypedIdType) + where TValue : notnull + { + return (Func)StronglyTypedIdFactories.GetOrAdd( + stronglyTypedIdType, + CreateFactory); + } + + private static Func CreateFactory(Type stronglyTypedIdType) + where TValue : notnull + { + if (!IsStronglyTypedId(stronglyTypedIdType)) + throw new ArgumentException($"Type '{stronglyTypedIdType}' is not a strongly-typed id type", nameof(stronglyTypedIdType)); + + var ctor = stronglyTypedIdType.GetConstructor([typeof(TValue)]); + if (ctor is null) + throw new ArgumentException($"Type '{stronglyTypedIdType}' doesn't have a constructor with one parameter of type '{typeof(TValue)}'", nameof(stronglyTypedIdType)); + + var param = Expression.Parameter(typeof(TValue), "value"); + var body = Expression.New(ctor, param); + var lambda = Expression.Lambda>(body, param); + return lambda.Compile(); + } + + public static bool IsStronglyTypedId(Type? type) + { + return type is not null && IsStronglyTypedId(type, out _); + } + + + public static bool IsStronglyTypedId(Type type, [NotNullWhen(true)] out Type idType) + { + ArgumentNullException.ThrowIfNull(type); + + if (type.BaseType is Type { IsGenericType: true } baseType && + baseType.GetGenericTypeDefinition() == typeof(StronglyTypedId<>)) + { + idType = baseType.GetGenericArguments()[0]; + return true; + } + + idType = null; + return false; + } +} \ No newline at end of file diff --git a/fullstack2026BE/Infrastructure/Models/StronglyTypedId.cs b/fullstack2026BE/Infrastructure/Models/StronglyTypedId.cs new file mode 100644 index 0000000..5a6e9ad --- /dev/null +++ b/fullstack2026BE/Infrastructure/Models/StronglyTypedId.cs @@ -0,0 +1,10 @@ +using System.ComponentModel; +using Infrastructure.Helpers; + +namespace Infrastructure.Models; + +[TypeConverter(typeof(StronglyTypedIdConverter))] +public abstract record StronglyTypedId(TValue Value) + where TValue : notnull +{ +} \ No newline at end of file diff --git a/fullstack2026BE/UnitTests/BLL/Services/UserServiceTest.cs b/fullstack2026BE/UnitTests/BLL/Services/UserServiceTest.cs index 2c2e30a..a021c55 100644 --- a/fullstack2026BE/UnitTests/BLL/Services/UserServiceTest.cs +++ b/fullstack2026BE/UnitTests/BLL/Services/UserServiceTest.cs @@ -1,5 +1,6 @@ using BLL.Services; using Data.Access.Repositories; +using Data.Model.BusinessStructs; using Data.Model.DTO; using NSubstitute; using NUnit.Framework; @@ -27,8 +28,8 @@ public void Setup() public async Task GetAllAsync_WhenCalled_ReturnsAllUsersMapped() { _repository.GetAllAsync().Returns([ - new UserEntity { Id = 1, Username = "user1", Email = "1", Password = "1" }, - new UserEntity { Id = 2, Username = "user2", Email = "2", Password = "2" } + new UserEntity { Id = new UserId(1), Username = "user1", Email = "1", Password = "1" }, + new UserEntity { Id = new UserId(2), Username = "user2", Email = "2", Password = "2" } ]); var result = await _userService.GetAllAsync(); diff --git a/fullstack2026BE/UnitTests/UnitTests.csproj b/fullstack2026BE/UnitTests/UnitTests.csproj index bfb83e2..87d6fbc 100644 --- a/fullstack2026BE/UnitTests/UnitTests.csproj +++ b/fullstack2026BE/UnitTests/UnitTests.csproj @@ -5,6 +5,7 @@ enable enable UniTests + CS8618 diff --git a/fullstack2026BE/WebApp/Controllers/v1/UserController.cs b/fullstack2026BE/WebApp/Controllers/v1/UserController.cs index e3c4c82..e3df9d9 100644 --- a/fullstack2026BE/WebApp/Controllers/v1/UserController.cs +++ b/fullstack2026BE/WebApp/Controllers/v1/UserController.cs @@ -1,4 +1,5 @@ using BLL.Services; +using Data.Model.BusinessStructs; using Infrastructure.Logging; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,7 +9,8 @@ namespace WebApp.Controllers.V1 [ApiController] [Authorize] // Require authentication by default for all endpoints in this controller [Route("api/v{version:apiVersion}/user")] - + [ApiVersion("1.0")] + public class UserController(IUserService userService) : ControllerBase { /// @@ -26,7 +28,7 @@ public async Task GetUsers() /// [HttpGet("{id}")] [Authorize(Roles = "Admin")] - public async Task GetUser(int id) + public ActionResult GetUser(UserId id) { // This would require actual implementation in the service return Ok(new { id, message = "Admin only endpoint" }); @@ -37,10 +39,24 @@ public async Task GetUser(int id) /// [HttpGet("public")] [AllowAnonymous] - public ActionResult GetPublicData() + public ActionResult GetPublicData([FromQuery] UserId id) + { + var res = new Test { Id = id, Value = "feag" }; + return Ok(res); + } + + [HttpPost("test")] + [AllowAnonymous] + public ActionResult PostTest([FromBody] Test dto) { - return Ok(new { message = "This is publicly accessible data" }); + return Ok(dto); } } + + public class Test + { + public UserId Id { get; set; } + public string Value { get; set; } + } } diff --git a/fullstack2026BE/WebApp/Helpers/StronglyTypedIdNewtonsoftJsonConverter.cs b/fullstack2026BE/WebApp/Helpers/StronglyTypedIdNewtonsoftJsonConverter.cs new file mode 100644 index 0000000..1b21ffa --- /dev/null +++ b/fullstack2026BE/WebApp/Helpers/StronglyTypedIdNewtonsoftJsonConverter.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using Infrastructure.Helpers; +using Infrastructure.Models; +using Newtonsoft.Json; + +namespace WebApp.Helpers; + +public class StronglyTypedIdNewtonsoftJsonConverter : JsonConverter +{ + private static readonly ConcurrentDictionary Cache = new(); + + public override bool CanConvert(Type objectType) + { + return StronglyTypedIdHelper.IsStronglyTypedId(objectType); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var converter = GetConverter(objectType); + return converter.ReadJson(reader, objectType, existingValue, serializer); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + } + else + { + var converter = GetConverter(value.GetType()); + converter.WriteJson(writer, value, serializer); + } + } + + private static JsonConverter GetConverter(Type objectType) + { + return Cache.GetOrAdd(objectType, CreateConverter); + } + + private static JsonConverter CreateConverter(Type objectType) + { + if (!StronglyTypedIdHelper.IsStronglyTypedId(objectType, out var valueType)) + throw new InvalidOperationException($"Cannot create converter for '{objectType}'"); + + var type = typeof(StronglyTypedIdNewtonsoftJsonConverter<,>).MakeGenericType(objectType, valueType); + var converter = Activator.CreateInstance(type) ?? throw new InvalidOperationException($"Cannot create converter for '{objectType}'"); + return (JsonConverter)converter; + } +} + +public class StronglyTypedIdNewtonsoftJsonConverter : JsonConverter + where TStronglyTypedId : StronglyTypedId + where TValue : notnull +{ + public override TStronglyTypedId? ReadJson(JsonReader reader, Type objectType, TStronglyTypedId? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + + var value = serializer.Deserialize(reader); + if (value is null) throw new JsonSerializationException($"Cannot convert null value to {typeof(TValue)}"); + var factory = StronglyTypedIdHelper.GetFactory(objectType); + return (TStronglyTypedId)factory(value); + } + + public override void WriteJson(JsonWriter writer, TStronglyTypedId? value, JsonSerializer serializer) + { + if (value is null) + writer.WriteNull(); + else + writer.WriteValue(value.Value); + } +} \ No newline at end of file diff --git a/fullstack2026BE/WebApp/Helpers/StronglyTypedIdSchemaFilter.cs b/fullstack2026BE/WebApp/Helpers/StronglyTypedIdSchemaFilter.cs new file mode 100644 index 0000000..c4547a7 --- /dev/null +++ b/fullstack2026BE/WebApp/Helpers/StronglyTypedIdSchemaFilter.cs @@ -0,0 +1,31 @@ +using Infrastructure.Models; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace WebApp.Helpers; + +public class StronglyTypedIdSchemaFilter : ISchemaFilter +{ + private static bool IsStronglyTypedId(Type type) + { + while (type != null && type != typeof(object)) + { + if (type.IsGenericType && type.GetGenericTypeDefinition().Name == "StronglyTypedId`1") + return true; + type = type.BaseType; + } + return false; + } + + public void Apply(OpenApiSchema? schema, SchemaFilterContext? context) + { + if (schema == null || context == null) return; + if (!IsStronglyTypedId(context.Type)) return; + // Optionally, detect the underlying type (int, Guid, string, etc.) + schema.Type = "integer"; // or "string" for Guid/string + schema.Format = null; + schema.Properties.Clear(); + schema.Reference = null; + schema.AdditionalPropertiesAllowed = false; + } +} \ No newline at end of file diff --git a/fullstack2026BE/WebApp/Program.cs b/fullstack2026BE/WebApp/Program.cs index 05c3df6..f496c61 100644 --- a/fullstack2026BE/WebApp/Program.cs +++ b/fullstack2026BE/WebApp/Program.cs @@ -7,6 +7,7 @@ using Microsoft.IdentityModel.Tokens; using System.Text; using Infrastructure.Logging; +using WebApp.Helpers; using WebApp.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -21,7 +22,6 @@ var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get() ?? throw new InvalidOperationException("JwtSettings configuration is missing."); -// Add JWT Authentication builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -64,7 +64,13 @@ // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.ConfigureDi(); -builder.Services.AddControllers(); +builder.Services.AddControllers() + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Converters.Add( + new StronglyTypedIdNewtonsoftJsonConverter()); + }); + builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); @@ -72,6 +78,12 @@ options.ReportApiVersions = true; }); builder.Services.AddOpenApi(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SchemaFilter(); +}); + var app = builder.Build(); Logger.Info("=== Server started, CORS active for http://localhost:4200 ==="); @@ -80,11 +92,13 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); } -app.UseRouting(); // 1. Routing -app.UseCors("AllowAngularDev"); // 2. CORS — must be after UseRouting, before UseAuth* -app.UseAuthentication(); // 3. Authentication -app.UseAuthorization(); // 4. Authorization +app.UseRouting(); +app.UseCors("AllowAngularDev"); +app.UseAuthentication(); +app.UseAuthorization(); app.MapControllers(); app.MapStaticAssets(); diff --git a/fullstack2026BE/WebApp/WebApp.csproj b/fullstack2026BE/WebApp/WebApp.csproj index e026bf9..f5ff654 100644 --- a/fullstack2026BE/WebApp/WebApp.csproj +++ b/fullstack2026BE/WebApp/WebApp.csproj @@ -8,13 +8,15 @@ + + - + From e314b0fe5d947b4dd054c46ba9dd6d163ab48e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kuusik?= Date: Tue, 14 Apr 2026 17:03:41 +0300 Subject: [PATCH 2/3] SQL to work with stronglyTypedID --- fullstack2026BE/Data.Model/DTO/UserDto.cs | 2 +- fullstack2026BE/Data.Model/Data.Model.csproj | 4 ++ .../Helpers/StronglyTypedIdTypeHandler.cs | 16 +++++++ .../WebApp/Configs/DapperConfig.cs | 44 +++++++++++++++++++ .../WebApp/Controllers/v1/UserController.cs | 23 ---------- fullstack2026BE/WebApp/Program.cs | 35 ++++++++++++++- 6 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdTypeHandler.cs create mode 100644 fullstack2026BE/WebApp/Configs/DapperConfig.cs diff --git a/fullstack2026BE/Data.Model/DTO/UserDto.cs b/fullstack2026BE/Data.Model/DTO/UserDto.cs index cf1192a..0146997 100644 --- a/fullstack2026BE/Data.Model/DTO/UserDto.cs +++ b/fullstack2026BE/Data.Model/DTO/UserDto.cs @@ -1,5 +1,5 @@ -using System.Text.Json.Serialization; using Data.Model.BusinessStructs; +using Newtonsoft.Json; namespace Data.Model.DTO; diff --git a/fullstack2026BE/Data.Model/Data.Model.csproj b/fullstack2026BE/Data.Model/Data.Model.csproj index 8f46594..f2433ec 100644 --- a/fullstack2026BE/Data.Model/Data.Model.csproj +++ b/fullstack2026BE/Data.Model/Data.Model.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdTypeHandler.cs b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdTypeHandler.cs new file mode 100644 index 0000000..602f20d --- /dev/null +++ b/fullstack2026BE/Infrastructure/Helpers/StronglyTypedIdTypeHandler.cs @@ -0,0 +1,16 @@ +using System.Data; +using Dapper; +using Infrastructure.Models; + +namespace Infrastructure.Helpers; + +public class StronglyTypedIdTypeHandler(Func factory) : SqlMapper.TypeHandler + where TId : StronglyTypedId + where TValue : notnull +{ + public override TId Parse(object value) + => factory((TValue)Convert.ChangeType(value, typeof(TValue))); + + public override void SetValue(IDbDataParameter parameter, TId value) + => parameter.Value = value.Value; +} \ No newline at end of file diff --git a/fullstack2026BE/WebApp/Configs/DapperConfig.cs b/fullstack2026BE/WebApp/Configs/DapperConfig.cs new file mode 100644 index 0000000..6f05e4d --- /dev/null +++ b/fullstack2026BE/WebApp/Configs/DapperConfig.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using Dapper; +using Data.Model.BusinessStructs; +using Infrastructure.Helpers; +using Infrastructure.Models; + +namespace WebApp.Configs; + +public static class DapperConfig +{ + public static void RegisterTypeHandlers() + { + var stronglyTypedIdType = typeof(StronglyTypedId<>); + + var idTypes = typeof(UserId).Assembly // or wherever your IDs live + .GetTypes() + .Where(t => t is { IsAbstract: false, BaseType.IsGenericType: true } + && t.BaseType.GetGenericTypeDefinition() == stronglyTypedIdType); + + foreach (var idType in idTypes) + { + var valueType = idType.BaseType!.GetGenericArguments()[0]; + var handlerType = typeof(StronglyTypedIdTypeHandler<,>) + .MakeGenericType(idType, valueType); + + // Build factory: v => new UserId(v) + var ctor = idType.GetConstructor([valueType])!; + var param = Expression.Parameter(valueType, "v"); + var factory = Expression.Lambda( + Expression.New(ctor, param), param + ).Compile(); + + var handler = Activator.CreateInstance(handlerType, factory)!; + + // SqlMapper.AddTypeHandler(handler) via reflection + typeof(SqlMapper) + .GetMethods() + .First(m => m is { Name: "AddTypeHandler", IsGenericMethod: true } + && m.GetParameters().Length == 1) + .MakeGenericMethod(idType) + .Invoke(null, [handler]); + } + } +} \ No newline at end of file diff --git a/fullstack2026BE/WebApp/Controllers/v1/UserController.cs b/fullstack2026BE/WebApp/Controllers/v1/UserController.cs index e3df9d9..27755a1 100644 --- a/fullstack2026BE/WebApp/Controllers/v1/UserController.cs +++ b/fullstack2026BE/WebApp/Controllers/v1/UserController.cs @@ -34,29 +34,6 @@ public ActionResult GetUser(UserId id) return Ok(new { id, message = "Admin only endpoint" }); } - /// - /// Public endpoint - allows anonymous access - /// - [HttpGet("public")] - [AllowAnonymous] - public ActionResult GetPublicData([FromQuery] UserId id) - { - var res = new Test { Id = id, Value = "feag" }; - return Ok(res); - } - - [HttpPost("test")] - [AllowAnonymous] - public ActionResult PostTest([FromBody] Test dto) - { - return Ok(dto); - } - } - - public class Test - { - public UserId Id { get; set; } - public string Value { get; set; } } } diff --git a/fullstack2026BE/WebApp/Program.cs b/fullstack2026BE/WebApp/Program.cs index f496c61..1fe1da9 100644 --- a/fullstack2026BE/WebApp/Program.cs +++ b/fullstack2026BE/WebApp/Program.cs @@ -7,6 +7,8 @@ using Microsoft.IdentityModel.Tokens; using System.Text; using Infrastructure.Logging; +using Microsoft.OpenApi.Models; +using WebApp.Configs; using WebApp.Helpers; using WebApp.Middleware; @@ -79,10 +81,39 @@ }); builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(c => +builder.Services.AddSwaggerGen(options => { - c.SchemaFilter(); + options.SchemaFilter(); + options.SwaggerDoc("v1", new OpenApiInfo { Title = "TripPlanner API", Version = "v1" }); + + // 1. Define what "Bearer" means to Swagger + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter your JWT token. The 'Bearer ' prefix is added automatically." + }); + + // 2. Tell Swagger to send it on every request + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); }); +DapperConfig.RegisterTypeHandlers(); var app = builder.Build(); Logger.Info("=== Server started, CORS active for http://localhost:4200 ==="); From 66275a76a9378109385761b3e91d3c77684d32ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kuusik?= Date: Tue, 14 Apr 2026 20:57:34 +0300 Subject: [PATCH 3/3] Auth fixing --- .../Services/Auth/AuthService.cs} | 33 +++++++++++----- .../BLL/Services/Auth/IAuthService.cs | 12 ++++++ fullstack2026BE/BLL/Services/IUserService.cs | 3 +- fullstack2026BE/BLL/Services/UserService.cs | 8 ++-- .../Repositories/IUserRepository.cs | 2 +- .../Repositories/UserRepository.cs | 2 +- fullstack2026BE/Data.Model/DTO/UserDto.cs | 2 +- .../Data.Model/Entities/UserEntity.cs | 2 +- .../Data.Model/Results/TokenCreationResult.cs | 5 +++ .../Infrastructure/Infrastructure.csproj | 4 ++ .../Services/IJwtTokenService.cs | 10 ----- .../WebApp/Controllers/AuthController.cs | 38 ++++++------------- fullstack2026BE/WebApp/Models/AuthModels.cs | 2 - 13 files changed, 66 insertions(+), 57 deletions(-) rename fullstack2026BE/{Infrastructure/Services/JwtTokenService.cs => BLL/Services/Auth/AuthService.cs} (64%) create mode 100644 fullstack2026BE/BLL/Services/Auth/IAuthService.cs create mode 100644 fullstack2026BE/Data.Model/Results/TokenCreationResult.cs delete mode 100644 fullstack2026BE/Infrastructure/Services/IJwtTokenService.cs diff --git a/fullstack2026BE/Infrastructure/Services/JwtTokenService.cs b/fullstack2026BE/BLL/Services/Auth/AuthService.cs similarity index 64% rename from fullstack2026BE/Infrastructure/Services/JwtTokenService.cs rename to fullstack2026BE/BLL/Services/Auth/AuthService.cs index 733059a..21ffbcd 100644 --- a/fullstack2026BE/Infrastructure/Services/JwtTokenService.cs +++ b/fullstack2026BE/BLL/Services/Auth/AuthService.cs @@ -1,33 +1,32 @@ using System.IdentityModel.Tokens.Jwt; +using System.Net; using System.Security.Claims; using System.Text; +using Data.Access.Repositories; +using Data.Model.BusinessStructs; +using Data.Model.Results; +using Data.Model.Utils; using Infrastructure.DependencyInjection; using Infrastructure.Settings; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -namespace Infrastructure.Services; +namespace BLL.Services.Auth; -public class JwtTokenService(IOptions jwtSettings) : IJwtTokenService, IScopedDependency +public class AuthService(IOptions jwtSettings, IUserService userService) : IAuthService, IScopedDependency { private readonly JwtSettings _jwtSettings = jwtSettings.Value; - public string GenerateToken(string userId, string email, List? roles = null) + private string GenerateToken(string username, string email) { var claims = new List { - new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.NameIdentifier, username), new(ClaimTypes.Email, email), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) }; - // Add roles as claims - if (roles is { Count: > 0 }) - { - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - } - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -42,6 +41,20 @@ public string GenerateToken(string userId, string email, List? roles = n return new JwtSecurityTokenHandler().WriteToken(token); } + public async Task GenerateTokenAsync(string username, string password) + { + var user = await userService.GetByCredentialsAsync(username, password) ?? throw new UnauthorizedAccessException(); + var token = GenerateToken(user.Username, user.Email); + return new TokenCreationResult(user.Id, token); + } + + public async Task RenewTokenAsync(string refreshToken) + { + var user = await userService.GetUserByTokenAsync(refreshToken) ?? throw new UnauthorizedAccessException(); + var token = GenerateToken(user.Username, user.Email); + return new TokenCreationResult(user.Id, token); + } + public ClaimsPrincipal? ValidateToken(string token) { var tokenHandler = new JwtSecurityTokenHandler(); diff --git a/fullstack2026BE/BLL/Services/Auth/IAuthService.cs b/fullstack2026BE/BLL/Services/Auth/IAuthService.cs new file mode 100644 index 0000000..cbbc999 --- /dev/null +++ b/fullstack2026BE/BLL/Services/Auth/IAuthService.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; +using Data.Model.Results; + +namespace BLL.Services.Auth; + +public interface IAuthService +{ + Task GenerateTokenAsync(string username, string password); + Task RenewTokenAsync(string refreshToken); + ClaimsPrincipal? ValidateToken(string token); +} + diff --git a/fullstack2026BE/BLL/Services/IUserService.cs b/fullstack2026BE/BLL/Services/IUserService.cs index ce6d0a7..9f995ef 100644 --- a/fullstack2026BE/BLL/Services/IUserService.cs +++ b/fullstack2026BE/BLL/Services/IUserService.cs @@ -1,3 +1,4 @@ +using Data.Model.BusinessStructs; using Data.Model.DTO; namespace BLL.Services; @@ -14,5 +15,5 @@ public interface IUserService Task RegisterAsync(string username, string email, string password); Task GetUserByTokenAsync(string refreshToken); - Task GenerateAndStoreRefreshTokenAsync(UserDto userDto); + Task GenerateAndStoreRefreshTokenAsync(UserId userId); } \ No newline at end of file diff --git a/fullstack2026BE/BLL/Services/UserService.cs b/fullstack2026BE/BLL/Services/UserService.cs index 345409c..d345d3f 100644 --- a/fullstack2026BE/BLL/Services/UserService.cs +++ b/fullstack2026BE/BLL/Services/UserService.cs @@ -5,6 +5,7 @@ using Data.Model.Utils; using Infrastructure.DependencyInjection; using System.Net; +using Data.Model.BusinessStructs; namespace BLL.Services; @@ -52,7 +53,7 @@ public async Task RegisterAsync(string username, string email, string p public async Task GetUserByTokenAsync(string refreshToken) { - var entity = await userRepository.GetUserByTokensAsync(refreshToken); + var entity = await userRepository.GetUserByTokenAsync(refreshToken); return entity == null ? null : new UserDto { Id = entity.Id, @@ -63,11 +64,10 @@ public async Task RegisterAsync(string username, string email, string p }; } - public async Task GenerateAndStoreRefreshTokenAsync(UserDto userDto) + public async Task GenerateAndStoreRefreshTokenAsync(UserId userId) { - var refreshToken = GenerateSecureRefreshToken(); - var userId = userDto.Id; if (userId == null) throw new HttpException(HttpStatusCode.BadRequest, "User ID is required to generate refresh token"); + var refreshToken = GenerateSecureRefreshToken(); var newRefreshTokenEntity = new RefreshTokenEntity { CreatedAt = DateTimeOffset.UtcNow, diff --git a/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs b/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs index ff5a05e..ebd09bc 100644 --- a/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs +++ b/fullstack2026BE/Data.Access/Repositories/IUserRepository.cs @@ -8,7 +8,7 @@ public interface IUserRepository Task> GetAllAsync(); Task GetByEmailAsync(string email); Task AddAsync(UserEntity user); - Task GetUserByTokensAsync(string refreshToken); + Task GetUserByTokenAsync(string refreshToken); Task StoreRefreshTokenAsync(RefreshTokenEntity entity); Task DeleteAllWithUserIdAsync(UserId userId); Task RevokeRefreshToken(string refreshToken); diff --git a/fullstack2026BE/Data.Access/Repositories/UserRepository.cs b/fullstack2026BE/Data.Access/Repositories/UserRepository.cs index 70a7acb..df487d2 100644 --- a/fullstack2026BE/Data.Access/Repositories/UserRepository.cs +++ b/fullstack2026BE/Data.Access/Repositories/UserRepository.cs @@ -35,7 +35,7 @@ await conn.SqlKata() .InsertAsync(InsertHelper.ToInsertDictionary(user)); } - public async Task GetUserByTokensAsync(string refreshToken) + public async Task GetUserByTokenAsync(string refreshToken) { var currentTime = DateTimeOffset.UtcNow; await using var conn = await MainDb.OpenConnectionAsync(); diff --git a/fullstack2026BE/Data.Model/DTO/UserDto.cs b/fullstack2026BE/Data.Model/DTO/UserDto.cs index 0146997..5ad332f 100644 --- a/fullstack2026BE/Data.Model/DTO/UserDto.cs +++ b/fullstack2026BE/Data.Model/DTO/UserDto.cs @@ -6,7 +6,7 @@ namespace Data.Model.DTO; public record UserDto { [JsonIgnore] - public UserId? Id { get; init; } + public required UserId Id { get; init; } public required string Username { get; set; } public required string Email { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/fullstack2026BE/Data.Model/Entities/UserEntity.cs b/fullstack2026BE/Data.Model/Entities/UserEntity.cs index 2a7f9dc..aba6c8e 100644 --- a/fullstack2026BE/Data.Model/Entities/UserEntity.cs +++ b/fullstack2026BE/Data.Model/Entities/UserEntity.cs @@ -8,7 +8,7 @@ namespace Data.Model.Entities; public class UserEntity : BaseEntity { [Key] - public UserId? Id { get; init; } + public UserId Id { get; init; } = new UserId(0); public required string Username { get; init; } public required string Password { get; init; } public required string Email { get; init; } diff --git a/fullstack2026BE/Data.Model/Results/TokenCreationResult.cs b/fullstack2026BE/Data.Model/Results/TokenCreationResult.cs new file mode 100644 index 0000000..2a1846c --- /dev/null +++ b/fullstack2026BE/Data.Model/Results/TokenCreationResult.cs @@ -0,0 +1,5 @@ +using Data.Model.BusinessStructs; + +namespace Data.Model.Results; + +public record TokenCreationResult(UserId UserId, string JwtToken); \ No newline at end of file diff --git a/fullstack2026BE/Infrastructure/Infrastructure.csproj b/fullstack2026BE/Infrastructure/Infrastructure.csproj index 14f9995..cfc2f29 100644 --- a/fullstack2026BE/Infrastructure/Infrastructure.csproj +++ b/fullstack2026BE/Infrastructure/Infrastructure.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/fullstack2026BE/Infrastructure/Services/IJwtTokenService.cs b/fullstack2026BE/Infrastructure/Services/IJwtTokenService.cs deleted file mode 100644 index 7e18273..0000000 --- a/fullstack2026BE/Infrastructure/Services/IJwtTokenService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Security.Claims; - -namespace Infrastructure.Services; - -public interface IJwtTokenService -{ - string GenerateToken(string userId, string email, List? roles = null); - ClaimsPrincipal? ValidateToken(string token); -} - diff --git a/fullstack2026BE/WebApp/Controllers/AuthController.cs b/fullstack2026BE/WebApp/Controllers/AuthController.cs index e88f38c..90bd8a0 100644 --- a/fullstack2026BE/WebApp/Controllers/AuthController.cs +++ b/fullstack2026BE/WebApp/Controllers/AuthController.cs @@ -1,6 +1,6 @@ using BLL.Services; +using BLL.Services.Auth; using Data.Model.Requests; -using Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using WebApp.Models; @@ -10,7 +10,7 @@ namespace WebApp.Controllers; [ApiController] [AllowAnonymous] [Route("api/auth")] -public class AuthController(IJwtTokenService jwtTokenService, IUserService userService) : ControllerBase +public class AuthController(IAuthService authService, IUserService userService) : ControllerBase { /// /// Login endpoint - generates JWT token @@ -25,25 +25,14 @@ public async Task> Login([FromBody] LoginRequest req return BadRequest(new { message = "Email and password are required" }); } - try - { - var user = await userService.GetByCredentialsAsync(request.Email, request.Password); - // TODO: Fetch roles from DB when roles implemented - var roles = new List { "User" }; - var token = jwtTokenService.GenerateToken(user.Email, user.Email, roles); - var refreshToken = await userService.GenerateAndStoreRefreshTokenAsync(user); - return Ok(new LoginResponse - { - Token = token, - Email = user.Email, - Roles = roles, - RefreshToken = refreshToken - }); - } - catch (UnauthorizedAccessException) + + var tokenCreationResult = await authService.GenerateTokenAsync(request.Email, request.Password); + var refreshToken = await userService.GenerateAndStoreRefreshTokenAsync(tokenCreationResult.UserId); + return Ok(new LoginResponse { - return Unauthorized(new { message = "Invalid credentials" }); - } + Token = tokenCreationResult.JwtToken, + RefreshToken = refreshToken + }); } /// @@ -79,17 +68,14 @@ public async Task> Refresh([FromBody] RefreshRequest return Unauthorized(new { message = "Invalid refresh token" }); } - var roles = new List { "User" }; // Fetch actual roles as needed - var token = jwtTokenService.GenerateToken(user.Email, user.Email, roles); - var newRefreshToken = await userService.GenerateAndStoreRefreshTokenAsync(user); + var tokenCreationResult = await authService.RenewTokenAsync(request.RefreshToken); + var newRefreshToken = await userService.GenerateAndStoreRefreshTokenAsync(tokenCreationResult.UserId); // Generate new JWT return Ok(new LoginResponse { - Token = token, - Email = user.Email, - Roles = roles, + Token = tokenCreationResult.JwtToken, RefreshToken = newRefreshToken // Add this property to your response model }); } diff --git a/fullstack2026BE/WebApp/Models/AuthModels.cs b/fullstack2026BE/WebApp/Models/AuthModels.cs index 223ea54..02fc2b2 100644 --- a/fullstack2026BE/WebApp/Models/AuthModels.cs +++ b/fullstack2026BE/WebApp/Models/AuthModels.cs @@ -9,8 +9,6 @@ public class LoginRequest public class LoginResponse { public string Token { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; public string RefreshToken { get; set; } = string.Empty; - public List Roles { get; set; } = new(); }