ApiServer

This commit is contained in:
2026-06-01 00:28:15 +03:00
parent 01b935e112
commit 51a047602e
18 changed files with 507 additions and 39 deletions

View File

@@ -6,4 +6,9 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Contracts\Contracts.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,7 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Grpc.AspNetCore" Version="2.67.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Contracts\Contracts.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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"
}
]
}
}

View File

@@ -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";
}

View File

@@ -0,0 +1,6 @@
namespace ApiServer.Auth;
public static class BasicAuthenticationDefaults
{
public const string AuthenticationScheme = "Basic";
}

View File

@@ -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<BasicAuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<BasicAuthOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> 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);
}
}

View File

@@ -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<ActionResult<ApiResponse>> 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));
}
}

View File

@@ -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<BasicAuthOptions, BasicAuthenticationHandler>(
BasicAuthenticationDefaults.AuthenticationScheme,
options => configuration.GetSection("BasicAuth").Bind(options));
services.AddAuthorization();
services.AddSingleton<IMenuStore, InMemoryMenuStore>();
services.AddSingleton<ISmsApiService, SmsApiService>();
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy;
options.SerializerOptions.Converters.Add(new DecimalFromStringJsonConverter());
});
return services;
}
}

View File

@@ -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<GetMenuResponse> 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<SendOrderResponse> 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 = "",
});
}
}

View File

@@ -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 },
};
}

View File

@@ -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<SmsTestGrpcService>();
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

View File

@@ -0,0 +1,10 @@
using Domain.Entities;
namespace ApiServer.Services;
public interface IMenuStore
{
IReadOnlyList<Dish> GetMenu();
bool TrySendOrder(string orderId, IReadOnlyList<(string Id, decimal Quantity)> items, out string? errorMessage);
}

View File

@@ -0,0 +1,9 @@
using Contracts.Requests;
using Contracts.Responses;
namespace ApiServer.Services;
public interface ISmsApiService
{
ApiResponse Handle(ApiRequest request);
}

View File

@@ -0,0 +1,82 @@
using Domain.Entities;
namespace ApiServer.Services;
internal sealed class InMemoryMenuStore : IMenuStore
{
private readonly List<Dish> _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<string> _orderIds = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<Dish> 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;
}
}

View File

@@ -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,
};
}

View File

@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"BasicAuth": {
"Username": "user",
"Password": "password"
}
}

View File

@@ -6,8 +6,22 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.27.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.67.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.67.0" />
<PackageReference Include="Grpc.Tools" Version="2.67.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\sms.proto" GrpcServices="Both" />
</ItemGroup>
</Project>

View File

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