Refactored endpoints to be more functional and more expressive

This commit is contained in:
2026-09-01 11:23:25 +02:00
parent 346cb8da5f
commit 2df5b5c2ed
86 changed files with 1565 additions and 1575 deletions
@@ -1,78 +1,50 @@
using PostFundManagement.Domain.Errors;
namespace PostFundManagement.Application.Services.Shared;
public sealed class BrowserLocalStorageService(ProtectedLocalStorage storage)
{
public async ValueTask<Result> DeleteAsync(string key)
{
try
public async ValueTask<Result> DeleteAsync(string key) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
await storage.DeleteAsync(key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}));
public async ValueTask<Result> SaveAsync(string key, string value)
{
try
public async ValueTask<Result> SaveAsync(string key, string value) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
await storage.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}));
public async ValueTask<Result> SaveAsync<TValue>(string key, TValue value) where TValue : class
{
try
public async ValueTask<Result> SaveAsync<TValue>(string key, TValue value) where TValue : class =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
await storage.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}));
public async ValueTask<Result<string>> GetAsync(string key)
{
try
public async ValueTask<Result<string>> GetAsync(string key) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{
var retrieval = await storage.GetAsync<string>(key);
return retrieval.Success && !string.IsNullOrWhiteSpace(retrieval.Value)
? Result.Ok(retrieval.Value)
: Result.Fail($"Could not find object by key {key}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
: Result.Fail(NotFoundError.Create($"Could not find object by key {key}"));
}));
public async ValueTask<Result<TValue>> GetAsync<TValue>(string key) where TValue : class
{
try
public async ValueTask<Result<TValue>> GetAsync<TValue>(string key) where TValue : class =>
await Result.Try((Func<ValueTask<Result<TValue>>>)(async () =>
{
var retrieval = await storage.GetAsync<TValue>(key);
return retrieval.Success && retrieval.Value is not null
? Result.Ok(retrieval.Value)
: Result.Fail($"Could not find object by key {key}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
: Result.Fail(NotFoundError.Create($"Could not find object by key {key}"));
}));
}
@@ -7,6 +7,6 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{ContractS3SettingsSection}:BucketName").Value ?? "";
protected override string CdnBaseUrl => configuration.GetSection($"{ContractS3SettingsSection}:CdnBaseUrl").Value ?? "";
protected override string BucketName => configuration.GetSection($"{ContractS3SettingsSection}:BucketName").Value ?? string.Empty;
protected override string CdnBaseUrl => configuration.GetSection($"{ContractS3SettingsSection}:CdnBaseUrl").Value ?? string.Empty;
}
@@ -7,6 +7,6 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{EvidenceS3SettingsSection}:BucketName").Value ?? "";
protected override string CdnBaseUrl => configuration.GetSection($"{EvidenceS3SettingsSection}:CdnBaseUrl").Value ?? "";
protected override string BucketName => configuration.GetSection($"{EvidenceS3SettingsSection}:BucketName").Value ?? string.Empty;
protected override string CdnBaseUrl => configuration.GetSection($"{EvidenceS3SettingsSection}:CdnBaseUrl").Value ?? string.Empty;
}
@@ -7,6 +7,6 @@ namespace PostFundManagement.Application.Services.Shared;
public sealed class GeneralS3Service(IConfiguration configuration, [FromKeyedServices(GeneralBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{GeneralS3SettingsSection}:BucketName").Value ?? "";
protected override string CdnBaseUrl => configuration.GetSection($"{GeneralS3SettingsSection}:CdnBaseUrl").Value ?? "";
protected override string BucketName => configuration.GetSection($"{GeneralS3SettingsSection}:BucketName").Value ?? string.Empty;
protected override string CdnBaseUrl => configuration.GetSection($"{GeneralS3SettingsSection}:CdnBaseUrl").Value ?? string.Empty;
}
@@ -1,6 +1,7 @@
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
namespace PostFundManagement.Application.Shared;
namespace PostFundManagement.Application.Services.Shared;
public sealed partial class HashService(IHashids hasher) : IService
{
@@ -31,59 +32,55 @@ public sealed partial class HashService(IHashids hasher) : IService
public static Result<string> ToMd5Hash(string input)
{
if (string.IsNullOrEmpty(input))
return Result.Fail<string>("Input content cannot be null or empty for MD5 processing.");
return Result.Fail<string>(ValidationError.Create("Input content cannot be null or empty for MD5 processing."));
byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
}
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input)
? Result.Fail<string>("Input must be a valid hexadecimal string.")
? Result.Fail<string>(ValidationError.Create("Input must be a valid hexadecimal string."))
: Result.Ok(hasher.EncodeHex(input));
public Result<string> HashEncodeIntId(int id) => id < 0
? Result.Fail<string>("Id cannot be negative.")
? Result.Fail<string>(ValidationError.Create("Id cannot be negative."))
: Result.Ok(hasher.Encode(id));
public Result<string> HashEncodeLongId(long id) => id < 0
? Result.Fail<string>("Id cannot be negative.")
? Result.Fail<string>(ValidationError.Create("Id cannot be negative."))
: Result.Ok(hasher.EncodeLong(id));
public Result<int> DecodeIntIdHash(string hash)
{
if (string.IsNullOrWhiteSpace(hash)) return Result.Fail<int>("Invalid token layout.");
if (string.IsNullOrWhiteSpace(hash))
return Result.Fail<int>(ValidationError.Create("Invalid token layout."));
int[] decoded = hasher.Decode(hash);
return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail<int>("Invalid or modified Int hash token.");
return decoded.Length == 1
? Result.Ok(decoded[0]) :
Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
}
public Result<long> DecodeLongIdHash(string hash)
{
if (string.IsNullOrWhiteSpace(hash)) return Result.Fail<long>("Invalid token layout.");
if (string.IsNullOrWhiteSpace(hash))
return Result.Fail<long>(ValidationError.Create("Invalid token layout."));
long[] decoded = hasher.DecodeLong(hash);
return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail<long>("Invalid or modified Long hash token.");
return decoded.Length == 1 ? Result.Ok(decoded[0]) :
Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
}
public Result<string> DecodeHexHash(string hex)
{
try
public async Task<Result<string>> DecodeHexHash(string hex) =>
await Result.Try((Func<ValueTask<Result<string>>>)(async () =>
{
string decoded = hasher.DecodeHex(hex);
return string.IsNullOrEmpty(decoded)
? Result.Fail<string>("Invalid or corrupted hex hash.")
? Result.Fail<string>(ValidationError.Create("Invalid or corrupted hex hash."))
: Result.Ok(decoded);
}
catch (FormatException fex)
{
return Result.Fail<string>(new Error("Invalid hash structure.").CausedBy(fex));
}
catch (Exception ex)
{
return Result.Fail<string>(new Error(ex.Message).CausedBy(ex));
}
}
}));
}
@@ -1,24 +1,20 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Services.Shared;
public sealed class IdentityService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
public async ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
return !await context.Users.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken)
? Result.Fail("Invalid user")
: Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
var userExistsAndActive = await context.Users
.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken);
return Result.FailIf(!userExistsAndActive, NotFoundError.Create());
}));
}
@@ -1,3 +1,4 @@
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Security.Configuration;
using PostFundManagement.Domain.Security.Models;
using PostFundManagement.Domain.Security.Sdk;
@@ -8,24 +9,21 @@ public sealed class TokenService(ISecurityConnectApi connectApi, IOptions<Securi
{
private readonly SecurityClientSettings clientSettings = clientOptions.Value;
public async Task<Result<Domain.Security.Models.TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default)
{
try
public async ValueTask<Result<Domain.Security.Models.TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<ValueTask<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
{
var request = new Domain.Security.Models.TokenRequest
using var response = await connectApi.GetToken(new Domain.Security.Models.TokenRequest
{
ClientId = clientSettings.ClientId,
ClientSecret = clientSettings.ClientSecret,
GrantType = clientSettings.GrantType,
Scope = clientSettings.Scope,
};
using var response = await connectApi.GetToken(request, cancellationToken);
}, cancellationToken);
var contentRaw = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(contentRaw))
return Result.Fail(new Error($"The authentication endpoint returned an empty payload. Status code: {response.StatusCode}"));
return Result.Fail(ValidationError.Create($"The authentication endpoint returned an empty payload. Status code: {response.StatusCode}"));
if (response.IsSuccessStatusCode)
{
@@ -33,34 +31,15 @@ public sealed class TokenService(ISecurityConnectApi connectApi, IOptions<Securi
return !string.IsNullOrWhiteSpace(tokenResponse?.AccessToken)
? Result.Ok(tokenResponse)
: Result.Fail<Domain.Security.Models.TokenResponse>(new Error("Authentication succeeded, but no access token was found in the response payload."));
: Result.Fail<Domain.Security.Models.TokenResponse>(NotFoundError.Create("Authentication succeeded, but no access token was found in the response payload."));
}
try
return await Result.Try((Func<ValueTask<Result<Domain.Security.Models.TokenResponse>>>)(async () =>
{
var errorResult = JsonSerializer.Deserialize<TokenErrorResponse>(contentRaw);
if (errorResult != null)
{
string summary = $"{errorResult.Error}: {errorResult.ErrorDescription}";
return Result.Fail(new Error(summary));
}
}
catch
{
return Result.Fail(new Error($"Authentication failed: {contentRaw}"));
}
return Result.Fail(new Error($"Authentication failed with status code: {response.StatusCode}"));
}
catch (OperationCanceledException ex)
{
return Result.Fail(new Error("The token generation request was canceled.").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
return Result.FailIf(errorResult != null,
CreateError.Create($"{errorResult!.Error}: {errorResult.ErrorDescription}"));
}));
}));
}