API для мобильного клиента на ASP.NET Core

Опубликовано 26.10.2026 09:50:00 в категории Просто о NET

Сайт можно обновить за минуту: вы публикуете новую версию, и через секунду её видят все посетители. С мобильным приложением так не получится. Новую сборку сначала проверяет модерация магазина, потом её устанавливают пользователи — и делают это когда захотят. Часть пользователей не обновится никогда. Поэтому API для мобильного клиента нужно проектировать с расчётом на то, что одновременно с ним работают несколько версий приложения, в том числе очень старые.

В этой статье разберём, как построить такой API на ASP.NET Core. Все примеры будут на одной задаче: приложение «Музей юмора» для RuStore, которое показывает записи сущности «Юмор» из моего блога (calabonga.net). Приложение бесплатное, контент для взрослых в нём не показывается. Платная версия с таким контентом — в планах, и её мы тоже учтём при проектировании, не написав при этом ни строчки лишнего кода.

Код клиента на .NET MAUI разберём в следующей статье. Здесь — только серверная часть: Minimal API, .NET 10, доступ к данным через IDbContextFactory.

Условия, в которых работает мобильный клиент

Прежде чем писать код, полезно перечислить условия, которые отличают мобильный клиент от браузера. Каждое из них превращается в конкретное требование к API.

  1. Старые версии живут годами. Пользователь установил приложение и забыл про него. Через год оно всё ещё ходит в ваш API с запросами версии 1.0. Требование: контракт API нельзя ломать, нужны версионирование и способ попросить пользователя обновиться.
  2. Сеть ненадёжная. Метро, лифт, загородная трасса. Запрос может оборваться посередине, ответ может прийти через десять секунд. Требование: ответы должны быть небольшими, а повторный запрос — безопасным.
  3. Трафик бывает платным. Требование: не гонять одни и те же данные повторно, если они не изменились.
  4. Батарея. Каждое пробуждение радиомодуля стоит заряда. Требование: меньше запросов, больше данных за один запрос там, где это оправдано.
  5. Работа без сети. Хорошее приложение показывает то, что уже загрузило, даже в авиарежиме. Требование: API должен позволять синхронизировать локальный кэш, а не только листать ленту.
  6. Часы устройства врут. Пользователь может вручную выставить любое время. Требование: никаких решений на сервере на основе времени, которое прислал клиент.
  7. Приложение можно разобрать. APK — это архив, и всё, что в нём лежит, можно достать. Требование: в приложении не должно быть секретов.

Дальше каждое из этих требований получит свой раздел с кодом.

Контракт API: DTO вместо сущности

Для статьи я использую упрощённую модель сущности «Юмор». У реальной сущности в блоге полей больше, и называться они могут иначе, но для разговора об API важны именно эти:

public class Humor
{
    public Guid Id { get; set; }
    public string Content { get; set; } = null!;
    public bool IsAdult { get; set; }       // контент 18+
    public bool IsVisible { get; set; }     // опубликовано в блоге
    public string CreatedBy { get; set; } = null!;
    public DateTime CreatedAt { get; set; } // UTC
    public DateTime UpdatedAt { get; set; } // UTC
}

Плохо: отдавать сущность как есть

Самый быстрый способ сделать API — вернуть сущность из базы данных напрямую:

app.MapGet("/api/humor", async (IDbContextFactory<BlogDbContext> factory, CancellationToken ct) =>
{
    await using var db = await factory.CreateDbContextAsync(ct);
    return await db.Humors.ToListAsync(ct);
});

У этого подхода три проблемы.

  • Утечка внутренних данных. Клиент получает CreatedBy (логин автора записи), флаг IsVisible и сам флаг IsAdult. Ничего из этого приложению не нужно.
  • Контракт привязан к схеме базы данных. Переименовали колонку — сломали все установленные приложения. А их, как мы выяснили, быстро не обновить.
  • Нет контроля над объёмом. Добавили в сущность навигационное свойство или большое поле — ответ вырос для всех клиентов, включая тех, кто на платном трафике.

Хорошо: отдельный объект передачи данных

Объект передачи данных (Data Transfer Object, DTO) описывает ровно то, что нужно клиенту:

public sealed record HumorDto(
    Guid Id,
    string Content,
    DateTime CreatedAt,
    DateTime UpdatedAt);

Обратите внимание: флага IsAdult в DTO нет. Сейчас приложение бесплатное, и контент 18+ туда не попадает вообще, поэтому клиенту этот флаг не нужен. Когда появится платная версия, поле можно будет добавить — добавление поля не ломает старые версии приложения (подробнее об этом в разделе «Версионирование (API versioning)»).

Проекция в DTO делается прямо в запросе через Select. Тогда EF Core выбирает из базы только нужные колонки и не отслеживает изменения сущностей:

.Select(x => new HumorDto(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))

Правильно: DTO как опубликованный контракт

DTO — это ещё не контракт, пока его никто не зафиксировал. Правильный шаг — публиковать описание API в формате OpenAPI и считать его договором с клиентом. В .NET 10 для этого достаточно встроенного генератора:

builder.Services.AddOpenApi();
// ...
app.MapOpenApi(); // документ доступен по /openapi/v1.json

Документ OpenAPI даёт две вещи. Во-первых, из него можно сгенерировать клиентский код для MAUI-приложения (этим займёмся в следующей статье). Во-вторых, его можно сравнивать между сборками: если в новой версии из документа пропало поле или изменился тип, это ломающее изменение, и его видно до публикации, а не после жалоб пользователей.

И ещё одно правило для дат: на сервере все даты хранятся и отдаются в UTC. System.Text.Json сериализует DateTime с Kind = Utc в формате ISO 8601 с суффиксом Z, и клиент сам переведёт время в часовой пояс пользователя. Проверьте, что при чтении из базы данных у дат действительно Kind = Utc: провайдер для PostgreSQL возвращает так колонки timestamptz, а для SQL Server понадобится конвертер значений (value converter), иначе суффикса Z в ответе не будет.

Версионирование (API versioning)

Почему без версий не обойтись

Сайт обновляется у всех сразу, а версии приложения на руках у пользователей живут параллельно

На схеме видно главное: в любой момент времени с API работают несколько версий приложения. Значит, сервер должен уметь отвечать каждой из них так, как она ожидает.

Что ломает клиент, а что нет

Ломающие изменения (breaking changes) — те, после которых старое приложение перестаёт работать правильно:

  • удалить или переименовать поле;
  • изменить тип поля (например, Id из int в Guid);
  • сделать необязательное поле обязательным во входных данных;
  • изменить смысл поля, оставив название;
  • добавить новое значение в перечисление, если клиент строго проверяет значения;
  • изменить адрес эндпоинта или формат ошибок.

Неломающие изменения:

  • добавить новое поле в ответ (System.Text.Json по умолчанию игнорирует неизвестные поля, поэтому старый клиент его просто не заметит);
  • добавить новый эндпоинт;
  • добавить необязательный параметр запроса.

Отсюда простое правило: в рамках одной версии API разрешено только добавлять. Всё остальное — новая версия.

Настройка Asp.Versioning для Minimal API

Для версионирования используется пакет Asp.Versioning. В .NET 10 для Minimal API нужны Asp.Versioning.Http и, для интеграции с OpenAPI, Asp.Versioning.Mvc.ApiExplorer. Версию будем передавать в сегменте адреса: /api/v1/humor. Для мобильного клиента это самый наглядный вариант: версия видна в логах, в метриках и в любом прокси.

builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.ReportApiVersions = true; // заголовок api-supported-versions
        options.ApiVersionReader = new UrlSegmentApiVersionReader();
    })
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });

Эндпоинты группируются по версиям:

var humorApi = app.NewVersionedApi("Humor");

var v1 = humorApi
    .MapGroup("/api/v{version:apiVersion}/humor")
    .HasApiVersion(new ApiVersion(1, 0));

v1.MapGet("/", HumorEndpoints.GetFeedAsync);
v1.MapGet("/{id:guid}", HumorEndpoints.GetByIdAsync);
v1.MapGet("/changes", HumorEndpoints.GetChangesAsync);

Когда понадобится версия 2, рядом появится вторая группа, а первая продолжит работать для старых приложений. Для отдельных документов OpenAPI по каждой версии есть пакет Asp.Versioning.OpenApi. В апреле 2026 года он был в статусе release candidate, поэтому перед использованием проверьте его текущий статус.

Минимальная поддерживаемая версия приложения

Рано или поздно старую версию API придётся выключить. Если сделать это молча, старое приложение просто перестанет работать, и пользователь не поймёт почему. Правильный путь — заранее дать приложению способ узнать, что пора обновиться. Для этого достаточно одного эндпоинта с настройками:

public sealed record AppConfigDto(
    string MinSupportedVersion,
    string LatestVersion,
    string? Message);

var appApi = app.NewVersionedApi("App");

appApi.MapGroup("/api/v{version:apiVersion}/app")
    .HasApiVersion(new ApiVersion(1, 0))
    .MapGet("/config", (IOptionsSnapshot<MobileAppOptions> options) =>
    {
        var o = options.Value;
        return TypedResults.Ok(new AppConfigDto(o.MinSupportedVersion, o.LatestVersion, o.Message));
    });

Приложение вызывает этот эндпоинт при запуске. Если его собственная версия меньше MinSupportedVersion, оно показывает экран «Пожалуйста, обновите приложение» со ссылкой на страницу в RuStore. Значения лежат в конфигурации, поэтому поднять минимальную версию можно без выпуска новой сборки сервера.

Важно, чтобы этот эндпоинт появился в самой первой версии приложения. Добавить его потом не получится: приложения, выпущенные без него, о нём никогда не узнают.

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

Теперь главный вопрос для «Музея юмора»: как не показать контент 18+ в бесплатном приложении. Это хороший пример того, где проходит граница ответственности между клиентом и сервером.

Фильтр на клиенте не защищает контент: он уже лежит на устройстве. Решать, что отдавать, должен сервер

Плохо: фильтровать на клиенте

// Сервер отдаёт всё, вместе с флагом IsAdult
var items = await api.GetFeedAsync();

// Приложение «прячет» лишнее
var visible = items.Where(x => !x.IsAdult);

Контент 18+ уже пришёл на устройство. Его видно через любой отладочный прокси, он может попасть в локальный кэш, а в будущем, когда появится подписка, такую «защиту» отключат за пять минут.

Хорошо, но недостаточно: параметр запроса

v1.MapGet("/", (bool includeAdult = false) => ...);

Стало лучше: по умолчанию контент 18+ не отдаётся. Но решение по-прежнему принимает клиент. Достаточно добавить к адресу ?includeAdult=true, и сервер послушно вернёт всё.

Правильно: политика на сервере

Решение о том, какой контент можно показывать, принимает сервер, и клиент не может на него повлиять. Оформим это правило отдельным классом — политикой контента:

public interface IHumorContentPolicy
{
    Expression<Func<Humor, bool>> CanShow { get; }
    bool IsShown(Humor humor);
}

public sealed class FreeContentPolicy : IHumorContentPolicy
{
    private static readonly Expression<Func<Humor, bool>> Rule =
        x => x.IsVisible && !x.IsAdult;

    private static readonly Func<Humor, bool> CompiledRule = Rule.Compile();

    public Expression<Func<Humor, bool>> CanShow => Rule;

    public bool IsShown(Humor humor) => CompiledRule(humor);
}

Правило записано один раз, в виде выражения (expression). CanShow подставляется в запрос EF Core и превращается в условие WHERE в SQL. IsShown — та же самая логика для проверки уже загруженной записи в памяти; она понадобится в разделе «Кэширование и синхронизация».

Регистрация:

Политика применяется ко всем эндпоинтам, которые отдают записи. Самая частая ошибка здесь — защитить ленту, но забыть про запрос одной записи по идентификатору. Тогда контент 18+ можно получить, перебирая идентификаторы. Если запись не проходит политику, ответ — 404 Not Found, а не 403 Forbidden. Ответ 403 сообщил бы клиенту, что запись существует, а это тоже лишняя информация.

Почему не глобальный фильтр запросов

У EF Core есть глобальные фильтры запросов (global query filters), и кажется, что !IsAdult удобно повесить прямо на сущность. Но BlogDbContext используется не только API, но и самим блогом, где правила показа другие. Глобальный фильтр изменил бы поведение сайта. Политика же живёт на уровне API и касается только мобильного клиента.

Задел на платную версию

Сейчас у политики нет параметров: правило одно для всех. Добавлять «на будущее» параметр с пользователем или подпиской не нужно — это нарушило бы принцип YAGNI. Когда подписка появится, изменится реализация политики и, возможно, её сигнатура внутри сервера. Контракт API при этом останется прежним: тот же адрес, тот же DTO. Пользователь с подпиской просто начнёт получать больше записей.

Пагинация для бесконечной ленты

Лента «Музея юмора» — бесконечная прокрутка: пользователь листает вниз, приложение подгружает следующую порцию. Разберём, как правильно отдавать такие порции.

Плохо: пагинация по смещению

Постраничная выборка по смещению (offset pagination) — первое, что приходит в голову:

var items = await db.Humors.AsNoTracking()
    .Where(policy.CanShow)
    .OrderByDescending(x => x.CreatedAt)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .Select(x => new HumorDto(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))
    .ToListAsync(ct);

Для таблицы на сайте с номерами страниц это нормально. Для ленты — нет, и вот почему.

Новая запись сдвигает смещение, и пользователь видит одну и ту же шутку дважды. Курсор от этого не зависит

Пользователь загрузил первую страницу. Пока он её читал, в блоге опубликовали новую шутку. Она встала в начало ленты и сдвинула все записи на одну позицию. Вторая страница начинается теперь с записи, которая уже была последней на первой — пользователь видит дубль. Если запись удалили, происходит обратное: одна запись пропадает, и пользователь её никогда не увидит.

Вторая проблема — производительность. Чтобы пропустить 10 000 строк, базе данных всё равно приходится их прочитать. Чем дальше пользователь листает, тем медленнее работает запрос.

Правильно: пагинация по ключу

Пагинация по ключу (keyset pagination) запоминает не номер страницы, а последнюю показанную запись. Следующая страница — это записи «после неё»:

var query = db.Humors.AsNoTracking().Where(policy.CanShow);

if (cursor is { } c)
{
    query = query.Where(x =>
        x.CreatedAt < c.CreatedAt ||
        (x.CreatedAt == c.CreatedAt && x.Id.CompareTo(c.Id) < 0));
}

var rows = await query
    .OrderByDescending(x => x.CreatedAt)
    .ThenByDescending(x => x.Id)
    .Take(pageSize + 1)
    .Select(x => new HumorDto(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))
    .ToListAsync(ct);

Несколько деталей, которые здесь важны.

  • Уникальный порядок сортировки. Сортировка только по CreatedAt не уникальна: две записи могут иметь одинаковое время. Поэтому добавляется второй ключ — Id. Без него записи на границе страниц могут теряться.
  • CompareTo вместо <. В C# для Guid не определён оператор <, поэтому используется CompareTo. EF Core переводит выражение a.CompareTo(b) < 0 в обычное сравнение a < b в SQL. Порядок Guid в базе данных может отличаться от порядка в .NET, но это не важно: и сортировка, и сравнение выполняются на стороне базы данных, поэтому они согласованы.
  • pageSize + 1. Лишняя запись нужна только для того, чтобы узнать, есть ли следующая страница. Клиенту она не отдаётся.
  • Индекс. Нужен составной индекс по (CreatedAt DESC, Id DESC). С ним запрос одинаково быстрый и на первой странице, и на тысячной.

Непрозрачный курсор

Клиенту не нужно знать, из чего состоит курсор. Если отдать ему createdAt и id отдельными параметрами, вы привяжете контракт API к деталям реализации пагинации. Правильнее упаковать эти значения в непрозрачную строку (opaque cursor):

internal readonly record struct FeedCursor(DateTime CreatedAt, Guid Id)
{
    public string Encode()
    {
        Span<byte> buffer = stackalloc byte[24];
        BinaryPrimitives.WriteInt64LittleEndian(buffer, CreatedAt.Ticks);
        Id.TryWriteBytes(buffer[8..]);
        return Base64Url.EncodeToString(buffer);
    }

    public static bool TryDecode(string? value, out FeedCursor cursor)
    {
        cursor = default;
        Span<byte> buffer = stackalloc byte[24];
        if (string.IsNullOrEmpty(value)
            || !Base64Url.TryDecodeFromChars(value, buffer, out var written)
            || written != 24)
        {
            return false;
        }

        var ticks = BinaryPrimitives.ReadInt64LittleEndian(buffer);
        if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks)
        {
            return false;
        }

        cursor = new FeedCursor(new DateTime(ticks, DateTimeKind.Utc), new Guid(buffer[8..]));
        return true;
    }
}

Base64Url — встроенный класс из System.Buffers.Text, который появился в .NET 9: результат можно безопасно передавать в адресе без дополнительного кодирования. Курсор приходит от клиента, поэтому декодер проверяет всё: длину, корректность Base64, допустимый диапазон даты. Если курсор испорчен, API отвечает ошибкой 400 с понятным кодом — об этом в разделе «Ошибки: единый формат ответа».

Ответ ленты выглядит так:

Первый запрос — полный ответ с ETag. Повторный — 304 без тела. Синхронизация — только изменения и удаления

ETag и ответ 304 Not Modified

ETag — это метка версии ресурса. Сервер отдаёт её в заголовке ответа, клиент сохраняет и при следующем запросе присылает в заголовке If-None-Match. Если версия не изменилась, сервер отвечает 304 Not Modified без тела.

Для одной записи метку удобно строить из UpdatedAt: запись изменилась — изменилась и метка.

public static async Task<IResult> GetByIdAsync(
    Guid id, HttpContext context, HumorQueries queries, CancellationToken ct)
{
    var operation = await queries.GetByIdAsync(id, ct);
    if (!operation.Ok)
    {
        return operation.Error.ToProblem();
    }

    var etag = new EntityTagHeaderValue($"\"{operation.Result.UpdatedAt.Ticks:x}\"");
    context.Response.GetTypedHeaders().ETag = etag;

    var ifNoneMatch = context.Request.GetTypedHeaders().IfNoneMatch;
    if (ifNoneMatch.Any(x => x.Compare(etag, useStrongComparison: false)))
    {
        return TypedResults.StatusCode(StatusCodes.Status304NotModified);
    }

    return TypedResults.Ok(operation.Result);
}

Обратите внимание: здесь база данных всё равно получает запрос — нужно же узнать UpdatedAt. ETag экономит трафик пользователя, но не нагрузку на сервер.

Кэш вывода

Чтобы не ходить в базу данных за каждым одинаковым запросом, в ASP.NET Core есть кэш вывода (output caching). Он хранит готовые ответы на сервере и умеет сам отвечать 304, если в закэшированном ответе есть заголовок ETag, а клиент прислал совпадающий If-None-Match.

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("humor", policy => policy
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("cursor", "pageSize")
        .Tag("humor"));
});

app.UseOutputCache();

v1.MapGet("/", HumorEndpoints.GetFeedAsync).CacheOutput("humor");

Когда в блоге публикуют или редактируют шутку, кэш сбрасывается по тегу:

await outputCacheStore.EvictByTagAsync("humor", ct);

Здесь есть одна ловушка, о которой стоит знать заранее. Ключ кэша сейчас не зависит от пользователя, и это правильно: политика контента одна для всех. Когда появится платная версия, ответы для пользователей с подпиской и без неё будут разными. Если не учесть это в ключе кэша, подписчик получит закэшированный ответ без контента 18+, а хуже того — пользователь без подписки получит ответ подписчика. По умолчанию кэш вывода не кэширует запросы с заголовком Authorization, и это спасёт в простом случае, но решение придётся принимать явно.

Дельта-синхронизация

Лента с курсором подходит для просмотра. Но приложению с офлайн-режимом нужно другое: держать локальную копию в актуальном состоянии и скачивать только то, что изменилось. Для этого делается отдельный эндпоинт синхронизации:

public sealed record ChangesDto(
    IReadOnlyList<HumorDto> Upserted,
    IReadOnlyList<Guid> Deleted,
    string NextToken,
    bool HasMore);

Клиент присылает токен, полученный в прошлый раз, а сервер возвращает:

  • Upserted — записи, которые появились или изменились;
  • Deleted — идентификаторы записей, которые клиент должен удалить у себя;
  • NextToken — токен для следующего запроса;
  • HasMore — признак того, что изменений больше одной порции и нужно запросить ещё.

Список Deleted — это надгробия (tombstones). Без них офлайн-кэш навсегда сохранил бы записи, которых на сервере уже нет. Для этого записи нельзя удалять из базы физически: нужно мягкое удаление (soft delete), например поле DeletedAt, а при удалении обязательно обновлять UpdatedAt. Политика контента дополняется условием x.DeletedAt == null.

Самое интересное — что считать удалением. Запись могла не только удалиться, но и перестать проходить политику: её сняли с публикации или пометили как 18+. Для бесплатного приложения она тоже «удалена». Здесь и пригодится метод IsShown из политики:

var rows = await db.Humors.AsNoTracking()
    .Where(x => x.UpdatedAt > since.UpdatedAt ||
               (x.UpdatedAt == since.UpdatedAt && x.Id.CompareTo(since.Id) > 0))
    .OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id)
    .Take(limit + 1)
    .ToListAsync(ct);

var page = rows.Take(limit).ToList();

var upserted = page.Where(policy.IsShown)
    .Select(x => new HumorDto(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))
    .ToList();

var deleted = page.Where(x => !policy.IsShown(x)).Select(x => x.Id).ToList();

Обратите внимание: в запросе к базе нет фильтра по политике. Нужны все изменившиеся записи, в том числе те, что перестали её проходить. Отдаём же мы только идентификаторы таких записей, а не их содержимое.

Токен синхронизации устроен так же, как курсор ленты: пара (UpdatedAt, Id) последней обработанной записи, упакованная в непрозрачную строку. Ключевой момент — токен выдаёт сервер. Клиент не присылает «дай всё, что изменилось после такого-то времени», потому что время на устройстве может быть любым (помните условие «Часы устройства врут» из раздела «Условия, в которых работает мобильный клиент»).

Если со временем вы начнёте физически удалять старые надгробия, например через полгода, токены старше этого срока станут недействительными: сервер уже не знает, что было удалено. В этом случае API отвечает 410 Gone с кодом sync.token-expired, и приложение загружает данные заново (подробнее в разделе «Ошибки: единый формат ответа»).

Клиент применяет изменения как вставку или обновление по Id (upsert). Такая операция идемпотентна: если ответ потерялся и клиент повторил запрос с тем же токеном, он получит те же изменения и применит их ещё раз без вреда.

Честное ограничение этого подхода: если две транзакции пишут почти одновременно и фиксируются не в порядке своих UpdatedAt, запись с более ранним временем может зафиксироваться уже после того, как клиент синхронизировался дальше. Для блога, где записи редактирует один автор, это практически исключено. Для систем с интенсивной записью нужен монотонный номер изменения или небольшое перекрытие окна синхронизации — благодаря идемпотентности повторы безопасны.

Ошибки: единый формат ответа

Мобильному клиенту мало узнать, что произошла ошибка. Ему нужно понять, что делать дальше: показать сообщение, повторить запрос, начать синхронизацию заново или попросить обновить приложение. Для этого ошибки должны приходить в одном формате и с машиночитаемым кодом.

Плохо: ошибки в произвольной форме

if (item is null)
{
    return Results.NotFound("Запись не найдена");
}
if (!FeedCursor.TryDecode(cursor, out var c))
{
    return Results.BadRequest(new { error = "bad cursor" });
}

В одном месте строка, в другом — объект с полем error, в третьем исключение превратится в HTML-страницу. Клиенту придётся разбирать каждый случай отдельно. А текст «Запись не найдена» нельзя использовать в логике: его поменяют, и проверка в приложении сломается.

Правильно: ProblemDetails и Result Pattern

Для описания ошибок HTTP API есть стандарт — RFC 9457 «Problem Details for HTTP APIs» (он заменил RFC 7807). ASP.NET Core поддерживает его из коробки. Осталось связать его с бизнес-логикой. Для этого используем паттерн «Результат» (Result Pattern) из пакета Calabonga.Results.

Сервис возвращает Operation: успех превращается в 200 с DTO, ошибка — в ProblemDetails с кодом

Сначала описываем ошибки как данные, с машиночитаемым кодом:

public sealed record ApiError(string Code, string Title, int Status);

public static class HumorErrors
{
    public static readonly ApiError NotFound =
        new("humor.not-found", "Запись не найдена", StatusCodes.Status404NotFound);

    public static readonly ApiError InvalidCursor =
        new("paging.invalid-cursor", "Некорректный курсор страницы", StatusCodes.Status400BadRequest);

    public static readonly ApiError SyncTokenExpired =
        new("sync.token-expired", "Токен синхронизации устарел", StatusCodes.Status410Gone);
}

Сервис чтения данных возвращает Operation<T, ApiError> и ничего не знает про HTTP:

public sealed class HumorQueries(
    IDbContextFactory<BlogDbContext> factory,
    IHumorContentPolicy policy)
{
    public async Task<Operation<HumorDto, ApiError>> GetByIdAsync(Guid id, CancellationToken ct)
    {
        await using var db = await factory.CreateDbContextAsync(ct);

        var item = await db.Humors.AsNoTracking()
            .Where(policy.CanShow)
            .Where(x => x.Id == id)
            .Select(x => new HumorDto(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))
            .FirstOrDefaultAsync(ct);

        if (item is null)
        {
            return Operation.Error(HumorErrors.NotFound);
        }

        return item;
    }
}

IDbContextFactory создаёт короткоживущий контекст на одну операцию, а await using гарантирует его освобождение. Operation.Error(...) и неявное преобразование из HumorDto позволяют возвращать и ошибку, и результат без лишних обёрток.

Перевод в HTTP — одно место на весь API:

public static class OperationHttpExtensions
{
    public static IResult ToHttpResult<T>(this Operation<T, ApiError> operation)
        => operation.Ok
            ? TypedResults.Ok(operation.Result)
            : operation.Error.ToProblem();

    public static IResult ToProblem(this ApiError error)
        => TypedResults.Problem(
            title: error.Title,
            statusCode: error.Status,
            type: $"https://www.calabonga.net/api/problems/{error.Code}",
            extensions: new Dictionary<string, object?> { ["code"] = error.Code });
}

И общая настройка, чтобы в том же формате приходили и непредвиденные ошибки:

builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = ctx =>
        ctx.ProblemDetails.Extensions.TryAdd("traceId", ctx.HttpContext.TraceIdentifier));

app.UseExceptionHandler();
app.UseStatusCodePages();

Клиент получает ответ вида:

{
  "type": "https://www.calabonga.net/api/problems/sync.token-expired",
  "title": "Токен синхронизации устарел",
  "status": 410,
  "code": "sync.token-expired",
  "traceId": "0HN7…"
}

Приложение смотрит на поле code, а не на текст. По sync.token-expired оно сбрасывает локальный кэш и загружает всё заново. По paging.invalid-cursor начинает ленту сначала. Текст для пользователя приложение берёт из своих ресурсов, на нужном языке. А traceId пользователь может прислать в поддержку, и вы найдёте этот запрос в логах.

Защита публичного API

API «Музея юмора» открыт для чтения без авторизации. Защищать здесь нужно не данные (они и так публичные), а сервер — от перегрузки и злоупотреблений.

Плохо: «секретный» ключ в приложении

// В приложении
client.DefaultRequestHeaders.Add("X-Api-Key", "f3a9c1...");

Это не защита. APK — это архив, и строки из него, включая сборки .NET внутри, достаются известными инструментами за несколько минут. Ключ, который знает каждая установленная копия приложения, — не секрет. Хуже того, он создаёт ложное чувство защищённости. Правило простое: в мобильном приложении не должно быть ничего, что вы не готовы опубликовать.

Правильно: ограничение частоты запросов

Ограничение частоты запросов (rate limiting) встроено в ASP.NET Core:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("mobile-public", context =>
        RateLimitPartition.GetTokenBucketLimiter(
            partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 300,
                TokensPerPeriod = 100,
                ReplenishmentPeriod = TimeSpan.FromSeconds(10),
                QueueLimit = 0,
                AutoReplenishment = true
            }));
});

app.UseRateLimiter();
v1.RequireRateLimiting("mobile-public");

Здесь есть особенность, характерная именно для мобильных клиентов. Операторы связи используют трансляцию адресов операторского уровня (Carrier-Grade NAT, CGNAT): тысячи абонентов выходят в интернет с одного IP-адреса. Если ограничить, скажем, 60 запросов в минуту на IP, вы заблокируете не злоумышленника, а всех пользователей одного оператора в одном районе. Поэтому лимиты на IP для мобильного API должны быть заметно щедрее, чем для сайта, а алгоритм «корзина токенов» (token bucket) лучше фиксированного окна: он допускает короткие всплески, например при запуске приложения.

Основная защита от нагрузки — всё-таки кэш вывода из раздела «Кэширование и синхронизация». Если большинство ответов отдаётся из кэша, даже большой поток запросов почти не доходит до базы данных.

Ещё одна деталь: если API работает за обратным прокси (nginx, балансировщик), RemoteIpAddress будет адресом прокси, и все пользователи попадут в одну корзину. Настройте UseForwardedHeaders с явным списком доверенных прокси.

HTTPS и поверхность атаки

  • Только HTTPS. Приложениям, собранным для Android 9 и выше, открытый HTTP-трафик по умолчанию запрещён, так что это требование и самой платформы.
  • Только то, что нужно. У мобильного API нет эндпоинтов записи. Административные эндпоинты блога не должны попадать в ту же группу маршрутов и тем более быть доступными без авторизации.
  • Закрепление сертификата (certificate pinning) для публичного API только для чтения обычно избыточно. При смене сертификата оно может сделать неработоспособными все установленные копии приложения, а мы помним, как медленно они обновляются.

Наблюдаемость (observability)

Последний вопрос: как понять, что старую версию API можно выключать? Ответ дают метрики с разбивкой по версиям приложения.

Приложение передаёт свою версию в заголовке, например X-App-Version: 1.2.0. На сервере эту версию добавляем тегом к встроенной метрике ASP.NET Core http.server.request.duration. Для этого есть IHttpMetricsTagsFeature:

app.Use(async (context, next) =>
{
    var appVersion = AppVersionTag.From(context.Request.Headers["X-App-Version"]);

    context.Features.Get<IHttpMetricsTagsFeature>()?.Tags.Add(new("app.version", appVersion));

    using (app.Logger.BeginScope(new Dictionary<string, object> { ["AppVersion"] = appVersion }))
    {
        await next(context);
    }
});

static class AppVersionTag
{
    public static string From(StringValues header)
        => Version.TryParse(header.ToString(), out var v) ? $"{v.Major}.{v.Minor}" : "unknown";
}

Здесь важны две детали.

  • Ограничение количества значений (cardinality). Значение тега берётся от клиента, а значит, туда может прийти что угодно. Если записывать его как есть, каждая уникальная строка создаст новый временной ряд в системе мониторинга. Поэтому версия нормализуется до вида мажорная.минорная, а всё нераспознанное становится unknown.
  • Проверка на null. IHttpMetricsTagsFeature есть в контексте, только если метрику кто-то собирает. Без проверки приложение упадёт в окружении без мониторинга.

Если метрики экспортируются в Prometheus через OpenTelemetry, распределение запросов по версиям даёт такой запрос:

sum by (app_version) (
  rate(http_server_request_duration_seconds_count{http_route=~"/api/.*"}[1h])
)

В теге http_route записывается шаблон маршрута, а не реальный адрес. Версия API в нашем шаблоне — параметр {version:apiVersion}, поэтому по http_route версии v1 и v2 не различить. Если нужна разбивка и по версии API, добавьте второй тег тем же способом, что и app.version.

Порядок вывода версии из эксплуатации получается такой:

  1. Выпустить версию приложения, которая работает с API v2.
  2. Объявить API v1 устаревшим: отдавать заголовки Deprecation (RFC 9745) и Sunset (RFC 8594) с датой отключения.
  3. Следить по метрикам, как падает доля запросов от старых версий.
  4. Поднять MinSupportedVersion в конфигурации — старые приложения покажут экран обновления.
  5. Выключить v1, когда трафик на неё станет пренебрежимо малым.

Метрики здесь работают как обратная связь: вы принимаете решение по данным, а не по ощущениям.

Заключение

API для мобильного клиента отличается от API для сайта не технологиями, а условиями. Отсюда и решения. Короткий чек-лист:

  • клиенту отдаётся DTO, а не сущность, и контракт зафиксирован в OpenAPI;
  • в рамках версии API только добавляются поля и эндпоинты, всё остальное — новая версия;
  • с первой версии приложения есть эндпоинт с минимальной поддерживаемой версией;
  • что показывать, решает политика на сервере, и она применяется ко всем эндпоинтам, включая запрос по идентификатору;
  • лента построена на пагинации по ключу с непрозрачным курсором и составным индексом;
  • ETag экономит трафик, кэш вывода — запросы к базе данных, а при появлении подписки ключ кэша учитывает права пользователя;
  • синхронизация отдаёт изменения и надгробия по токену, выданному сервером, а не по времени устройства;
  • все ошибки приходят в формате ProblemDetails с машиночитаемым кодом;
  • в приложении нет секретов, лимиты запросов учитывают CGNAT;
  • метрики размечены версией приложения, и решение о выключении старой версии API принимается по данным.

В следующей статье — клиентская часть: MAUI-приложение «Музей юмора», которое работает с этим API, хранит ленту офлайн и правильно обрабатывает 304 и коды ошибок.

Использованные пакеты

  • Asp.Versioning.Http 10.0.0 — версионирование Minimal API.
  • Asp.Versioning.Mvc.ApiExplorer 10.0.0 — интеграция версионирования с описанием API.
  • Calabonga.Results 2.0.1 — паттерн «Результат» (Operation<T, TError>, пространство имён Calabonga.OperationResults).
  • Microsoft.AspNetCore.OpenApi 10.0.x — генерация документа OpenAPI.
  • Microsoft.EntityFrameworkCore 10.0.x — доступ к данным через IDbContextFactory.
  • Встроенные возможности ASP.NET Core 10: ProblemDetails, кэш вывода (output caching), ограничение частоты запросов (rate limiting), метрики с IHttpMetricsTagsFeature.

Ссылки

Мои видео

Boosty.to | YouTube | Yandex.Дзен | RuTube | VK Video

Больше видео на boosty Calabonga, а если хотите тоже получать донаты, тогда заходите и регистрируйте свою страницу на boosty. Я использую хостинг reg.ru, Подключайся с промокодом 9A17-953A-8591-CF98 чтобы получить скидку 5%

Комментарии к статье ()

Загрузка...

Что-то пошло не по сценарию и завершилось ошибкой. Перезагрузить страницу (F5) 🗙

Переподключаем сервер...

Переподключение сломалось... Пробуем еще раз сек.

Не получилось переподключится.
Пожалуйста, обновите страницу F5

Сессия была поставлена сервером на паузу.

Восстановить сессию не получилось.
Пожалуйста, обновите страницу F5.