diff --git a/SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs b/SfeduSchedule.Plugin.UniVerse/Controllers/UniverseController.cs
new file mode 100644
index 0000000..1a1d406
--- /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("api/universe")]
+public sealed class UniverseController(IUniverseUserLookupService lookupService) : ControllerBase
+{
+ ///
+ /// Получить Sub ID пользователя по полному имени.
+ ///
+ /// Полное имя пользователя для поиска в Modeus.
+ /// Токен отмены запроса.
+ /// Sub ID пользователя в формате plain text.
+ /// Пользователь найден, возвращается его 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);
+ }
+}