From 1c4352aa286b6d01937fa0caf30b781787fb2b21 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sun, 24 May 2026 19:54:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=BD=D0=B0=20MVC=20=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20swagger=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/UniverseController.cs | 53 ++++++++ .../UniverseGraphQlModels.cs} | 15 -- .../Models/UniverseUserLookupResult.cs | 43 ++++++ SfeduSchedule.Plugin.UniVerse/Plugin.cs | 128 ------------------ .../Services/IUniverseUserLookupService.cs | 15 ++ .../Services/UniverseUserLookupService.cs | 86 ++++++++++++ 6 files changed, 197 insertions(+), 143 deletions(-) create mode 100644 SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs rename SfeduSchedule.Plugin.UniVerse/{GraphQLModels.cs => Models/UniverseGraphQlModels.cs} (68%) create mode 100644 SfeduSchedule.Plugin.UniVerse/Models/UniverseUserLookupResult.cs create mode 100644 SfeduSchedule.Plugin.UniVerse/Services/IUniverseUserLookupService.cs create mode 100644 SfeduSchedule.Plugin.UniVerse/Services/UniverseUserLookupService.cs diff --git a/SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs b/SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs new file mode 100644 index 0000000..7fb3b60 --- /dev/null +++ b/SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace SfeduSchedule.Plugin.UniVerse.Controllers; + +[ApiController] +[Route("plugins/universe")] +public sealed class UniverseController(IUniverseUserLookupService lookupService) : ControllerBase +{ + /// + /// Получить Sub ID пользователя по полному имени. + /// + /// Полное имя пользователя для поиска в Modeus. + /// Токен отмены запроса. + /// UniVerse Sub ID пользователя в формате plain text. + /// Пользователь найден, возвращается его UniVerse Sub ID. + /// Не передан обязательный query-параметр fullname. + /// Не передан или некорректен API-ключ. + /// API-ключ не имеет доступа к методу. + /// Пользователь не найден. + /// Вышестоящий сервис вернул ошибку или некорректный ответ. + /// Внутренняя ошибка обработки запроса. + [HttpGet("subid", Name = "UniVerseGetSubId")] + [Authorize(AuthenticationSchemes = "ApiKey")] + [Produces("text/plain")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task GetSubId( + [FromQuery] string? fullname, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(fullname)) + return BadRequest("Query parameter 'fullname' is required."); + + var result = await lookupService.FindSubIdAsync(fullname, cancellationToken); + + return result.Status switch + { + UniverseUserLookupStatus.Found => Content(result.SubId!, "text/plain"), + UniverseUserLookupStatus.NotFound => NotFound(), + UniverseUserLookupStatus.UpstreamError => Problem( + result.ErrorMessage, + statusCode: StatusCodes.Status502BadGateway), + _ => Problem(statusCode: StatusCodes.Status500InternalServerError) + }; + } +} diff --git a/SfeduSchedule.Plugin.UniVerse/GraphQLModels.cs b/SfeduSchedule.Plugin.UniVerse/Models/UniverseGraphQlModels.cs similarity index 68% rename from SfeduSchedule.Plugin.UniVerse/GraphQLModels.cs rename to SfeduSchedule.Plugin.UniVerse/Models/UniverseGraphQlModels.cs index 08ec510..9967acb 100644 --- a/SfeduSchedule.Plugin.UniVerse/GraphQLModels.cs +++ b/SfeduSchedule.Plugin.UniVerse/Models/UniverseGraphQlModels.cs @@ -3,21 +3,6 @@ using System.Text.Json.Serialization; namespace SfeduSchedule.Plugin.UniVerse; -public sealed record UniverseUserLookupResult( - UniverseUserLookupStatus Status, - string? SubId = null, - string? ErrorMessage = null) -{ - public static UniverseUserLookupResult Found(string subId) => - new(UniverseUserLookupStatus.Found, subId); - - public static UniverseUserLookupResult NotFound() => - new(UniverseUserLookupStatus.NotFound); - - public static UniverseUserLookupResult UpstreamError(string message) => - new(UniverseUserLookupStatus.UpstreamError, ErrorMessage: message); -} - internal sealed record UniverseUsersGraphQlRequest( [property: JsonPropertyName("query")] string Query, [property: JsonPropertyName("variables")] UniverseUsersGraphQlVariables Variables); diff --git a/SfeduSchedule.Plugin.UniVerse/Models/UniverseUserLookupResult.cs b/SfeduSchedule.Plugin.UniVerse/Models/UniverseUserLookupResult.cs new file mode 100644 index 0000000..d149e9f --- /dev/null +++ b/SfeduSchedule.Plugin.UniVerse/Models/UniverseUserLookupResult.cs @@ -0,0 +1,43 @@ +namespace SfeduSchedule.Plugin.UniVerse; + +/// +/// Результат поиска пользователя. +/// +/// Статус выполнения поиска. +/// Найденный Sub ID пользователя. +/// Описание ошибки вышестоящего сервиса. +public sealed record UniverseUserLookupResult( + UniverseUserLookupStatus Status, + string? SubId = null, + string? ErrorMessage = null) +{ + /// + /// Успешный результат поиска. + /// + /// Найденный Sub ID пользователя. + /// Результат со статусом Found. + public static UniverseUserLookupResult Found(string subId) => + new(UniverseUserLookupStatus.Found, subId); + + /// + /// Результат для случая, когда пользователь не найден. + /// + /// Результат со статусом NotFound. + public static UniverseUserLookupResult NotFound() => + new(UniverseUserLookupStatus.NotFound); + + /// + /// Результат ошибки вышестоящего сервиса. + /// + /// Описание ошибки. + /// Результат со статусом UpstreamError. + public static UniverseUserLookupResult UpstreamError(string message) => + new(UniverseUserLookupStatus.UpstreamError, ErrorMessage: message); +} + +public enum UniverseUserLookupStatus +{ + Found, + NotFound, + UpstreamError +} diff --git a/SfeduSchedule.Plugin.UniVerse/Plugin.cs b/SfeduSchedule.Plugin.UniVerse/Plugin.cs index a7254c7..150491f 100644 --- a/SfeduSchedule.Plugin.UniVerse/Plugin.cs +++ b/SfeduSchedule.Plugin.UniVerse/Plugin.cs @@ -1,20 +1,11 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using ModeusSchedule.Abstractions; namespace SfeduSchedule.Plugin.UniVerse; public sealed class UniVersePlugin : IPlugin { - private const string ApiKeyScheme = "ApiKey"; - public string Name => "UniVerse"; public void ConfigureServices(IServiceCollection services) @@ -24,124 +15,5 @@ public sealed class UniVersePlugin : IPlugin public void MapEndpoints(IEndpointRouteBuilder endpoints) { - endpoints.MapGet("/plugins/universe/subid", - async (string? fullname, IUniverseUserLookupService lookupService, CancellationToken cancellationToken) => - { - if (string.IsNullOrWhiteSpace(fullname)) - return Results.BadRequest("Query parameter 'fullname' is required."); - - var result = await lookupService.FindSubIdAsync(fullname, cancellationToken); - - return result.Status switch - { - UniverseUserLookupStatus.Found => Results.Text(result.SubId, "text/plain"), - UniverseUserLookupStatus.NotFound => Results.NotFound(), - UniverseUserLookupStatus.UpstreamError => Results.Problem( - result.ErrorMessage, - statusCode: StatusCodes.Status502BadGateway), - _ => Results.Problem(statusCode: StatusCodes.Status500InternalServerError) - }; - }) - .RequireAuthorization(policy => policy - .AddAuthenticationSchemes(ApiKeyScheme) - .RequireAuthenticatedUser()) - .WithName("UniVerseGetSubId") - .Produces(StatusCodes.Status200OK, "text/plain") - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status502BadGateway); } } - -public interface IUniverseUserLookupService -{ - Task FindSubIdAsync(string fullName, CancellationToken cancellationToken = default); -} - -public sealed class UniverseUserLookupService( - HttpClient httpClient, - IConfiguration configuration, - ILogger logger) : IUniverseUserLookupService -{ - private const string AccessControlApiPath = "access-control/api"; - private const string UsersQuery = """ - query($t:String!){ users(filter:{text:$t}, pager:{activePage:1,pageSize:10}) { items { id name displayName description } } } - """; - - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public async Task FindSubIdAsync( - string fullName, - CancellationToken cancellationToken = default) - { - var token = configuration["TOKEN"]; - if (string.IsNullOrWhiteSpace(token)) - return UniverseUserLookupResult.UpstreamError("Modeus TOKEN is not configured."); - - var modeusUrl = configuration["MODEUS_URL"]; - if (string.IsNullOrWhiteSpace(modeusUrl)) - return UniverseUserLookupResult.UpstreamError("MODEUS_URL is not configured."); - - httpClient.BaseAddress = new Uri(modeusUrl, UriKind.Absolute); - - using var request = new HttpRequestMessage(HttpMethod.Post, AccessControlApiPath); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - request.Content = JsonContent.Create( - new UniverseUsersGraphQlRequest( - UsersQuery, - new UniverseUsersGraphQlVariables(fullName)), - options: JsonOptions); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - logger.LogWarning( - "Modeus access-control/api returned {StatusCode} while searching user by fullName.", - response.StatusCode); - return UniverseUserLookupResult.UpstreamError( - $"Modeus access-control/api returned {(int)response.StatusCode}."); - } - - UniverseUsersGraphQlResponse? payload; - try - { - await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); - payload = await JsonSerializer.DeserializeAsync( - contentStream, - JsonOptions, - cancellationToken); - } - catch (JsonException exception) - { - logger.LogWarning(exception, "Unable to deserialize Modeus access-control/api response."); - return UniverseUserLookupResult.UpstreamError("Modeus access-control/api returned invalid JSON."); - } - - if (payload?.Errors is { Count: > 0 }) - { - logger.LogWarning( - "Modeus access-control/api returned GraphQL errors: {Errors}", - JsonSerializer.Serialize(payload.Errors, JsonOptions)); - return UniverseUserLookupResult.UpstreamError("Modeus access-control/api returned GraphQL errors."); - } - - var users = payload?.Data?.Users?.Items; - if (users is null || users.Count == 0) - return UniverseUserLookupResult.NotFound(); - - var subId = users[0].Name; - return string.IsNullOrWhiteSpace(subId) - ? UniverseUserLookupResult.NotFound() - : UniverseUserLookupResult.Found(subId); - } -} - -public enum UniverseUserLookupStatus -{ - Found, - NotFound, - UpstreamError -} diff --git a/SfeduSchedule.Plugin.UniVerse/Services/IUniverseUserLookupService.cs b/SfeduSchedule.Plugin.UniVerse/Services/IUniverseUserLookupService.cs new file mode 100644 index 0000000..d0c27be --- /dev/null +++ b/SfeduSchedule.Plugin.UniVerse/Services/IUniverseUserLookupService.cs @@ -0,0 +1,15 @@ +namespace SfeduSchedule.Plugin.UniVerse; + +/// +/// Сервис поиска Sub ID через API Modeus. +/// +public interface IUniverseUserLookupService +{ + /// + /// Найти Sub ID пользователя по полному имени. + /// + /// Полное имя пользователя. + /// Токен отмены запроса. + /// Результат поиска пользователя и его Sub ID, если он найден. + Task FindSubIdAsync(string fullName, CancellationToken cancellationToken = default); +} diff --git a/SfeduSchedule.Plugin.UniVerse/Services/UniverseUserLookupService.cs b/SfeduSchedule.Plugin.UniVerse/Services/UniverseUserLookupService.cs new file mode 100644 index 0000000..f42406f --- /dev/null +++ b/SfeduSchedule.Plugin.UniVerse/Services/UniverseUserLookupService.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace SfeduSchedule.Plugin.UniVerse; + +public sealed class UniverseUserLookupService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) : IUniverseUserLookupService +{ + private const string AccessControlApiPath = "access-control/api"; + private const string UsersQuery = """ + query($t:String!){ users(filter:{text:$t}, pager:{activePage:1,pageSize:10}) { items { id name displayName description } } } + """; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task FindSubIdAsync( + string fullName, + CancellationToken cancellationToken = default) + { + var token = configuration["TOKEN"]; + if (string.IsNullOrWhiteSpace(token)) + return UniverseUserLookupResult.UpstreamError("Modeus TOKEN is not configured."); + + var modeusUrl = configuration["MODEUS_URL"]; + if (string.IsNullOrWhiteSpace(modeusUrl)) + return UniverseUserLookupResult.UpstreamError("MODEUS_URL is not configured."); + + httpClient.BaseAddress = new Uri(modeusUrl, UriKind.Absolute); + + using var request = new HttpRequestMessage(HttpMethod.Post, AccessControlApiPath); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create( + new UniverseUsersGraphQlRequest( + UsersQuery, + new UniverseUsersGraphQlVariables(fullName)), + options: JsonOptions); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Modeus access-control/api returned {StatusCode} while searching user by fullName.", + response.StatusCode); + return UniverseUserLookupResult.UpstreamError( + $"Modeus access-control/api returned {(int)response.StatusCode}."); + } + + UniverseUsersGraphQlResponse? payload; + try + { + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + payload = await JsonSerializer.DeserializeAsync( + contentStream, + JsonOptions, + cancellationToken); + } + catch (JsonException exception) + { + logger.LogWarning(exception, "Unable to deserialize Modeus access-control/api response."); + return UniverseUserLookupResult.UpstreamError("Modeus access-control/api returned invalid JSON."); + } + + if (payload?.Errors is { Count: > 0 }) + { + logger.LogWarning( + "Modeus access-control/api returned GraphQL errors: {Errors}", + JsonSerializer.Serialize(payload.Errors, JsonOptions)); + return UniverseUserLookupResult.UpstreamError("Modeus access-control/api returned GraphQL errors."); + } + + var users = payload?.Data?.Users?.Items; + if (users is null || users.Count == 0) + return UniverseUserLookupResult.NotFound(); + + var subId = users[0].Name; + return string.IsNullOrWhiteSpace(subId) + ? UniverseUserLookupResult.NotFound() + : UniverseUserLookupResult.Found(subId); + } +}