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);
+}