Web API
Minimal API против MVC-контроллеров, привязка модели, результаты действий, JWT-аутентификация, политики авторизации и версионирование API.
11 вопросов
JuniorТеорияОчень частоЧто нужно AddJwtBearer, чтобы проверить токен, и откуда берутся эти значения?
Что нужно AddJwtBearer, чтобы проверить токен, и откуда берутся эти значения?
Ему нужны TokenValidationParameters: ключ подписи, допустимый issuer и допустимая audience. Их привязывают при старте из builder.Configuration, а не зашивают в код — ключ является секретом, поэтому приходит из переменной окружения, читаемой после appsettings.json и потому побеждающей.
Типичные ошибки
- ✗Зашивать ключ подписи в код вместо привязки из конфигурации
- ✗Доверять issuer и audience из самого токена, а не настроенным значениям
- ✗Думать, что payload
JWTзашифрован, а не просто подписан
Уточняющие вопросы
- →Что делает обработчик, когда claim
expв токене уже истёк? - →Почему любой, у кого есть подписанный токен, может прочитать его payload?
MiddleТеорияОчень частоЧем аутентификация отличается от авторизации и что политика даёт сверх проверки роли?
Чем аутентификация отличается от авторизации и что политика даёт сверх проверки роли?
Аутентификация устанавливает, кто вызывающий, и даёт ClaimsPrincipal; авторизация решает, вправе ли этот principal дойти до эндпоинта. Политика — это именованный набор требований: claims, роли или своё требование с обработчиком, поэтому [Authorize] называет намерение вроде CanRefund вместо жёсткой привязки к имени группы.
Типичные ошибки
- ✗Использовать роли для всего и кодировать бизнес-правила в именах групп
- ✗Ждать, что политика вычислится до routing, без метаданных эндпоинта
- ✗Путать 401 (не аутентифицирован) с 403 (аутентифицирован, но не допущен)
Уточняющие вопросы
- →Когда нужен обработчик авторизации вместо простого требования по claims?
- →Какой статус-код даст провалившаяся политика уже аутентифицированному вызывающему?
MiddleТеорияОчень частоГде в конвейере работает bearer-аутентификация с форматом подписанных токенов JWT и что она проверяет?
Где в конвейере работает bearer-аутентификация с форматом подписанных токенов JWT и что она проверяет?
UseAuthentication работает после UseRouting и до UseAuthorization. Обработчик читает bearer-токен из заголовка Authorization, проверяет его подпись настроенным ключом, а также issuer, audience и срок годности; при успехе он кладёт ClaimsPrincipal в HttpContext.User. Доступ он не решает — это делает UseAuthorization.
Типичные ошибки
- ✗Ждать, что
UseAuthenticationотклонит анонимный запрос, хотя он лишь строит principal - ✗Регистрировать
UseAuthorizationдоUseAuthentication, когдаHttpContext.Userещё пуст - ✗Считать, что верная подпись уже означает авторизованность вызывающего на эндпоинте
Уточняющие вопросы
- →Как отозвать токен до истечения срока, если обработчик не хранит состояние?
- →Откуда на старте берутся ключ подписи, issuer и audience?
JuniorТеорияЧастоЧто атрибут [ApiController] включает для контроллера автоматически?
Что атрибут [ApiController] включает для контроллера автоматически?
Он включает API-соглашения: автоматический ответ 400 с телом ProblemDetails, когда ModelState невалиден, вывод источника привязки (сложные типы берутся из тела, простые — из route values, затем из query string), и обязательный attribute routing на контроллере.
Типичные ошибки
- ✗По-прежнему писать руками проверку невалидного
ModelStateв каждом действии - ✗Ждать, что на
[ApiController]заработает conventional routing, хотя нужен attribute routing - ✗Считать, что сложный параметр привязывается из query string, а не из тела запроса
Уточняющие вопросы
- →Как заменить автоматический ответ 400 на собственную форму ошибки?
- →Какие типы параметров вывод источника привязки отправляет в тело запроса?
JuniorТеорияЧастоКогда действие контроллера возвращает IActionResult вместо конкретного типа?
Когда действие контроллера возвращает IActionResult вместо конкретного типа?
Конкретный тип возвращают, когда действие всегда отдаёт одну форму — она сериализуется и уходит с кодом 200. IActionResult возвращают, когда действие само выбирает статус-код, например NotFound() или BadRequest(). ActionResult<T> даёт и то и другое, сохраняя тип полезной нагрузки видимым для описания API.
Типичные ошибки
- ✗Думать, что конкретный тип возврата не может выразить 404 или 400
- ✗Возвращать
IActionResultвезде и терять тип ответа в описании API - ✗Забывать, что
ActionResult<T>умеет вернуть и типизированную нагрузку, и статус-результат
Уточняющие вопросы
- →Как
ProducesResponseTypeменяет документ API-описанияOpenAPIдля действия? - →Какой статус-код отдаст действие под
[ApiController], если вернутьnull?
MiddleТеорияЧастоЧто выигрывает и что теряет модель эндпоинтов Minimal APIs против контроллеров model-view-controller MVC?
Что выигрывает и что теряет модель эндпоинтов Minimal APIs против контроллеров model-view-controller MVC?
Minimal APIs отображают делегат прямо на маршрут: нет класса контроллера, нет конвейера action-фильтров, ответ строится через TypedResults. Контроллеры дают соглашения, которые тянут большой API: action-фильтры, валидацию ModelState через [ApiController], свои model binders. И те и другие работают на одном routing, контейнере и конвейере middleware.
Типичные ошибки
- ✗Ждать автоматического 400 от
[ApiController]при невалидной модели в эндпоинте Minimal API - ✗Считать, что Minimal APIs минуют конвейер middleware или контейнер
- ✗Думать, что к эндпоинту Minimal API применяются action-фильтры, хотя там endpoint-фильтры
Уточняющие вопросы
- →Как валидировать тело запроса в эндпоинте Minimal API?
- →Что такое endpoint-фильтр и чем он отличается от action-фильтра?
MiddleТеорияЧастоКак model binding решает, из какой части запроса берётся параметр действия?
Как model binding решает, из какой части запроса берётся параметр действия?
Явный атрибут побеждает — [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromServices]. Без него [ApiController] выводит источник сам: сложный тип берётся из тела, простой — из route values, а затем из query string. Из тела может прийти не более одного параметра, потому что тело читается один раз.
Типичные ошибки
- ✗Ставить два сложных параметра в одно действие и ждать, что оба привяжутся из тела
- ✗Забывать, что простой тип берётся из route values и query, а не из тела
- ✗Считать, что явный
[FromQuery]перекрывается выводом источника привязки
Уточняющие вопросы
- →Почему из тела запроса может привязаться только один параметр?
- →Что делает привязка, если значение есть, но не приводится к нужному типу?
MiddleДизайнИногдаВаша команда владеет публичным REST API, которым пользуются сторонние клиенты, и заставить их обновиться вы не можете. В следующем релизе надо добавить обязательное поле в тело запроса на создание заказа и изменить форму ответа — оба изменения ломающие. Существующие клиенты должны продолжать работать без правок как минимум год, новые клиенты должны по умолчанию получать новый контракт, и вам нужно видеть, кто ещё зовёт старый контракт, чтобы спланировать его удаление. Всё живёт за одним деплоем и одной базой. Спроектируйте стратегию версионирования: как запрос выбирает версию, как обе версии представлены в коде, что вы отвечаете на запрос неподдерживаемой версии и как объявляется и измеряется устаревание.
Ваша команда владеет публичным REST API, которым пользуются сторонние клиенты, и заставить их обновиться вы не можете. В следующем релизе надо добавить обязательное поле в тело запроса на создание заказа и изменить форму ответа — оба изменения ломающие. Существующие клиенты должны продолжать работать без правок как минимум год, новые клиенты должны по умолчанию получать новый контракт, и вам нужно видеть, кто ещё зовёт старый контракт, чтобы спланировать его удаление. Всё живёт за одним деплоем и одной базой. Спроектируйте стратегию версионирования: как запрос выбирает версию, как обе версии представлены в коде, что вы отвечаете на запрос неподдерживаемой версии и как объявляется и измеряется устаревание.
Версионируйте контракт, а не базу. Выберите один явный селектор: сегмент URL заметнее всего, заголовок сохраняет URL неизменными. Заморозьте обработчики v1 и дайте v2 развиваться, отображая обе версии на одну доменную модель. На неподдерживаемую версию отвечайте 400 и перечисляйте поддерживаемые. Объявляйте устаревание заголовком Sunset и считайте вызовы по версиям, чтобы удаление опиралось на данные, а не на надежду.
Типичные ошибки
- ✗Вносить ломающее изменение на месте и надеяться, что клиенты успеют подстроиться
- ✗Выбирать версию неявно (по API-ключу, адресу, умолчанию), а не по самому запросу
- ✗Выкатывать новую версию без сигнала об устаревании и без метрик использования по версиям
Уточняющие вопросы
- →Как сохранить одну доменную модель, на которую отображаются две версии контракта?
- →Что вы отвечаете клиенту, запросившему версию, которую вы уже удалили?
SeniorДизайнИногдаRead-heavy API каталога отдаёт страницы товаров: 95% трафика — чтения, и горстка товаров забирает большую его часть. Сервис работает на нескольких инстансах. Данные товара меняются редко, но когда редактор обновляет товар, изменение должно стать видимым на каждом инстансе за считаные секунды. Чтения сейчас идут прямо в EF Core, и база стала узким местом. Кроме того, вы видите повторные запросы идентификаторов товаров, которых не существует, а когда истекает запись популярного товара, десятки параллельных запросов разом бьют в базу. Памяти на инстансе мало, весь каталог локально не удержать. Спроектируйте слой кэширования: какие уровни, как обслуживается чтение, как обновление становится видимым везде и как вы защищаете базу от этих двух отказов.
Read-heavy API каталога отдаёт страницы товаров: 95% трафика — чтения, и горстка товаров забирает большую его часть. Сервис работает на нескольких инстансах. Данные товара меняются редко, но когда редактор обновляет товар, изменение должно стать видимым на каждом инстансе за считаные секунды. Чтения сейчас идут прямо в EF Core, и база стала узким местом. Кроме того, вы видите повторные запросы идентификаторов товаров, которых не существует, а когда истекает запись популярного товара, десятки параллельных запросов разом бьют в базу. Памяти на инстансе мало, весь каталог локально не удержать. Спроектируйте слой кэширования: какие уровни, как обслуживается чтение, как обновление становится видимым везде и как вы защищаете базу от этих двух отказов.
Два уровня: кэш в памяти инстанса перед общим распределённым кэшем, оба читаются cache-aside поверх запроса с AsNoTracking. На обновление инвалидируйте ключ в обоих уровнях и держите время жизни коротким, чтобы пропущенная инвалидация самоисцелялась за секунды. Кэшируйте и промахи, ненадолго, чтобы несуществующие идентификаторы не доходили до базы. Сливайте параллельные перестройки ключа, чтобы истечение стоило одного запроса, а не десятков.
Типичные ошибки
- ✗Полагаться только на истечение, из-за чего обновление редактора невидимо всё время жизни записи
- ✗Не кэшировать промахи, из-за чего запросы несуществующих идентификаторов доходят до базы
- ✗Давать истечению горячего ключа отправить все параллельные запросы в базу разом
Уточняющие вопросы
- →Как инвалидировать уровень в памяти на остальных инстансах после обновления?
- →Как не дать двум уровням отдавать две разные версии одного товара?
SeniorДизайнРедкоВы строите API для бизнес-продукта на ASP.NET Core и EF Core. Каждый клиент — это tenant, и данные одного tenant не должны никогда утечь в ответ другому: это жёсткое требование. Tenant-ы бывают и крошечные, и несколько очень крупных; один корпоративный tenant по договору требует, чтобы его данные лежали в отдельной базе, и любой tenant может попросить выгрузить или удалить свои данные. Новых подключают еженедельно, поэтому провижининг должен быть дешёвым, а миграции схемы — оставаться управляемыми, когда tenant-ов станут тысячи. Tenant известен из claim в токене вызывающего. Сравните общую базу с колонкой-дискриминатором и базу на каждого tenant и спроектируйте стратегию разрешения и изоляции, включая то, как DbContext узнаёт, какой tenant он обслуживает.
Вы строите API для бизнес-продукта на ASP.NET Core и EF Core. Каждый клиент — это tenant, и данные одного tenant не должны никогда утечь в ответ другому: это жёсткое требование. Tenant-ы бывают и крошечные, и несколько очень крупных; один корпоративный tenant по договору требует, чтобы его данные лежали в отдельной базе, и любой tenant может попросить выгрузить или удалить свои данные. Новых подключают еженедельно, поэтому провижининг должен быть дешёвым, а миграции схемы — оставаться управляемыми, когда tenant-ов станут тысячи. Tenant известен из claim в токене вызывающего. Сравните общую базу с колонкой-дискриминатором и базу на каждого tenant и спроектируйте стратегию разрешения и изоляции, включая то, как DbContext узнаёт, какой tenant он обслуживает.
Поставляйте оба варианта за одной абстракцией. Разрешайте tenant один раз на запрос из claim токена в scoped-контекст tenant, который scoped DbContext берёт, чтобы выбрать строку подключения. Tenant-ы в общей базе получают глобальный фильтр по дискриминатору плюс идентификатор tenant на каждой записи: дёшево подключать и мигрировать, но пропущенный фильтр утечёт данные. Корпоративный tenant получает свою базу — изоляция по построению ценой миграции многих.
Типичные ошибки
- ✗Передавать идентификатор tenant руками в каждый запрос, из-за чего один забытый фильтр утечёт данные
- ✗Доверять присланному клиентом заголовку вместо аутентифицированного claim
- ✗Держать текущий tenant в
Singleton, а не в scoped-сервисе
Уточняющие вопросы
- →Как не дать обойти глобальный фильтр запросов явным отключением или сырым SQL?
- →Как безопасно прогнать одну миграцию по тысячам баз tenant-ов?
SeniorДизайнРедкоПубличный REST API обслуживают четыре инстанса за балансировщиком. Анонимные вызывающие опознаются по адресу, аутентифицированные — по subject-claim токена, а платный тариф должен получать больший лимит, чем бесплатный. Трафик неровный: клиент может законно выдать двадцать вызовов за секунду и затем молчать, а наивный счётчик с фиксированным окном и позволяет потратить две квоты на стыке окон, и отвергает этот законный всплеск. Злоупотребляющий клиент не должен стоить вам запроса в базу на каждый отклонённый вызов, а притормаживаемому клиенту надо сказать, когда повторить. Спроектируйте ограничитель: где он стоит в конвейере, как запрос относится к клиенту и тарифу, как один бюджет соблюдается на всех четырёх инстансах и что видит отклонённый вызывающий.
Публичный REST API обслуживают четыре инстанса за балансировщиком. Анонимные вызывающие опознаются по адресу, аутентифицированные — по subject-claim токена, а платный тариф должен получать больший лимит, чем бесплатный. Трафик неровный: клиент может законно выдать двадцать вызовов за секунду и затем молчать, а наивный счётчик с фиксированным окном и позволяет потратить две квоты на стыке окон, и отвергает этот законный всплеск. Злоупотребляющий клиент не должен стоить вам запроса в базу на каждый отклонённый вызов, а притормаживаемому клиенту надо сказать, когда повторить. Спроектируйте ограничитель: где он стоит в конвейере, как запрос относится к клиенту и тарифу, как один бюджет соблюдается на всех четырёх инстансах и что видит отклонённый вызывающий.
Ограничивайте в конвейере до всякой работы: лимитер стоит выше авторизации, а health-эндпоинт из него исключён. Относите запрос к subject токена, если он аутентифицирован, иначе к адресу, и выбирайте политику по тарифу вызывающего. Сглаживайте стык скользящим окном или token bucket, чтобы короткий всплеск проходил, а средняя скорость держалась. Счётчики держите в хранилище, общем для всех инстансов, и отклоняйте кодом 429 с Retry-After.
Типичные ошибки
- ✗Держать счётчики в памяти каждого инстанса, из-за чего реальный лимит умножается на их число
- ✗Брать фиксированное окно, позволяя клиенту потратить две квоты на стыке окон
- ✗Аутентифицировать или ходить в базу до лимитера, из-за чего отклонённый трафик всё равно стоит работы
Уточняющие вопросы
- →Как не дать общему хранилищу счётчиков стать узким местом или единой точкой отказа?
- →Что вы вернёте клиенту, который упорно игнорирует присланный вами
Retry-After?