From 67f5bd4b0963df862cc9852bc3a29aa63b818090 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 30 Jul 2026 23:16:19 -0500 Subject: [PATCH 1/6] Move the user contacts domain to Immediate.Handlers Introduces Immediate.Handlers alongside MediatR and converts the first domain to it, replacing UserContactController with minimal API endpoints generated by Immediate.Apis. MediatR stays wired up for every domain that has not moved yet. Immediate has no publish/notification concept and no pipeline behaviors are in use here, so the conversion is mechanical: each request/handler pair becomes a [Handler] container class with a nested Command or Query record. Crypter.API needs its own Immediate.Handlers reference even though Crypter.Core already has one. Source generators do not flow across a project reference, and Immediate.Apis excludes the analyzer from its own dependency, so without it the API assembly gets no generated handlers. Minimal API handlers have no equivalent of ControllerBase.User, so authenticated endpoints resolve the caller through IHttpContextAccessor. Co-Authored-By: Claude Opus 5 --- Crypter.API/AssemblyInfo.cs | 29 +++++ .../Controllers/UserContactController.cs | 120 ------------------ Crypter.API/Crypter.API.csproj | 2 + .../UserContacts/AddUserContactEndpoint.cs | 92 ++++++++++++++ .../UserContacts/GetUserContactsEndpoint.cs | 64 ++++++++++ .../UserContacts/RemoveUserContactEndpoint.cs | 69 ++++++++++ Crypter.API/Methods/EndpointResults.cs | 41 ++++++ Crypter.API/Program.cs | 4 + Crypter.Core/AssemblyInfo.cs | 4 + Crypter.Core/Crypter.Core.csproj | 1 + Crypter.Core/DependencyInjection.cs | 1 + .../Commands/AddUserContactCommand.cs | 29 ++--- .../Commands/RemoveUserContactCommand.cs | 24 ++-- .../Queries/GetUserContactsQuery.cs | 23 ++-- 14 files changed, 340 insertions(+), 163 deletions(-) create mode 100644 Crypter.API/AssemblyInfo.cs delete mode 100644 Crypter.API/Controllers/UserContactController.cs create mode 100644 Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs create mode 100644 Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs create mode 100644 Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs create mode 100644 Crypter.API/Methods/EndpointResults.cs diff --git a/Crypter.API/AssemblyInfo.cs b/Crypter.API/AssemblyInfo.cs new file mode 100644 index 000000000..c633c25d6 --- /dev/null +++ b/Crypter.API/AssemblyInfo.cs @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using Immediate.Handlers.Shared; + +[assembly: ImmediateAssemblyIdentifier("CrypterApi")] diff --git a/Crypter.API/Controllers/UserContactController.cs b/Crypter.API/Controllers/UserContactController.cs deleted file mode 100644 index 269edad56..000000000 --- a/Crypter.API/Controllers/UserContactController.cs +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2023 Crypter File Transfer - * - * This file is part of the Crypter file transfer project. - * - * Crypter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Crypter source code is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * You can be released from the requirements of the aforementioned license - * by purchasing a commercial license. Buying such a license is mandatory - * as soon as you develop commercial activities involving the Crypter source - * code without disclosing the source code of your own applications. - * - * Contact the current copyright holder to discuss commercial license options. - */ - -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Crypter.API.Controllers.Base; -using Crypter.Common.Contracts; -using Crypter.Common.Contracts.Features.Contacts; -using Crypter.Core.Features.UserContacts.Commands; -using Crypter.Core.Features.UserContacts.Queries; -using EasyMonads; -using MediatR; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Crypter.API.Controllers; - -[ApiController] -[Route("api/user/contact")] -public class UserContactController : CrypterControllerBase -{ - private readonly ISender _sender; - - public UserContactController(ISender sender) - { - _sender = sender; - } - - /// - /// Get a list of user contacts. - /// - /// - /// - [HttpGet] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))] - public async Task GetUserContactsAsync(CancellationToken cancellationToken) - { - GetUserContactsQuery request = new GetUserContactsQuery(UserId); - List result = await _sender.Send(request, cancellationToken); - return Ok(result); - } - - /// - /// Add a user as a contact. - /// - /// - /// - [HttpPost] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserContact))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(ErrorResponse))] - public async Task AddUserContactAsync([FromQuery] string username) - { - IActionResult MakeErrorResponse(AddUserContactError error) - { -#pragma warning disable CS8524 - return error switch - { - AddUserContactError.UnknownError => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error), - AddUserContactError.NotFound => MakeErrorResponseBase(HttpStatusCode.NotFound, error), - AddUserContactError.InvalidUser => MakeErrorResponseBase(HttpStatusCode.BadRequest, error) - }; -#pragma warning restore CS8524 - } - - AddUserContactCommand request = new AddUserContactCommand(UserId, username); - return await _sender.Send(request) - .MatchAsync( - MakeErrorResponse, - Ok, - MakeErrorResponse(AddUserContactError.UnknownError)); - } - - /// - /// Remove a user from contacts. - /// - /// - /// - [HttpDelete] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(void))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))] - public async Task RemoveUserContactAsync([FromQuery] string username) - { - RemoveUserContactCommand request = new RemoveUserContactCommand(UserId, username); - await _sender.Send(request); - return Ok(); - } -} diff --git a/Crypter.API/Crypter.API.csproj b/Crypter.API/Crypter.API.csproj index 5b38112ab..525a54c53 100644 --- a/Crypter.API/Crypter.API.csproj +++ b/Crypter.API/Crypter.API.csproj @@ -25,6 +25,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs new file mode 100644 index 000000000..48cb3b82d --- /dev/null +++ b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Crypter.API.Methods; +using Crypter.Common.Contracts; +using Crypter.Common.Contracts.Features.Contacts; +using Crypter.Core.Features.UserContacts.Commands; +using Crypter.Core.Services; +using EasyMonads; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Crypter.API.Endpoints.UserContacts; + +[Handler] +[MapPost("api/user/contact")] +[Authorize] +public static partial class AddUserContactEndpoint +{ + public sealed record Request + { + [FromQuery] + public required string Username { get; init; } + } + + internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => + endpoint + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status500InternalServerError); + + private static async ValueTask HandleAsync( + [AsParameters] Request request, + IHttpContextAccessor httpContextAccessor, + AddUserContactCommand.Handler handler, + CancellationToken cancellationToken) + { + Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + AddUserContactCommand.Command command = new AddUserContactCommand.Command(userId, request.Username); + + Either result = await handler.HandleAsync(command, cancellationToken); + return result.Match( + MakeErrorResponse, + x => Results.Ok(x), + MakeErrorResponse(AddUserContactError.UnknownError)); + } + + private static IResult MakeErrorResponse(AddUserContactError error) + { +#pragma warning disable CS8524 + return error switch + { + AddUserContactError.UnknownError => EndpointResults.MakeErrorResponse(HttpStatusCode.InternalServerError, error), + AddUserContactError.NotFound => EndpointResults.MakeErrorResponse(HttpStatusCode.NotFound, error), + AddUserContactError.InvalidUser => EndpointResults.MakeErrorResponse(HttpStatusCode.BadRequest, error) + }; +#pragma warning restore CS8524 + } +} diff --git a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs new file mode 100644 index 000000000..21c0cdd5a --- /dev/null +++ b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Crypter.Common.Contracts.Features.Contacts; +using Crypter.Core.Features.UserContacts.Queries; +using Crypter.Core.Services; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Crypter.API.Endpoints.UserContacts; + +[Handler] +[MapGet("api/user/contact")] +[Authorize] +public static partial class GetUserContactsEndpoint +{ + public sealed record Request; + + internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => + endpoint + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + + private static async ValueTask HandleAsync( + Request request, + IHttpContextAccessor httpContextAccessor, + GetUserContactsQuery.Handler handler, + CancellationToken cancellationToken) + { + Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + List result = await handler.HandleAsync(new GetUserContactsQuery.Query(userId), cancellationToken); + return Results.Ok(result); + } +} diff --git a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs new file mode 100644 index 000000000..daacc6f06 --- /dev/null +++ b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Crypter.Core.Features.UserContacts.Commands; +using Crypter.Core.Services; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Crypter.API.Endpoints.UserContacts; + +[Handler] +[MapDelete("api/user/contact")] +[Authorize] +public static partial class RemoveUserContactEndpoint +{ + public sealed record Request + { + [FromQuery] + public required string Username { get; init; } + } + + internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => + endpoint + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + + private static async ValueTask HandleAsync( + [AsParameters] Request request, + IHttpContextAccessor httpContextAccessor, + RemoveUserContactCommand.Handler handler, + CancellationToken cancellationToken) + { + Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + RemoveUserContactCommand.Command command = new RemoveUserContactCommand.Command(userId, request.Username); + + await handler.HandleAsync(command, cancellationToken); + return Results.Ok(); + } +} diff --git a/Crypter.API/Methods/EndpointResults.cs b/Crypter.API/Methods/EndpointResults.cs new file mode 100644 index 000000000..aa526ebd8 --- /dev/null +++ b/Crypter.API/Methods/EndpointResults.cs @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Net; +using Crypter.Common.Contracts; +using Microsoft.AspNetCore.Http; + +namespace Crypter.API.Methods; + +internal static class EndpointResults +{ + internal static IResult MakeErrorResponse(HttpStatusCode httpStatus, Enum errorCode) + { + ErrorResponse errorResponse = new ErrorResponse((int)httpStatus, errorCode); + return Results.Json(errorResponse, statusCode: (int)httpStatus); + } +} diff --git a/Crypter.API/Program.cs b/Crypter.API/Program.cs index 36967f517..d39e6220a 100644 --- a/Crypter.API/Program.cs +++ b/Crypter.API/Program.cs @@ -27,6 +27,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using Crypter.API; using Crypter.API.Configuration; using Crypter.API.MetadataProviders; using Crypter.API.Middleware; @@ -86,6 +87,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(); builder.Services.AddCors(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddCrypterApiHandlers(); builder.Services.AddControllers() .ConfigureApiBehaviorOptions(options => { @@ -151,6 +154,7 @@ app.UseAuthorization(); app.UseMiddleware(); app.MapControllers(); +app.MapCrypterApiEndpoints(); await app.MigrateDatabaseAsync(); app.ScheduleRecurringReports(); diff --git a/Crypter.Core/AssemblyInfo.cs b/Crypter.Core/AssemblyInfo.cs index aaf13f726..f983a8a77 100644 --- a/Crypter.Core/AssemblyInfo.cs +++ b/Crypter.Core/AssemblyInfo.cs @@ -24,6 +24,10 @@ * Contact the current copyright holder to discuss commercial license options. */ +using Immediate.Handlers.Shared; + +[assembly: ImmediateAssemblyIdentifier("CrypterCore")] + namespace Crypter.Core; public class AssemblyInfo diff --git a/Crypter.Core/Crypter.Core.csproj b/Crypter.Core/Crypter.Core.csproj index e0fb8063e..5c3717d42 100644 --- a/Crypter.Core/Crypter.Core.csproj +++ b/Crypter.Core/Crypter.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/Crypter.Core/DependencyInjection.cs b/Crypter.Core/DependencyInjection.cs index 240658b28..90ec39a40 100644 --- a/Crypter.Core/DependencyInjection.cs +++ b/Crypter.Core/DependencyInjection.cs @@ -60,6 +60,7 @@ public static IServiceCollection AddCrypterCore(this IServiceCollection services services.AddDataAccess(defaultConnectionString); services.AddMediatR(cfg => cfg .RegisterServicesFromAssemblyContaining(typeof(AssemblyInfo))); + services.AddCrypterCoreHandlers(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs index 50044e370..cd1c25311 100644 --- a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs +++ b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs @@ -30,32 +30,27 @@ using System.Threading.Tasks; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Core.LinqExpressions; -using Crypter.Core.MediatorMonads; using Crypter.DataAccess; using Crypter.DataAccess.Entities; using EasyMonads; +using Immediate.Handlers.Shared; using Microsoft.EntityFrameworkCore; namespace Crypter.Core.Features.UserContacts.Commands; -public record AddUserContactCommand(Guid UserId, string ContactUsername) - : IEitherRequest; - -internal class AddUserContactCommandHandler - : IEitherRequestHandler +[Handler] +public static partial class AddUserContactCommand { - private readonly DataContext _dataContext; - - public AddUserContactCommandHandler(DataContext dataContext) - { - _dataContext = dataContext; - } + public sealed record Command(Guid UserId, string ContactUsername); - public async Task> Handle(AddUserContactCommand request, CancellationToken cancellationToken) + private static async ValueTask> HandleAsync( + Command request, + DataContext dataContext, + CancellationToken cancellationToken) { string lowerContactUsername = request.ContactUsername.ToLower(); - var foundUser = await _dataContext.Users + var foundUser = await dataContext.Users .Where(x => x.Username.ToLower() == lowerContactUsername) .Where(LinqUserExpressions.UserPrivacyAllowsVisitor(request.UserId)) .Select(x => new { x.Id, x.Username, x.Profile!.Alias }) @@ -71,7 +66,7 @@ public async Task> Handle(AddUserContac return AddUserContactError.InvalidUser; } - bool contactExists = await _dataContext.UserContacts + bool contactExists = await dataContext.UserContacts .Where(x => x.OwnerId == request.UserId) .Where(x => x.ContactId == foundUser.Id) .AnyAsync(CancellationToken.None); @@ -79,8 +74,8 @@ public async Task> Handle(AddUserContac if (!contactExists) { UserContactEntity newContactEntity = new UserContactEntity(request.UserId, foundUser.Id); - _dataContext.UserContacts.Add(newContactEntity); - await _dataContext.SaveChangesAsync(CancellationToken.None); + dataContext.UserContacts.Add(newContactEntity); + await dataContext.SaveChangesAsync(CancellationToken.None); } return new UserContact(foundUser.Username, foundUser.Alias); diff --git a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs index 46a1418d9..217f175b6 100644 --- a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs +++ b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs @@ -31,33 +31,31 @@ using Crypter.DataAccess; using Crypter.DataAccess.Entities; using EasyMonads; +using Immediate.Handlers.Shared; using Microsoft.EntityFrameworkCore; namespace Crypter.Core.Features.UserContacts.Commands; -public sealed record RemoveUserContactCommand(Guid UserId, string ContactUsername) : MediatR.IRequest; - -internal class RemoveUserContactCommandHandler : MediatR.IRequestHandler +[Handler] +public static partial class RemoveUserContactCommand { - private readonly DataContext _dataContext; - - public RemoveUserContactCommandHandler(DataContext dataContext) - { - _dataContext = dataContext; - } + public sealed record Command(Guid UserId, string ContactUsername); - public async Task Handle(RemoveUserContactCommand request, CancellationToken cancellationToken) + private static async ValueTask HandleAsync( + Command request, + DataContext dataContext, + CancellationToken cancellationToken) { string lowerContactUsername = request.ContactUsername.ToLower(); - UserContactEntity? contactEntity = await _dataContext.UserContacts + UserContactEntity? contactEntity = await dataContext.UserContacts .Where(x => x.OwnerId == request.UserId && x.Contact!.Username.ToLower() == lowerContactUsername) .FirstOrDefaultAsync(CancellationToken.None); if (contactEntity is not null) { - _dataContext.UserContacts.Remove(contactEntity); - await _dataContext.SaveChangesAsync(CancellationToken.None); + dataContext.UserContacts.Remove(contactEntity); + await dataContext.SaveChangesAsync(CancellationToken.None); } return Unit.Default; diff --git a/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs b/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs index 5b24bdb5a..c9cbff196 100644 --- a/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs +++ b/Crypter.Core/Features/UserContacts/Queries/GetUserContactsQuery.cs @@ -34,31 +34,28 @@ using Crypter.Common.Enums; using Crypter.DataAccess; using Crypter.DataAccess.Entities; -using MediatR; +using Immediate.Handlers.Shared; using Microsoft.EntityFrameworkCore; namespace Crypter.Core.Features.UserContacts.Queries; -public sealed record GetUserContactsQuery(Guid UserId) : IRequest>; - -internal class GetUserContactsQueryHandler : IRequestHandler> +[Handler] +public static partial class GetUserContactsQuery { - private readonly DataContext _dataContext; + public sealed record Query(Guid UserId); - public GetUserContactsQueryHandler(DataContext dataContext) - { - _dataContext = dataContext; - } - - public Task> Handle(GetUserContactsQuery request, CancellationToken cancellationToken) + private static async ValueTask> HandleAsync( + Query request, + DataContext dataContext, + CancellationToken cancellationToken) { - return _dataContext.UserContacts + return await dataContext.UserContacts .Where(x => x.OwnerId == request.UserId) .Select(x => x.Contact) .Select(ToUserContactDto(request.UserId)) .ToListAsync(cancellationToken); } - + private static Expression> ToUserContactDto(Guid? visitorId) { return x => x != null From 4cd579304a0ff78e039e6b6ba5060b9ceb840ac0 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 11:33:57 -0500 Subject: [PATCH 2/6] Validate the contact username inside the user contact handlers Minimal API binding rejects a missing query parameter before the handler runs, returning a bare 400 with no body, where the MVC controller used to produce an ErrorResponse via InvalidModelStateResponseFactory. Rather than reinstate that at the endpoint, the handlers now validate the username themselves with Username.TryFrom, so a non-HTTP caller gets the same checking. RemoveUserContactCommand had no error channel and returned Unit, so it gains Either. That changes the client signature of RemoveUserContactAsync from Maybe, and adds DeleteEitherUnitResponseAsync to the HTTP clients, which had no Either returning delete. Co-Authored-By: Claude Opus 5 --- .../UserContacts/AddUserContactEndpoint.cs | 2 +- .../UserContacts/RemoveUserContactEndpoint.cs | 29 +++++++++++++--- .../CrypterAuthenticatedHttpClient.cs | 7 ++++ .../HttpClients/CrypterHttpClient.cs | 6 ++++ .../Requests/UserContactRequests.cs | 5 +-- .../HttpClients/ICrypterHttpClient.cs | 2 ++ .../Requests/IUserContactRequests.cs | 2 +- .../Services/UserContactsService.cs | 5 +-- .../Contacts/RemoveUserContactError.cs | 33 +++++++++++++++++++ .../Commands/AddUserContactCommand.cs | 10 ++++-- .../Commands/RemoveUserContactCommand.cs | 13 ++++++-- .../RemoveUserContact_Tests.cs | 4 +-- 12 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs diff --git a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs index 48cb3b82d..fbb29a9b8 100644 --- a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs @@ -51,7 +51,7 @@ public static partial class AddUserContactEndpoint public sealed record Request { [FromQuery] - public required string Username { get; init; } + public string? Username { get; init; } } internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => diff --git a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs index daacc6f06..3a201d65e 100644 --- a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs @@ -25,10 +25,15 @@ */ using System; +using System.Net; using System.Threading; using System.Threading.Tasks; +using Crypter.API.Methods; +using Crypter.Common.Contracts; +using Crypter.Common.Contracts.Features.Contacts; using Crypter.Core.Features.UserContacts.Commands; using Crypter.Core.Services; +using EasyMonads; using Immediate.Apis.Shared; using Immediate.Handlers.Shared; using Microsoft.AspNetCore.Authorization; @@ -46,13 +51,15 @@ public static partial class RemoveUserContactEndpoint public sealed record Request { [FromQuery] - public required string Username { get; init; } + public string? Username { get; init; } } internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => endpoint .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized); + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); private static async ValueTask HandleAsync( [AsParameters] Request request, @@ -63,7 +70,21 @@ private static async ValueTask HandleAsync( Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); RemoveUserContactCommand.Command command = new RemoveUserContactCommand.Command(userId, request.Username); - await handler.HandleAsync(command, cancellationToken); - return Results.Ok(); + Either result = await handler.HandleAsync(command, cancellationToken); + return result.Match( + MakeErrorResponse, + _ => Results.Ok(), + MakeErrorResponse(RemoveUserContactError.UnknownError)); + } + + private static IResult MakeErrorResponse(RemoveUserContactError error) + { +#pragma warning disable CS8524 + return error switch + { + RemoveUserContactError.UnknownError => EndpointResults.MakeErrorResponse(HttpStatusCode.InternalServerError, error), + RemoveUserContactError.InvalidUser => EndpointResults.MakeErrorResponse(HttpStatusCode.BadRequest, error) + }; +#pragma warning restore CS8524 } } diff --git a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs index 630ca893a..3fec87251 100644 --- a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs @@ -215,6 +215,13 @@ public async Task> DeleteUnitResponseAsync(string uri) : Maybe.None; } + public async Task> DeleteEitherUnitResponseAsync(string uri) + { + Func requestFactory = MakeRequestMessageFactory(HttpMethod.Delete, uri); + using HttpResponseMessage response = await SendWithAuthenticationAsync(requestFactory, false); + return await DeserializeEitherUnitResponseAsync(response); + } + public async Task> SendAsync(Func requestFactory) where TResponse : class { diff --git a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs index 0882d3f41..57d3eccf6 100644 --- a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs @@ -125,6 +125,12 @@ public async Task> DeleteUnitResponseAsync(string uri) : Maybe.None; } + public async Task> DeleteEitherUnitResponseAsync(string uri) + { + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, uri); + return await SendRequestEitherUnitResponseAsync(request); + } + public async Task> SendAsync(Func requestFactory) where TResponse : class { diff --git a/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs index bc387b301..6dcf977d5 100644 --- a/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs +++ b/Crypter.Common.Client/HttpClients/Requests/UserContactRequests.cs @@ -55,9 +55,10 @@ public Task> AddUserContactAsync(string .ExtractErrorCode(); } - public Task> RemoveUserContactAsync(string contactUsername) + public Task> RemoveUserContactAsync(string contactUsername) { string url = $"api/user/contact?username={contactUsername}"; - return _crypterAuthenticatedHttpClient.DeleteUnitResponseAsync(url); + return _crypterAuthenticatedHttpClient.DeleteEitherUnitResponseAsync(url) + .ExtractErrorCode(); } } diff --git a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs index 83958076d..2fcf35cee 100644 --- a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs +++ b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs @@ -60,6 +60,8 @@ Task> PostEitherUnitResponseAsync(string u Task> DeleteUnitResponseAsync(string uri); + Task> DeleteEitherUnitResponseAsync(string uri); + Task> SendAsync(Func requestFactory) where TResponse : class; diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs index fee8cc5ae..b6c62d300 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserContactRequests.cs @@ -35,5 +35,5 @@ public interface IUserContactRequests { Task>> GetUserContactsAsync(); Task> AddUserContactAsync(string contactUsername); - Task> RemoveUserContactAsync(string contactUsername); + Task> RemoveUserContactAsync(string contactUsername); } diff --git a/Crypter.Common.Client/Services/UserContactsService.cs b/Crypter.Common.Client/Services/UserContactsService.cs index 3f87de5ed..61a1d82fc 100644 --- a/Crypter.Common.Client/Services/UserContactsService.cs +++ b/Crypter.Common.Client/Services/UserContactsService.cs @@ -83,8 +83,9 @@ public async Task RemoveContactAsync(string contactUsername) { await LoadContactsAsync(); string lowerContactUsername = contactUsername.ToLower(); - Maybe response = await _crypterApiClient.UserContact.RemoveUserContactAsync(lowerContactUsername); - response.IfSome(_ => _contacts!.Remove(lowerContactUsername)); + Either response = + await _crypterApiClient.UserContact.RemoveUserContactAsync(lowerContactUsername); + response.DoRight(_ => _contacts!.Remove(lowerContactUsername)); } private async Task> FetchContactsAsync() diff --git a/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs b/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs new file mode 100644 index 000000000..2bca35937 --- /dev/null +++ b/Crypter.Common/Contracts/Features/Contacts/RemoveUserContactError.cs @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +namespace Crypter.Common.Contracts.Features.Contacts; + +public enum RemoveUserContactError +{ + UnknownError, + InvalidUser +} diff --git a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs index cd1c25311..2f6cc900a 100644 --- a/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs +++ b/Crypter.Core/Features/UserContacts/Commands/AddUserContactCommand.cs @@ -29,6 +29,7 @@ using System.Threading; using System.Threading.Tasks; using Crypter.Common.Contracts.Features.Contacts; +using Crypter.Common.Primitives; using Crypter.Core.LinqExpressions; using Crypter.DataAccess; using Crypter.DataAccess.Entities; @@ -41,14 +42,19 @@ namespace Crypter.Core.Features.UserContacts.Commands; [Handler] public static partial class AddUserContactCommand { - public sealed record Command(Guid UserId, string ContactUsername); + public sealed record Command(Guid UserId, string? ContactUsername); private static async ValueTask> HandleAsync( Command request, DataContext dataContext, CancellationToken cancellationToken) { - string lowerContactUsername = request.ContactUsername.ToLower(); + if (!Username.TryFrom(request.ContactUsername!, out Username? validContactUsername)) + { + return AddUserContactError.InvalidUser; + } + + string lowerContactUsername = validContactUsername.Value.ToLower(); var foundUser = await dataContext.Users .Where(x => x.Username.ToLower() == lowerContactUsername) diff --git a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs index 217f175b6..bd1570a0d 100644 --- a/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs +++ b/Crypter.Core/Features/UserContacts/Commands/RemoveUserContactCommand.cs @@ -28,6 +28,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Crypter.Common.Contracts.Features.Contacts; +using Crypter.Common.Primitives; using Crypter.DataAccess; using Crypter.DataAccess.Entities; using EasyMonads; @@ -39,14 +41,19 @@ namespace Crypter.Core.Features.UserContacts.Commands; [Handler] public static partial class RemoveUserContactCommand { - public sealed record Command(Guid UserId, string ContactUsername); + public sealed record Command(Guid UserId, string? ContactUsername); - private static async ValueTask HandleAsync( + private static async ValueTask> HandleAsync( Command request, DataContext dataContext, CancellationToken cancellationToken) { - string lowerContactUsername = request.ContactUsername.ToLower(); + if (!Username.TryFrom(request.ContactUsername!, out Username? validContactUsername)) + { + return RemoveUserContactError.InvalidUser; + } + + string lowerContactUsername = validContactUsername.Value.ToLower(); UserContactEntity? contactEntity = await dataContext.UserContacts .Where(x => x.OwnerId == request.UserId && x.Contact!.Username.ToLower() == lowerContactUsername) diff --git a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs index e08372ad4..b7a663fed 100644 --- a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs @@ -90,7 +90,7 @@ await userLoginResult.DoRightAsync(async loginResponse => Either addContactResult = await _client!.UserContact.AddUserContactAsync(contactUsername); Maybe> secondContactsResult = await _client!.UserContact.GetUserContactsAsync(); - Maybe removeContactResult = await _client!.UserContact.RemoveUserContactAsync(contactUsername); + Either removeContactResult = await _client!.UserContact.RemoveUserContactAsync(contactUsername); Maybe> finalContactsResult = await _client!.UserContact.GetUserContactsAsync(); Assert.That(userRegistrationResult.IsRight, Is.True); @@ -107,7 +107,7 @@ await userLoginResult.DoRightAsync(async loginResponse => Assert.That(x[0].Username, Is.EqualTo(contactUsername)); }); - Assert.That(removeContactResult.IsSome, Is.True); + Assert.That(removeContactResult.IsRight, Is.True); Assert.That(finalContactsResult.IsSome, Is.True); finalContactsResult.IfSome(x => Assert.That(x, Is.Empty)); } From ac1363068c87993e321789412f8f609530f76591 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 11:35:15 -0500 Subject: [PATCH 3/6] Extract the endpoint user id lookup into EndpointUser Minimal API handlers have no ControllerBase.User, so every authenticated endpoint repeats the same walk from IHttpContextAccessor to the claims principal. Roughly forty endpoints are still to be migrated, so this puts the walk in one place before the pattern spreads. Co-Authored-By: Claude Opus 5 --- .../UserContacts/AddUserContactEndpoint.cs | 3 +- .../UserContacts/GetUserContactsEndpoint.cs | 4 +- .../UserContacts/RemoveUserContactEndpoint.cs | 3 +- Crypter.API/Methods/EndpointUser.cs | 39 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 Crypter.API/Methods/EndpointUser.cs diff --git a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs index fbb29a9b8..1455f549d 100644 --- a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs @@ -32,7 +32,6 @@ using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Core.Features.UserContacts.Commands; -using Crypter.Core.Services; using EasyMonads; using Immediate.Apis.Shared; using Immediate.Handlers.Shared; @@ -68,7 +67,7 @@ private static async ValueTask HandleAsync( AddUserContactCommand.Handler handler, CancellationToken cancellationToken) { - Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + Guid userId = EndpointUser.ParseUserId(httpContextAccessor); AddUserContactCommand.Command command = new AddUserContactCommand.Command(userId, request.Username); Either result = await handler.HandleAsync(command, cancellationToken); diff --git a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs index 21c0cdd5a..c7e0b4d1d 100644 --- a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs @@ -28,9 +28,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Crypter.API.Methods; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Core.Features.UserContacts.Queries; -using Crypter.Core.Services; using Immediate.Apis.Shared; using Immediate.Handlers.Shared; using Microsoft.AspNetCore.Authorization; @@ -57,7 +57,7 @@ private static async ValueTask HandleAsync( GetUserContactsQuery.Handler handler, CancellationToken cancellationToken) { - Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + Guid userId = EndpointUser.ParseUserId(httpContextAccessor); List result = await handler.HandleAsync(new GetUserContactsQuery.Query(userId), cancellationToken); return Results.Ok(result); } diff --git a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs index 3a201d65e..e5e4d992c 100644 --- a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs @@ -32,7 +32,6 @@ using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Core.Features.UserContacts.Commands; -using Crypter.Core.Services; using EasyMonads; using Immediate.Apis.Shared; using Immediate.Handlers.Shared; @@ -67,7 +66,7 @@ private static async ValueTask HandleAsync( RemoveUserContactCommand.Handler handler, CancellationToken cancellationToken) { - Guid userId = TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + Guid userId = EndpointUser.ParseUserId(httpContextAccessor); RemoveUserContactCommand.Command command = new RemoveUserContactCommand.Command(userId, request.Username); Either result = await handler.HandleAsync(command, cancellationToken); diff --git a/Crypter.API/Methods/EndpointUser.cs b/Crypter.API/Methods/EndpointUser.cs new file mode 100644 index 000000000..33435899b --- /dev/null +++ b/Crypter.API/Methods/EndpointUser.cs @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using Crypter.Core.Services; +using Microsoft.AspNetCore.Http; + +namespace Crypter.API.Methods; + +internal static class EndpointUser +{ + internal static Guid ParseUserId(IHttpContextAccessor httpContextAccessor) + { + return TokenService.ParseUserId(httpContextAccessor.HttpContext!.User); + } +} From c82e6fa8eaaddeeffaf736d428426b595445c8bf Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 11:36:00 -0500 Subject: [PATCH 4/6] Describe the user contact endpoints in the OpenAPI document The controller carried these descriptions as XML doc comments, which never reached Swagger because the project does not generate a documentation file. WithSummary puts them in the generated document instead. Co-Authored-By: Claude Opus 5 --- Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs | 1 + Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs | 1 + Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs index 1455f549d..23baac08f 100644 --- a/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/AddUserContactEndpoint.cs @@ -55,6 +55,7 @@ public sealed record Request internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => endpoint + .WithSummary("Add a user as a contact.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status400BadRequest) diff --git a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs index c7e0b4d1d..734763913 100644 --- a/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/GetUserContactsEndpoint.cs @@ -48,6 +48,7 @@ public sealed record Request; internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => endpoint + .WithSummary("Get a list of user contacts.") .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized); diff --git a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs index e5e4d992c..6b47a074f 100644 --- a/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs +++ b/Crypter.API/Endpoints/UserContacts/RemoveUserContactEndpoint.cs @@ -55,6 +55,7 @@ public sealed record Request internal static void CustomizeEndpoint(RouteHandlerBuilder endpoint) => endpoint + .WithSummary("Remove a user from contacts.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status400BadRequest) From ccd5d6007b8d316be3251d2c6b3a20d3f6532030 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 11:39:06 -0500 Subject: [PATCH 5/6] Assert the specific error codes in the user contact tests The existing tests only checked that a failure was a Left, so a scrambled error-to-status mapping would still have passed. They now assert the error value, and cover the usernames the handlers reject. The absent-parameter case needs a raw HttpClient, since the typed client interpolates the username into the query string and so can only ever send an empty value, never omit it. Co-Authored-By: Claude Opus 5 --- Crypter.Test/Integration_Tests/TestMethods.cs | 19 ++++++++ .../UserContact_Tests/AddUserContact_Tests.cs | 42 +++++++++++++++++ .../RemoveUserContact_Tests.cs | 46 +++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/Crypter.Test/Integration_Tests/TestMethods.cs b/Crypter.Test/Integration_Tests/TestMethods.cs index 9859b77c1..b657cd468 100644 --- a/Crypter.Test/Integration_Tests/TestMethods.cs +++ b/Crypter.Test/Integration_Tests/TestMethods.cs @@ -24,14 +24,18 @@ * Contact the current copyright holder to discuss commercial license options. */ +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Repositories; +using Crypter.Common.Client.Models; using Crypter.Common.Contracts.Features.Transfer; using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Enums; using Crypter.Crypto.Providers.Default; using EasyMonads; +using Microsoft.AspNetCore.Mvc.Testing; using NUnit.Framework; namespace Crypter.Test.Integration_Tests; @@ -53,6 +57,21 @@ await loginResult.DoRightAsync(async loginResponse => }); } + /// + /// Creates an HttpClient carrying the stored authentication token, for tests that need to + /// send a request the typed API client cannot express. + /// + internal static async Task CreateAuthenticatedHttpClientAsync( + WebApplicationFactory factory, ITokenRepository tokenRepository) + { + HttpClient httpClient = factory.CreateClient(); + Maybe authenticationToken = await tokenRepository.GetAuthenticationTokenAsync(); + authenticationToken.IfSome(x => + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", x.Token)); + + return httpClient; + } + internal static async Task InitiateMultipartFileTransferAsync(ICrypterApiClient apiClient, ITokenRepository tokenRepository) { await LoginAsync(apiClient, tokenRepository); diff --git a/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs b/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs index 7b969be79..c0f238535 100644 --- a/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserContact_Tests/AddUserContact_Tests.cs @@ -24,9 +24,13 @@ * Contact the current copyright holder to discuss commercial license options. */ +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Repositories; +using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Enums; @@ -113,6 +117,9 @@ await userLoginResult.DoRightAsync(async loginResponse => Assert.That(userRegistrationResult.IsRight, Is.True); Assert.That(userLoginResult.IsRight, Is.True); Assert.That(result.IsLeft, Is.True); + result.DoLeftOrNeither( + left: error => Assert.That(error, Is.EqualTo(AddUserContactError.InvalidUser)), + neither: Assert.Fail); } [TestCase] @@ -136,5 +143,40 @@ await userLoginResult.DoRightAsync(async loginResponse => Assert.That(userRegistrationResult.IsRight, Is.True); Assert.That(userLoginResult.IsRight, Is.True); Assert.That(result.IsLeft, Is.True); + result.DoLeftOrNeither( + left: error => Assert.That(error, Is.EqualTo(AddUserContactError.NotFound)), + neither: Assert.Fail); + } + + [Test] + public async Task Add_User_Contact_Fails_For_Absent_Username_Parameter_Async() + { + await TestMethods.LoginAsync(_client!, _clientTokenRepository!); + using HttpClient httpClient = + await TestMethods.CreateAuthenticatedHttpClientAsync(_factory!, _clientTokenRepository!); + + using HttpResponseMessage response = await httpClient.PostAsync("api/user/contact", null); + ErrorResponse? errorResponse = await response.Content.ReadFromJsonAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(errorResponse, Is.Not.Null); + Assert.That(errorResponse!.Errors, Has.Count.EqualTo(1)); + Assert.That(errorResponse.Errors[0].ErrorCode, Is.EqualTo((int)AddUserContactError.InvalidUser)); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("no spaces allowed")] + [TestCase("bad*characters")] + public async Task Add_User_Contact_Fails_For_Invalid_Username_Async(string contactUsername) + { + await TestMethods.LoginAsync(_client!, _clientTokenRepository!); + + Either result = await _client!.UserContact.AddUserContactAsync(contactUsername); + + Assert.That(result.IsLeft, Is.True); + result.DoLeftOrNeither( + left: error => Assert.That(error, Is.EqualTo(AddUserContactError.InvalidUser)), + neither: Assert.Fail); } } diff --git a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs index b7a663fed..675afebd2 100644 --- a/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserContact_Tests/RemoveUserContact_Tests.cs @@ -25,9 +25,13 @@ */ using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Repositories; +using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.Contacts; using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Enums; @@ -111,4 +115,46 @@ await userLoginResult.DoRightAsync(async loginResponse => Assert.That(finalContactsResult.IsSome, Is.True); finalContactsResult.IfSome(x => Assert.That(x, Is.Empty)); } + + [Test] + public async Task Remove_User_Contact_Fails_For_Absent_Username_Parameter_Async() + { + await TestMethods.LoginAsync(_client!, _clientTokenRepository!); + using HttpClient httpClient = + await TestMethods.CreateAuthenticatedHttpClientAsync(_factory!, _clientTokenRepository!); + + using HttpResponseMessage response = await httpClient.DeleteAsync("api/user/contact"); + ErrorResponse? errorResponse = await response.Content.ReadFromJsonAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(errorResponse, Is.Not.Null); + Assert.That(errorResponse!.Errors, Has.Count.EqualTo(1)); + Assert.That(errorResponse.Errors[0].ErrorCode, Is.EqualTo((int)RemoveUserContactError.InvalidUser)); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("no spaces allowed")] + [TestCase("bad*characters")] + public async Task Remove_User_Contact_Fails_For_Invalid_Username_Async(string contactUsername) + { + await TestMethods.LoginAsync(_client!, _clientTokenRepository!); + + Either result = await _client!.UserContact.RemoveUserContactAsync(contactUsername); + + Assert.That(result.IsLeft, Is.True); + result.DoLeftOrNeither( + left: error => Assert.That(error, Is.EqualTo(RemoveUserContactError.InvalidUser)), + neither: Assert.Fail); + } + + [Test] + public async Task Remove_User_Contact_Succeeds_For_Absent_Contact_Async() + { + await TestMethods.LoginAsync(_client!, _clientTokenRepository!); + + Either result = await _client!.UserContact.RemoveUserContactAsync("Tom_Bombadil"); + + Assert.That(result.IsRight, Is.True); + } } From 9b62375783483595869baf1b1f5a336e89f039a4 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Fri, 31 Jul 2026 15:22:24 -0500 Subject: [PATCH 6/6] Remove the unused DeleteUnitResponseAsync HTTP client method Moving user contacts to DeleteEitherUnitResponseAsync left this method with no callers anywhere in the solution. The unauthenticated implementation also built its request with HttpMethod.Post, so it would not have deleted anything had it been called. Co-Authored-By: Claude Opus 5 --- .../HttpClients/CrypterAuthenticatedHttpClient.cs | 9 --------- Crypter.Common.Client/HttpClients/CrypterHttpClient.cs | 9 --------- .../Interfaces/HttpClients/ICrypterHttpClient.cs | 2 -- 3 files changed, 20 deletions(-) diff --git a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs index 3fec87251..46bd6169a 100644 --- a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs @@ -206,15 +206,6 @@ public async Task> PostEitherUnitResponseAsync> DeleteUnitResponseAsync(string uri) - { - Func requestFactory = MakeRequestMessageFactory(HttpMethod.Delete, uri); - using HttpResponseMessage response = await SendWithAuthenticationAsync(requestFactory, false); - return response.IsSuccessStatusCode - ? Unit.Default - : Maybe.None; - } - public async Task> DeleteEitherUnitResponseAsync(string uri) { Func requestFactory = MakeRequestMessageFactory(HttpMethod.Delete, uri); diff --git a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs index 57d3eccf6..ba67e525d 100644 --- a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs @@ -116,15 +116,6 @@ public async Task> PostEitherUnitResponseAsync> DeleteUnitResponseAsync(string uri) - { - using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri); - using HttpResponseMessage response = await _httpClient.SendAsync(request); - return response.IsSuccessStatusCode - ? Unit.Default - : Maybe.None; - } - public async Task> DeleteEitherUnitResponseAsync(string uri) { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, uri); diff --git a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs index 2fcf35cee..ab227503b 100644 --- a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs +++ b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs @@ -58,8 +58,6 @@ Task> PostMaybeUnitResponseAsync(string uri, TRequest body Task> PostEitherUnitResponseAsync(string uri, TRequest body) where TRequest : class; - Task> DeleteUnitResponseAsync(string uri); - Task> DeleteEitherUnitResponseAsync(string uri); Task> SendAsync(Func requestFactory)