From 51a047602e0c9f0be678838dd8e1c3b4176f61e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Mon, 1 Jun 2026 00:28:15 +0300 Subject: [PATCH] ApiServer --- src/ApiClient/ApiClient.csproj | 5 ++ src/ApiServer/ApiServer.csproj | 7 +- src/ApiServer/ApiServer.http | 39 ++++++++- src/ApiServer/Auth/BasicAuthOptions.cs | 10 +++ .../Auth/BasicAuthenticationDefaults.cs | 6 ++ .../Auth/BasicAuthenticationHandler.cs | 60 ++++++++++++++ src/ApiServer/Controllers/ApiController.cs | 37 +++++++++ src/ApiServer/DependencyInjection.cs | 30 +++++++ src/ApiServer/Grpc/SmsTestGrpcService.cs | 45 ++++++++++ src/ApiServer/Mapping/MenuMapper.cs | 29 +++++++ src/ApiServer/Program.cs | 43 +++------- src/ApiServer/Services/IMenuStore.cs | 10 +++ src/ApiServer/Services/ISmsApiService.cs | 9 ++ src/ApiServer/Services/InMemoryMenuStore.cs | 82 +++++++++++++++++++ src/ApiServer/Services/SmsApiService.cs | 71 ++++++++++++++++ src/ApiServer/appsettings.json | 6 +- src/Contracts/Contracts.csproj | 14 ++++ src/Contracts/Protos/sms.proto | 43 ++++++++++ 18 files changed, 507 insertions(+), 39 deletions(-) create mode 100644 src/ApiServer/Auth/BasicAuthOptions.cs create mode 100644 src/ApiServer/Auth/BasicAuthenticationDefaults.cs create mode 100644 src/ApiServer/Auth/BasicAuthenticationHandler.cs create mode 100644 src/ApiServer/Controllers/ApiController.cs create mode 100644 src/ApiServer/DependencyInjection.cs create mode 100644 src/ApiServer/Grpc/SmsTestGrpcService.cs create mode 100644 src/ApiServer/Mapping/MenuMapper.cs create mode 100644 src/ApiServer/Services/IMenuStore.cs create mode 100644 src/ApiServer/Services/ISmsApiService.cs create mode 100644 src/ApiServer/Services/InMemoryMenuStore.cs create mode 100644 src/ApiServer/Services/SmsApiService.cs create mode 100644 src/Contracts/Protos/sms.proto diff --git a/src/ApiClient/ApiClient.csproj b/src/ApiClient/ApiClient.csproj index b760144..aa5b0c7 100644 --- a/src/ApiClient/ApiClient.csproj +++ b/src/ApiClient/ApiClient.csproj @@ -6,4 +6,9 @@ enable + + + + + diff --git a/src/ApiServer/ApiServer.csproj b/src/ApiServer/ApiServer.csproj index 2e73281..b617332 100644 --- a/src/ApiServer/ApiServer.csproj +++ b/src/ApiServer/ApiServer.csproj @@ -7,7 +7,12 @@ - + + + + + + diff --git a/src/ApiServer/ApiServer.http b/src/ApiServer/ApiServer.http index 2588b27..61f33ad 100644 --- a/src/ApiServer/ApiServer.http +++ b/src/ApiServer/ApiServer.http @@ -1,6 +1,37 @@ -@ApiServer_HostAddress = http://localhost:5053 +@baseUrl = http://localhost:5053 +@user = user +@password = password -GET {{ApiServer_HostAddress}}/weatherforecast/ -Accept: application/json +### GetMenu +POST {{baseUrl}}/ +Authorization: Basic {{user}}:{{password}} +Content-Type: application/json -### +{ + "Command": "GetMenu", + "CommandParameters": { + "WithPrice": true + } +} + +### SendOrder +POST {{baseUrl}}/ +Authorization: Basic {{user}}:{{password}} +Content-Type: application/json + +{ + "Command": "SendOrder", + "CommandParameters": { + "OrderId": "62137983-1117-4D10-87C1-EF40A4348250", + "MenuItems": [ + { + "Id": "5979224", + "Quantity": "1" + }, + { + "Id": "9084246", + "Quantity": "0.408" + } + ] + } +} diff --git a/src/ApiServer/Auth/BasicAuthOptions.cs b/src/ApiServer/Auth/BasicAuthOptions.cs new file mode 100644 index 0000000..bb89e5d --- /dev/null +++ b/src/ApiServer/Auth/BasicAuthOptions.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authentication; + +namespace ApiServer.Auth; + +public sealed class BasicAuthOptions : AuthenticationSchemeOptions +{ + public string Username { get; set; } = "user"; + + public string Password { get; set; } = "password"; +} diff --git a/src/ApiServer/Auth/BasicAuthenticationDefaults.cs b/src/ApiServer/Auth/BasicAuthenticationDefaults.cs new file mode 100644 index 0000000..b61b822 --- /dev/null +++ b/src/ApiServer/Auth/BasicAuthenticationDefaults.cs @@ -0,0 +1,6 @@ +namespace ApiServer.Auth; + +public static class BasicAuthenticationDefaults +{ + public const string AuthenticationScheme = "Basic"; +} diff --git a/src/ApiServer/Auth/BasicAuthenticationHandler.cs b/src/ApiServer/Auth/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..ccb482e --- /dev/null +++ b/src/ApiServer/Auth/BasicAuthenticationHandler.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace ApiServer.Auth; + +internal sealed class BasicAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization.ToString(), out var header) + || !string.Equals(header.Scheme, "Basic", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(header.Parameter)) + { + return Task.FromResult(AuthenticateResult.Fail("Требуется Basic-аутентификация.")); + } + + string decoded; + try + { + decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header.Parameter)); + } + catch (FormatException) + { + return Task.FromResult(AuthenticateResult.Fail("Некорректный заголовок Authorization.")); + } + + var separatorIndex = decoded.IndexOf(':'); + if (separatorIndex < 0) + { + return Task.FromResult(AuthenticateResult.Fail("Некорректный заголовок Authorization.")); + } + + var username = decoded[..separatorIndex]; + var password = decoded[(separatorIndex + 1)..]; + + if (username != Options.Username || password != Options.Password) + { + return Task.FromResult(AuthenticateResult.Fail("Неверные учётные данные.")); + } + + var claims = new[] { new Claim(ClaimTypes.Name, username) }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.Headers.WWWAuthenticate = "Basic realm=\"ApiServer\""; + return base.HandleChallengeAsync(properties); + } +} diff --git a/src/ApiServer/Controllers/ApiController.cs b/src/ApiServer/Controllers/ApiController.cs new file mode 100644 index 0000000..b718e6c --- /dev/null +++ b/src/ApiServer/Controllers/ApiController.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using ApiServer.Auth; +using ApiServer.Services; +using Contracts.Requests; +using Contracts.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ApiServer.Controllers; + +[ApiController] +[Route("/")] +[Authorize(AuthenticationSchemes = BasicAuthenticationDefaults.AuthenticationScheme)] +public sealed class ApiController(ISmsApiService apiService) : ControllerBase +{ + [HttpPost] + public async Task> Post(CancellationToken cancellationToken) + { + ApiRequest request; + try + { + using var document = await JsonDocument.ParseAsync(Request.Body, cancellationToken: cancellationToken); + request = ApiRequestDeserializer.Deserialize(document.RootElement); + } + catch (JsonException ex) + { + return Ok(new ApiResponse + { + Command = "", + Success = false, + ErrorMessage = $"Некорректный JSON: {ex.Message}", + }); + } + + return Ok(apiService.Handle(request)); + } +} diff --git a/src/ApiServer/DependencyInjection.cs b/src/ApiServer/DependencyInjection.cs new file mode 100644 index 0000000..0c64012 --- /dev/null +++ b/src/ApiServer/DependencyInjection.cs @@ -0,0 +1,30 @@ +using ApiServer.Auth; +using ApiServer.Services; +using Contracts.Json; +using Microsoft.AspNetCore.Authentication; + +namespace ApiServer; + +public static class DependencyInjection +{ + public static IServiceCollection AddApiServer(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme) + .AddScheme( + BasicAuthenticationDefaults.AuthenticationScheme, + options => configuration.GetSection("BasicAuth").Bind(options)); + + services.AddAuthorization(); + + services.AddSingleton(); + services.AddSingleton(); + + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy; + options.SerializerOptions.Converters.Add(new DecimalFromStringJsonConverter()); + }); + + return services; + } +} diff --git a/src/ApiServer/Grpc/SmsTestGrpcService.cs b/src/ApiServer/Grpc/SmsTestGrpcService.cs new file mode 100644 index 0000000..d300e82 --- /dev/null +++ b/src/ApiServer/Grpc/SmsTestGrpcService.cs @@ -0,0 +1,45 @@ +using ApiServer.Mapping; +using ApiServer.Services; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Sms.Test; + +namespace ApiServer.Grpc; + +internal sealed class SmsTestGrpcService(IMenuStore store) : SmsTestService.SmsTestServiceBase +{ + public override Task GetMenu(BoolValue request, ServerCallContext context) + { + var withPrice = request?.Value ?? true; + var response = new GetMenuResponse + { + Success = true, + ErrorMessage = "", + }; + + response.MenuItems.AddRange(store.GetMenu().Select(dish => MenuMapper.ToGrpc(dish, withPrice))); + return Task.FromResult(response); + } + + public override Task SendOrder(Sms.Test.Order request, ServerCallContext context) + { + var items = request.OrderItems + .Select(item => (item.Id, (decimal)item.Quantity)) + .ToList(); + + if (!store.TrySendOrder(request.Id, items, out var errorMessage)) + { + return Task.FromResult(new SendOrderResponse + { + Success = false, + ErrorMessage = errorMessage ?? "Не удалось отправить заказ.", + }); + } + + return Task.FromResult(new SendOrderResponse + { + Success = true, + ErrorMessage = "", + }); + } +} diff --git a/src/ApiServer/Mapping/MenuMapper.cs b/src/ApiServer/Mapping/MenuMapper.cs new file mode 100644 index 0000000..6f67837 --- /dev/null +++ b/src/ApiServer/Mapping/MenuMapper.cs @@ -0,0 +1,29 @@ +using Domain.Entities; +using Sms.Test; + +namespace ApiServer.Mapping; + +internal static class MenuMapper +{ + public static Dish ApplyPriceVisibility(Dish dish, bool withPrice) => new() + { + Id = dish.Id, + Article = dish.Article, + Name = dish.Name, + Price = withPrice ? dish.Price : 0, + IsWeighted = dish.IsWeighted, + FullPath = dish.FullPath, + Barcodes = dish.Barcodes, + }; + + public static MenuItem ToGrpc(Dish dish, bool withPrice) => new() + { + Id = dish.Id, + Article = dish.Article, + Name = dish.Name, + Price = withPrice ? (double)dish.Price : 0, + IsWeighted = dish.IsWeighted, + FullPath = dish.FullPath, + Barcodes = { dish.Barcodes }, + }; +} diff --git a/src/ApiServer/Program.cs b/src/ApiServer/Program.cs index ee9d65d..765c129 100644 --- a/src/ApiServer/Program.cs +++ b/src/ApiServer/Program.cs @@ -1,41 +1,18 @@ +using ApiServer; +using ApiServer.Grpc; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +builder.Services.AddApiServer(builder.Configuration); +builder.Services.AddControllers(); +builder.Services.AddGrpc(); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} +app.UseAuthentication(); +app.UseAuthorization(); -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); +app.MapControllers(); +app.MapGrpcService(); app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/src/ApiServer/Services/IMenuStore.cs b/src/ApiServer/Services/IMenuStore.cs new file mode 100644 index 0000000..06df772 --- /dev/null +++ b/src/ApiServer/Services/IMenuStore.cs @@ -0,0 +1,10 @@ +using Domain.Entities; + +namespace ApiServer.Services; + +public interface IMenuStore +{ + IReadOnlyList GetMenu(); + + bool TrySendOrder(string orderId, IReadOnlyList<(string Id, decimal Quantity)> items, out string? errorMessage); +} diff --git a/src/ApiServer/Services/ISmsApiService.cs b/src/ApiServer/Services/ISmsApiService.cs new file mode 100644 index 0000000..74f17e3 --- /dev/null +++ b/src/ApiServer/Services/ISmsApiService.cs @@ -0,0 +1,9 @@ +using Contracts.Requests; +using Contracts.Responses; + +namespace ApiServer.Services; + +public interface ISmsApiService +{ + ApiResponse Handle(ApiRequest request); +} diff --git a/src/ApiServer/Services/InMemoryMenuStore.cs b/src/ApiServer/Services/InMemoryMenuStore.cs new file mode 100644 index 0000000..512f88f --- /dev/null +++ b/src/ApiServer/Services/InMemoryMenuStore.cs @@ -0,0 +1,82 @@ +using Domain.Entities; + +namespace ApiServer.Services; + +internal sealed class InMemoryMenuStore : IMenuStore +{ + private readonly List _menu = + [ + new() + { + Id = "5979224", + Article = "A1004292", + Name = "Каша гречневая", + Price = 50, + IsWeighted = false, + FullPath = @"ПРОИЗВОДСТВО\Гарниры", + Barcodes = ["57890975627974236429"], + }, + new() + { + Id = "9084246", + Article = "A1004293", + Name = "Конфеты Коровка", + Price = 300, + IsWeighted = true, + FullPath = @"ДЕСЕРТЫ\Развес", + Barcodes = [], + }, + ]; + + private readonly HashSet _orderIds = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList GetMenu() => _menu; + + public bool TrySendOrder(string orderId, IReadOnlyList<(string Id, decimal Quantity)> items, out string? errorMessage) + { + if (string.IsNullOrWhiteSpace(orderId)) + { + errorMessage = "OrderId не указан."; + return false; + } + + if (items.Count == 0) + { + errorMessage = "Заказ не содержит позиций."; + return false; + } + + if (!_orderIds.Add(orderId)) + { + errorMessage = $"Заказ с идентификатором '{orderId}' уже был отправлен."; + return false; + } + + foreach (var (id, quantity) in items) + { + if (string.IsNullOrWhiteSpace(id)) + { + _orderIds.Remove(orderId); + errorMessage = "Идентификатор блюда не указан."; + return false; + } + + if (quantity <= 0) + { + _orderIds.Remove(orderId); + errorMessage = "Количество должно быть больше нуля."; + return false; + } + + if (_menu.All(d => d.Id != id)) + { + _orderIds.Remove(orderId); + errorMessage = $"Блюдо с идентификатором '{id}' не найдено."; + return false; + } + } + + errorMessage = null; + return true; + } +} diff --git a/src/ApiServer/Services/SmsApiService.cs b/src/ApiServer/Services/SmsApiService.cs new file mode 100644 index 0000000..11d3ee9 --- /dev/null +++ b/src/ApiServer/Services/SmsApiService.cs @@ -0,0 +1,71 @@ +using ApiServer.Mapping; +using Contracts; +using Contracts.Menu; +using Contracts.Requests; +using Contracts.Responses; + +namespace ApiServer.Services; + +internal sealed class SmsApiService(IMenuStore store) : ISmsApiService +{ + public ApiResponse Handle(ApiRequest request) => request switch + { + GetMenuApiRequest getMenu => GetMenu(getMenu), + SendOrderApiRequest sendOrder => SendOrder(sendOrder), + _ when string.IsNullOrWhiteSpace(request.Command) => Fail(request.Command, "Command не указан."), + _ => Fail(request.Command, $"Неизвестная команда: {request.Command}."), + }; + + private GetMenuApiResponse GetMenu(GetMenuApiRequest request) + { + var withPrice = request.CommandParameters?.WithPrice ?? true; + var menuItems = store.GetMenu() + .Select(dish => MenuMapper.ApplyPriceVisibility(dish, withPrice)) + .ToList(); + + return new GetMenuApiResponse + { + Command = Commands.GetMenu, + Success = true, + Data = new GetMenuData { MenuItems = menuItems }, + }; + } + + private SendOrderApiResponse SendOrder(SendOrderApiRequest request) + { + var parameters = request.CommandParameters; + if (parameters is null) + { + return FailSendOrder("CommandParameters не указаны или имеют неверный формат."); + } + + var items = parameters.MenuItems + .Select(item => (item.Id, item.Quantity)) + .ToList(); + + if (!store.TrySendOrder(parameters.OrderId, items, out var errorMessage)) + { + return FailSendOrder(errorMessage ?? "Не удалось отправить заказ."); + } + + return new SendOrderApiResponse + { + Command = Commands.SendOrder, + Success = true, + }; + } + + private static SendOrderApiResponse FailSendOrder(string errorMessage) => new() + { + Command = Commands.SendOrder, + Success = false, + ErrorMessage = errorMessage, + }; + + private static ApiResponse Fail(string command, string errorMessage) => new() + { + Command = command, + Success = false, + ErrorMessage = errorMessage, + }; +} diff --git a/src/ApiServer/appsettings.json b/src/ApiServer/appsettings.json index 10f68b8..3e85792 100644 --- a/src/ApiServer/appsettings.json +++ b/src/ApiServer/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "BasicAuth": { + "Username": "user", + "Password": "password" + } } diff --git a/src/Contracts/Contracts.csproj b/src/Contracts/Contracts.csproj index fa68a1d..9c99c44 100644 --- a/src/Contracts/Contracts.csproj +++ b/src/Contracts/Contracts.csproj @@ -6,8 +6,22 @@ enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Contracts/Protos/sms.proto b/src/Contracts/Protos/sms.proto new file mode 100644 index 0000000..29cc6ef --- /dev/null +++ b/src/Contracts/Protos/sms.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +import "google/protobuf/wrappers.proto"; + +package sms.test; + +option csharp_namespace = "Sms.Test"; + +message MenuItem { + string id = 1; + string article = 2; + string name = 3; + double price = 4; + bool is_weighted = 5; + string full_path = 6; + repeated string barcodes = 7; +} + +message OrderItem { + string id = 1; + double quantity = 2; +} + +message Order { + string id = 1; + repeated OrderItem order_items = 2; +} + +message GetMenuResponse { + bool success = 1; + string error_message = 2; + repeated MenuItem menu_items = 3; +} + +message SendOrderResponse { + bool success = 1; + string error_message = 2; +} + +service SmsTestService { + rpc GetMenu(google.protobuf.BoolValue) returns (GetMenuResponse); + rpc SendOrder(Order) returns (SendOrderResponse); +}