ApiServer
This commit is contained in:
@@ -6,4 +6,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Contracts\Contracts.csproj" />
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
src/ApiServer/Auth/BasicAuthOptions.cs
Normal file
10
src/ApiServer/Auth/BasicAuthOptions.cs
Normal 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";
|
||||
}
|
||||
6
src/ApiServer/Auth/BasicAuthenticationDefaults.cs
Normal file
6
src/ApiServer/Auth/BasicAuthenticationDefaults.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ApiServer.Auth;
|
||||
|
||||
public static class BasicAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "Basic";
|
||||
}
|
||||
60
src/ApiServer/Auth/BasicAuthenticationHandler.cs
Normal file
60
src/ApiServer/Auth/BasicAuthenticationHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/ApiServer/Controllers/ApiController.cs
Normal file
37
src/ApiServer/Controllers/ApiController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
30
src/ApiServer/DependencyInjection.cs
Normal file
30
src/ApiServer/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/ApiServer/Grpc/SmsTestGrpcService.cs
Normal file
45
src/ApiServer/Grpc/SmsTestGrpcService.cs
Normal 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 = "",
|
||||
});
|
||||
}
|
||||
}
|
||||
29
src/ApiServer/Mapping/MenuMapper.cs
Normal file
29
src/ApiServer/Mapping/MenuMapper.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
10
src/ApiServer/Services/IMenuStore.cs
Normal file
10
src/ApiServer/Services/IMenuStore.cs
Normal 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);
|
||||
}
|
||||
9
src/ApiServer/Services/ISmsApiService.cs
Normal file
9
src/ApiServer/Services/ISmsApiService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Contracts.Requests;
|
||||
using Contracts.Responses;
|
||||
|
||||
namespace ApiServer.Services;
|
||||
|
||||
public interface ISmsApiService
|
||||
{
|
||||
ApiResponse Handle(ApiRequest request);
|
||||
}
|
||||
82
src/ApiServer/Services/InMemoryMenuStore.cs
Normal file
82
src/ApiServer/Services/InMemoryMenuStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/ApiServer/Services/SmsApiService.cs
Normal file
71
src/ApiServer/Services/SmsApiService.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -5,5 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"BasicAuth": {
|
||||
"Username": "user",
|
||||
"Password": "password"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
src/Contracts/Protos/sms.proto
Normal file
43
src/Contracts/Protos/sms.proto
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user