Към съдържанието
wannacode

WebAPI: данни за други програми

Проблем

Приложение, а не браузър

Магазинът пуска приложение за телефон, а партньорско кафене иска да поръчва от каталога автоматично. Страниците от предишните уроци са HTML за хора: менюта, таблици, бутони. Програмата на кафенето трябва да рови в този HTML, за да намери цената. Щом дизайнерът премести колоната, програмата спира да работи.

Програмите искат само данните, в предвидим вид. Затова сървърът има и втора врата: WebAPI, адреси, които връщат JSON вместо страници. Едно API служи на приложението за телефон, на сайта, на десктоп програмата на касата и на партньорите.

Идеята

Ресурси, методи и статуси

API-тата днес обикновено следват REST. Правилата са малко:

  • Адресът назовава ресурс, нещо: /api/products е всички продукти, /api/products/2 е продукт 2. В адреса няма глаголи като getProduct или delete.
  • Какво правиш с ресурса, казва HTTP методът: GET чете, POST създава, PUT заменя, DELETE изтрива.
  • Как е минало, казва статусът:
Статус Кога
200 OK GET е намерил ресурса, тялото е JSON
201 Created POST е създал нов ресурс; Location е адресът му
204 No Content PUT или DELETE са успели, тяло няма
400 Bad Request данните не са валидни, тялото казва защо
404 Not Found такъв ресурс няма
409 Conflict заявката е правилна, но не може да се изпълни сега (например няма наличност)

API по тези правила се казва REST API. Клиентът може да го ползва, без да чете кода на сървъра: адресът и методът стигат.

Пет заявки към един и същ ресурс и статусите, които връщат. Как се пишат контролерът и клиентът, е в следващите карти:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5292") };
var matcha = new Product { Name = "Матча", Price = 18.00m, Category = "чай" };
var cheaper = new Product { Name = "Колумбия Супремо", Price = 11.90m, Category = "кафе" };
Show(await http.GetAsync("/api/products/3"));
Show(await http.PostAsJsonAsync("/api/products", matcha));
Show(await http.PutAsJsonAsync("/api/products/2", cheaper));
Show(await http.DeleteAsync("/api/products/4"));
Show(await http.GetAsync("/api/products/4"));
static void Show(HttpResponseMessage r) =>
Console.WriteLine(r.RequestMessage.Method + " " + r.RequestMessage.RequestUri.AbsolutePath + " -> " + (int)r.StatusCode);
Стъпка по стъпка, с думи
  1. Ресурсът е /api/products: 4 продукта на сървъра. Клиентът знае само адреса.
  2. GET чете продукт 3 (Сенча). Има го: 200 OK и продуктът като JSON.
  3. POST към колекцията създава нов продукт. Сървърът му дава id 5: 201 Created.
  4. PUT заменя продукт 2: цената става 11.90 €. Тяло в отговора няма: 204 No Content.
  5. DELETE изтрива продукт 4: 204 No Content.
  6. GET на продукт 4: вече го няма, затова 404 Not Found.
  7. Адресът казва кой ресурс, методът какво правиш с него, статусът как е минало.

Виж го

Контролер за данни

Контролерът на API наследява ControllerBase: това е Controller без изгледите. Двата атрибута отгоре го правят API:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
[HttpGet]
public List<Product> GetAll() => products.All();
[HttpGet("{id:int}")]
public ActionResult<Product> Get(int id)
{
Product product = products.Find(id);
if (product == null) return NotFound();
return product;
}
}
  • [Route("api/[controller]")] слага името на контролера в адреса: api/Products. Големината на буквите в адреса няма значение.
  • [ApiController] включва поведението на API: параметрите идват от JSON тялото, а невалидните данни връщат 400 сами (след малко).
  • GetAll връща списък и той става JSON. Get връща ActionResult<Product>: или продукт (200 с JSON), или друг резултат, например NotFound(). return product; е позволено, защото C# превръща продукта в ActionResult<Product> сам.

В Program.cs контролерите се добавят с AddControllers (без изгледи) и се свързват с MapControllers (маршрутите са в атрибутите). AddJsonOptions е настройката за четим JSON от урока за MVC:

var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));
builder.Services.AddSingleton<IProductService, ProductService>();
var app = builder.Build();
app.Urls.Add("http://localhost:5261");
app.MapControllers();
await app.StartAsync();

Клиентът задава общото начало на адресите с BaseAddress и после пише само пътя:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5261") };
Console.WriteLine(await http.GetStringAsync("/api/products"));
Product tea = await http.GetFromJsonAsync<Product>("/api/products/3");
Console.WriteLine(tea.Name + ", " + tea.Price + " €");
HttpResponseMessage missing = await http.GetAsync("/api/products/99");
Console.WriteLine("GET /api/products/99 -> " + (int)missing.StatusCode);
Изход
[{"id":1,"name":"Етиопия Йиргачефе","price":14.90,"category":"кафе"},{"id":2,"name":"Колумбия Супремо","price":12.50,"category":"кафе"},{"id":3,"name":"Сенча","price":8.40,"category":"чай"},{"id":4,"name":"Ерл Грей","price":6.90,"category":"чай"}]
Сенча, 8.40 €
GET /api/products/99 -> 404

GetFromJsonAsync<Product> чете JSON и прави от него обект Product. Свойствата в JSON са с малка буква (name), а в C# с главна (Name). Четенето не гледа големината на буквите.

Виж го

POST: нов продукт

Product е със същите свойства, а атрибутите казват какво е валидно:

public class Product
{
public int Id { get; set; }
[Required(ErrorMessage = "Името е задължително.")]
public string Name { get; set; }
[Range(0.01, 1000, ErrorMessage = "Цената е от 0.01 до 1000 €.")]
public decimal Price { get; set; }
public string Category { get; set; }
}

Действието за създаване:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
products.Add(product);
return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}

CreatedAtAction връща 201, слага в Location адреса на новия продукт (сглобен от действието Get и id) и праща продукта в тялото. nameof(Get) е името на метода като текст, "Get", но компилаторът го проверява. Клиентът праща един верен и един празен продукт:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5263") };
var matcha = new Product { Name = "Матча", Price = 18.00m, Category = "чай" };
HttpResponseMessage created = await http.PostAsJsonAsync("/api/products", matcha);
Product saved = await created.Content.ReadFromJsonAsync<Product>();
Console.WriteLine((int)created.StatusCode + ", Location: " + created.Headers.Location);
Console.WriteLine("Новият продукт е с id " + saved.Id);
HttpResponseMessage rejected = await http.PostAsJsonAsync("/api/products", new Product { Price = 0 });
Console.WriteLine((int)rejected.StatusCode);
ValidationProblemDetails problem = await rejected.Content.ReadFromJsonAsync<ValidationProblemDetails>();
foreach (KeyValuePair<string, string[]> error in problem.Errors.OrderBy(e => e.Key))
{
Console.WriteLine(error.Key + ": " + string.Join(" ", error.Value));
}
Изход
201, Location: http://localhost:5263/api/Products/5
Новият продукт е с id 5
400
Name: Името е задължително.
Price: Цената е от 0.01 до 1000 €.

Вторият продукт няма име и цената му е 0. Create изобщо не се вика: [ApiController] проверява атрибутите и връща 400 сам. Тялото е ValidationProblemDetails: стандартен JSON с полето errors, в което за всяко свойство има списък грешки.

По-точно, за любопитните
  • Без [ApiController] трябва сам да пишеш if (!ModelState.IsValid) return ValidationProblem(); в началото на всяко действие и [FromBody] пред параметъра, за да се чете от JSON.
  • В тялото на 400 има и traceId, номер на заявката. По него намираш грешката в логовете на сървъра. Той е различен при всяка заявка, затова програмата печата само errors.
  • NotFound() в API контролер също връща малко JSON (ProblemDetails със статус 404), не празно тяло.

Виж го

PUT и DELETE

[HttpPut("{id:int}")]
public IActionResult Update(int id, Product product)
{
if (!products.Update(id, product)) return NotFound();
return NoContent();
}
[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
if (!products.Remove(id)) return NotFound();
return NoContent();
}

Услугата е тази от урока за MVC, с три метода повече: Add, Update и Remove. Update и Remove връщат false, ако продукт с този id няма, а действието го превръща в 404:

public interface IProductService
{
Product Find(int id);
List<Product> All();
void Add(Product product);
bool Update(int id, Product product);
bool Remove(int id);
}
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5264") };
var cheaper = new Product { Name = "Колумбия Супремо", Price = 11.90m, Category = "кафе" };
HttpResponseMessage updated = await http.PutAsJsonAsync("/api/products/2", cheaper);
Console.WriteLine("PUT /api/products/2 -> " + (int)updated.StatusCode);
Console.WriteLine((await http.GetFromJsonAsync<Product>("/api/products/2")).Price + " €");
HttpResponseMessage deleted = await http.DeleteAsync("/api/products/4");
Console.WriteLine("DELETE /api/products/4 -> " + (int)deleted.StatusCode);
HttpResponseMessage again = await http.DeleteAsync("/api/products/4");
Console.WriteLine("DELETE /api/products/4 -> " + (int)again.StatusCode);
HttpResponseMessage nowhere = await http.PutAsJsonAsync("/api/products/4", cheaper);
Console.WriteLine("PUT /api/products/4 -> " + (int)nowhere.StatusCode);
Изход
PUT /api/products/2 -> 204
11.90 €
DELETE /api/products/4 -> 204
DELETE /api/products/4 -> 404
PUT /api/products/4 -> 404

PUT праща целия продукт и той заменя стария. Второто изтриване на продукт 4 е 404: вече го няма. Същото е и при PUT към него.

По-точно, за любопитните
  • За промяна само на част от ресурса (например само цената) има метод PATCH. Той е по-рядък, защото трябва да се договориш как изглежда „част“.
  • GET, PUT и DELETE са идемпотентни: да ги повториш е безопасно, резултатът на сървъра е същият. POST не е: два POST правят два продукта. Затова браузърът пита „Да изпратя ли формата отново?“ само при POST.

Пробвай

Четири заявки

Сървърът е от предишните карти, с четирите продукта. Какво печата клиентът?

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5262") };
var sencha = new Product { Name = "Сенча", Price = 9.20m, Category = "чай" };
HttpResponseMessage a = await http.PutAsJsonAsync("/api/products/3", sencha);
HttpResponseMessage b = await http.DeleteAsync("/api/products/3");
HttpResponseMessage c = await http.GetAsync("/api/products/3");
HttpResponseMessage d = await http.PostAsJsonAsync("/api/products", new Product { Name = "", Price = 5 });
Console.WriteLine((int)a.StatusCode + " " + (int)b.StatusCode + " " + (int)c.StatusCode + " " + (int)d.StatusCode);
Отговор

Пробвай

Продуктът не се намира

GET /api/products/2 връща 405, а GET /api/products/id връща 404. Продукт 2 се показва само на странния адрес /api/products/id?id=2. На кой ред е грешката?

Избери реда с грешката.

Проблем

Последните десет пакета

Сървърът не обслужва заявките една по една. Ако сто души натиснат „Купи“ в една и съща секунда, ASP.NET Core ги обработва едновременно. Всички те ползват един и същ обект: услугата е singleton.

Складът има 10 пакета от промоционалното кафе. Резервацията проверява и намалява:

public class Stock
{
private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 };
public bool TryTake(int productId)
{
if (!left.TryGetValue(productId, out int count) || count == 0) return false;
left[productId] = count - 1;
return true;
}
public int Left(int productId) => left[productId];
}

Една по една заявките минават правилно, 10 пъти 201 и после 409:

Изход (12 заявки една след друга)
201 201 201 201 201 201 201 201 201 201 409 409

Едновременно обаче две заявки могат да прочетат count = 1 в един и същ миг. И двете решават, че има, и двете пишат 0 и връщат 201. Така продаваш повече, отколкото имаш. Колко повече, зависи от случая: при всяко пускане е различно. Същото е с последната стая в хотел и с последното място в залата.

Идеята

lock: един по един в опасното място

Проверката и промяната трябва да са неделими: никой да не влиза между тях. В C# това прави lock:

public class Stock
{
private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 };
private readonly object sync = new();
public bool TryTake(int productId)
{
lock (sync)
{
if (!left.TryGetValue(productId, out int count) || count == 0) return false;
left[productId] = count - 1;
return true;
}
}
public int Left(int productId)
{
lock (sync)
{
return left[productId];
}
}
}
  • sync е обикновен обект, който служи само за ключ. new() е кратко за new object(): типът се взима от полето отляво.
  • lock (sync) { ... } пуска в блока само една заявка наведнъж. Другите чакат на входа, докато първата излезе. Така проверката и намаляването стават заедно.
  • Четенето също е в lock: иначе можеш да прочетеш речника точно докато друга заявка го променя.

Контролерът е обикновен: 201 при успех, 409 Conflict, ако няма наличност:

public class Reservation
{
public int ProductId { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class ReservationsController : ControllerBase
{
private readonly Stock stock;
public ReservationsController(Stock stock) => this.stock = stock;
[HttpPost]
public IActionResult Reserve(Reservation reservation)
{
if (!stock.TryTake(reservation.ProductId)) return Conflict("Няма наличност.");
return StatusCode(201, reservation);
}
}

Доказателството: сто заявки наведнъж с Task.WhenAll срещу 10 пакета:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5266") };
var requests = new List<Task<HttpResponseMessage>>();
for (int i = 0; i < 100; i++)
{
requests.Add(http.PostAsJsonAsync("/api/reservations", new Reservation { ProductId = 1 }));
}
HttpResponseMessage[] responses = await Task.WhenAll(requests);
Console.WriteLine("201 Created: " + responses.Count(r => (int)r.StatusCode == 201));
Console.WriteLine("409 Conflict: " + responses.Count(r => (int)r.StatusCode == 409));
Console.WriteLine("Остават: " + app.Services.GetRequiredService<Stock>().Left(1));
Изход
201 Created: 10
409 Conflict: 90
Остават: 0

С lock резултатът е един и същ при всяко пускане. Същото важи за всяка услуга, регистрирана с AddSingleton, която пази данни в паметта: ProductService от горните карти също трябва да заключва в Add, Update и Remove, ако с него работят много клиенти.

По-точно, за любопитните
  • Вътре в lock не може да има await, и компилаторът не го позволява. lock държи ключа за нишката, която е влязла, а след await продължението може да е на друга нишка. Ако трябва да чакаш нещо асинхронно, докато пазиш данните, ползва се SemaphoreSlim с await semaphore.WaitAsync().
  • Блокът в lock трябва да е кратък: всички останали чакат. Не прави в него заявки към други сървъри или към диска.
  • За речник, който се ползва от много места едновременно, има готов ConcurrentDictionary<K, V>: методи като TryUpdate и AddOrUpdate правят проверката и промяната наведнъж, без твой lock.
  • За брояч стига Interlocked: Interlocked.Increment(ref count) увеличава числото неделимо. count++ не е неделимо: то е „прочети, събери, запиши“.
  • В истинско приложение складът е в база данни. Там същата задача решават транзакциите и условие в самата заявка: „намали, само ако е над 0“.

Виж го

Програмата от другата страна

Партньорското кафене не знае нищо за контролерите. То знае адресите и вида на JSON. Удобно е заявките да са в един клас:

public class CatalogClient
{
private readonly HttpClient http;
public CatalogClient(string address)
{
http = new HttpClient { BaseAddress = new Uri(address) };
}
public async Task<List<Product>> AllAsync() => await http.GetFromJsonAsync<List<Product>>("/api/products");
public async Task<int> AddAsync(Product product)
{
HttpResponseMessage response = await http.PostAsJsonAsync("/api/products", product);
response.EnsureSuccessStatusCode();
Product saved = await response.Content.ReadFromJsonAsync<Product>();
return saved.Id;
}
}

EnsureSuccessStatusCode() хвърля HttpRequestException, ако статусът не е 2xx. Така грешка от сървъра не минава незабелязано. Кафенето добавя продукт, взима кафетата, подредени по цена, и опитва невалиден продукт:

var catalog = new CatalogClient("http://localhost:5267");
int id = await catalog.AddAsync(new Product { Name = "Гватемала Антигуа", Price = 13.20m, Category = "кафе" });
Console.WriteLine("Добавен с id " + id);
List<Product> coffee = (await catalog.AllAsync()).Where(p => p.Category == "кафе").OrderBy(p => p.Price).ToList();
foreach (Product p in coffee)
{
Console.WriteLine(p.Name + " – " + p.Price + " €");
}
try
{
await catalog.AddAsync(new Product { Name = "Без цена" });
}
catch (HttpRequestException e)
{
Console.WriteLine("Отказано: " + (int)e.StatusCode);
}
Изход
Добавен с id 5
Колумбия Супремо – 12.50 €
Гватемала Антигуа – 13.20 €
Етиопия Йиргачефе – 14.90 €
Отказано: 400
По-точно, за любопитните
  • Swagger / OpenAPI описва API-то в стандартен JSON файл: адреси, методи, вид на телата, статуси. От него се прави страница, на която пробваш API-то от браузъра, и се генерират клиенти на други езици. В .NET 10 описанието е вградено (AddOpenApi и MapOpenApi), а страницата за пробване идва от отделен пакет, например Scalar.AspNetCore или Swashbuckle.AspNetCore.
  • CORS: браузърът по подразбиране не дава на JavaScript от shop.example.bg да чете отговори от api.example.bg. Сървърът разрешава изрично кои сайтове могат, с builder.Services.AddCors(...) и app.UseCors(...). За програми извън браузъра, като тази, CORS няма значение.
  • ASP.NET Core може да отговаря и с XML, ако клиентът поиска с заглавка Accept: application/xml и сървърът е с AddXmlSerializerFormatters(). Изборът на формат по заглавката се казва content negotiation.
  • Един HttpClient се ползва за много заявки. Нов за всяка заявка изчерпва връзките на компютъра при голямо натоварване.

Упражнение

API за задачи

Реши задачата в редактора си, в проект от dotnet new webapi --use-controllers или в един файл като в урока.

API за задачи (todo). TodoItem има Id, Title (задължително, с [Required]) и Done. TodosController на api/todos има петте действия: всички, една по id (или 404), създаване (201 с CreatedAtAction), замяна с PUT (204 или 404) и изтриване (204 или 404). Задачите са в singleton услуга TodoList, която заключва с lock при всяко четене и писане. Новата задача взима следващия номер, дори ако по-стари са изтрити.

Клиентът вика поред: две задачи, задача без заглавие, отбелязва първата като свършена, чете я, изтрива втората, търси я и накрая чете всички. Печата метода, статуса и тялото, ако заявката е успешна и има тяло:

Изход
POST -> 201 {"id":1,"title":"Поръчай филтри за кафе машината","done":false}
POST -> 201 {"id":2,"title":"Обади се на доставчика на чай","done":false}
POST без заглавие -> 400
PUT 1 -> 204
GET 1 -> 200 {"id":1,"title":"Поръчай филтри за кафе машината","done":true}
DELETE 2 -> 204
GET 2 -> 404
GET -> 200 [{"id":1,"title":"Поръчай филтри за кафе машината","done":true}]

После добави GET /api/todos?done=false, което връща само несвършените: параметър bool? done на GetAll и Where.

Тест

Провери се

1. Кой адрес и метод следват REST за „изтрий продукт 7“?
2. Какъв статус връща успешният POST, който създава продукт?
3. Product има [Required] на Name. Какво става с POST без име към контролер с [ApiController]?
4. Сто заявки резервират едновременно последните 10 бройки. Защо без lock може да се продадат повече?
5. Кое връща DELETE, когато изтриването е успяло?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·