Minimal API

Scalar у Minimal API: повний проєкт і Fluent OpenAPI

Покроковий проєкт ASP.NET Core Minimal API з Scalar, OpenAPI, повним Fluent-описом endpoint-ів без атрибутів, transformers, Route Groups і детальним розбором усіх варіантів metadata.

Scalar у Minimal API: повний проєкт і Fluent OpenAPI

.NET 9+ Recommended
У попередніх розділах ми вчилися будувати Minimal API, групувати маршрути, організовувати проєкт і проектувати HTTP-контракти. Але в реальному житті цього недостатньо. Команда бекенду не може просто сказати фронтенду: «дивись у Program.cs». Потрібна жива документація, яка будується з реального коду, оновлюється разом із ним і дозволяє одразу тестувати endpoint-и. Саме тут у гру входить Scalar.У цьому матеріалі ми не будемо використовувати атрибути на кшталт [Produces], [Tags], [EndpointSummary] або [Description]. Усе, що стосується документації, ми зробимо через Fluent API: WithSummary(), Produces(), Accepts(), WithOpenApi(), MapGroup(), AddOpenApi(...) transformers і MapScalarApiReference(...).

Що побудуємо

  • Minimal API проєкт, який можна створити з нуля через dotnet new web
  • OpenAPI-документ на маршруті /openapi/v1.json
  • Scalar UI на маршруті /scalar
  • Групу endpoint-ів /api/products з повним Fluent-описом metadata

Що розберемо

  • усі основні Fluent-методи опису endpoint-ів;
  • різницю між локальним, груповим і глобальним рівнем metadata;
  • WithOpenApi(...) для тонкого налаштування операції;
  • OpenAPI transformers для параметрів, схем і документа в цілому;
  • конфігурацію самого Scalar через MapScalarApiReference(...).

Результат наприкінці

  • у браузері відкривається https://localhost:{port}/scalar;
  • у Scalar видно summaries, descriptions, tags, operation id, request/response schemas;
  • документація відображає реальні статус-коди, тіла запитів і помилок;
  • все зібрано без атрибутів.
Стаття свідомо фокусується на Minimal API + OpenAPI + Scalar. Якщо вам потрібна окрема теорія про сам стандарт OpenAPI, дивіться матеріал про OpenAPI як контракт.

1. Що таке Scalar і чому не достатньо просто openapi.json

Коли ви вмикаєте OpenAPI в ASP.NET Core, ви отримуєте машиночитаний документ. Це чудово для генераторів клієнтів, тестів і контрактних перевірок. Але для людини сирий JSON на кшталт /openapi/v1.json незручний:

  • його важко читати очима;
  • ним незручно ділитися з QA або фронтендом;
  • з нього не дуже зручно робити ручні пробні запити;
  • він не дає якісного UX документації.

Scalar вирішує саме цю проблему. Він бере OpenAPI-документ і рендерить з нього інтерактивний reference UI.

Loading diagram...
flowchart LR
    A["Minimal API code"] --> B["OpenAPI generator"]
    B --> C["/openapi/v1.json"]
    C --> D["Scalar UI /scalar"]
    D --> E["Frontend / QA / Backend / Partners"]

Ключова думка: Scalar не вигадує контракт сам. Він лише красиво і зручно показує той OpenAPI-документ, який ви згенерували з коду або з контракту.

Якщо metadata на endpoint-ах бідна або неточна, Scalar не «врятує» документацію. Він чесно покаже саме те, що ви задали. Тому основна робота відбувається не в самому Scalar, а в тому, як ви описали endpoint-и.

2. Який стек ми беремо для відтворюваного проєкту

У 2026 році для нового навчального прикладу найрозумніший стартовий варіант такий:

.NET SDK
string required
Рекомендовано .NET 9 або новіше. Саме з .NET 9 у ASP.NET Core є вбудована підтримка AddOpenApi() і MapOpenApi().
OpenAPI generator
package required
Microsoft.AspNetCore.OpenApi для генерації OpenAPI-документа з Minimal API.
Scalar UI
package required
Scalar.AspNetCore для рендерингу документації на маршруті /scalar.
Style of metadata
string required
Тільки Fluent API. Без атрибутів на handler-ах і DTO.
Для нового проєкту Minimal API + AddOpenApi + Scalar.AspNetCore дає чистіший стек, ніж старий маршрут через SwaggerGen як основну інтеграцію. Це не означає, що Swashbuckle поганий; це означає, що для навчального прикладу з акцентом на сучасний ASP.NET Core простіше починати з вбудованого OpenAPI API.

Сумісність: рекомендований і legacy-шлях

Використовуємо:

  • builder.Services.AddOpenApi()
  • app.MapOpenApi()
  • app.MapScalarApiReference()

Це і є основний шлях у цій статті.

Нижче ми будуємо один конкретний відтворюваний проєкт. Якщо ви вирішите повторювати його покроково, не змішуйте .NET 9+ підхід з legacy-конфігурацією для .NET 8 в одному Program.cs.

3. Що саме побачить читач у кінці

Ми збудуємо невеликий каталог продуктів із такими endpoint-ами:

МетодМаршрутНавіщо потрібен
GET/api/productsсписок товарів з query-параметрами
GET/api/products/{id}читання одного товару
POST/api/productsстворення товару
PUT/api/products/{id}оновлення товару
DELETE/api/products/{id}видалення товару
GET/_internal/pingтехнічний маршрут, який ми сховаємо з документації

У документації ми покажемо:

  • summary;
  • description;
  • tags;
  • operationId;
  • requestBody;
  • query і route parameters;
  • 200, 201, 204, 404, 422, 500;
  • моделі запитів і відповідей;
  • кастомізацію OpenAPI-операцій через WithOpenApi(...);
  • глобальні доповнення через transformers.
Loading diagram...
@startuml
skinparam style plain

actor Developer
participant Browser
participant "Scalar UI" as Scalar
participant "OpenAPI Endpoint" as OpenApi
participant "Minimal API Endpoints" as Api

Developer -> Browser : Відкриває /scalar
Browser -> Scalar : GET /scalar
Scalar -> OpenApi : GET /openapi/v1.json
OpenApi --> Scalar : OpenAPI document
Developer -> Scalar : Натискає Try It
Scalar -> Api : HTTP request до /api/products
Api --> Scalar : JSON response
Scalar --> Browser : Рендер документації + результат запиту

@enduml

4. Створюємо проєкт з нуля

Крок 1: Генерація шаблону

mkdir MinimalApiScalarDemo
cd MinimalApiScalarDemo
dotnet new web --framework net9.0

Крок 2: Додаємо пакети

dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore

Крок 3: Перший запуск для перевірки бази

dotnet run
$ dotnet run
Building...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7234
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.

Поки що ми не побачимо ні Scalar, ні OpenAPI, бо ще нічого не підключали. Але важливо перевірити, що сам проєкт стартує.

Під час повторення прикладу працюйте через dotnet watch. Натискаєте Ctrl + S у редакторі, і сервер сам перезапускається. Це критично знижує тертя, коли ви налаштовуєте metadata endpoint-ів і одразу дивитесь результат у /scalar.

Увесь подальший сенс статті зводиться до одного критерію: після кожної зміни ви можете відкрити /scalar і побачити, як змінився контракт.


5. Структура проєкту

Ми не будемо складати всю логіку в один Program.cs. Для теми документації це погана ідея, бо змішаються:

  • маршрути;
  • бізнес-логіка;
  • DTO;
  • OpenAPI-конфігурація;
  • Scalar-конфігурація.

Побудуємо ось таку структуру:

Чому ця структура важлива

У темі документації багато хто робить помилку: кидає всі MapGet/MapPost у Program.cs, а потім не може зрозуміти, де закінчується логіка API і де починається логіка опису документації.

Тут ми розвели відповідальність:

  • Program.cs відповідає за композицію;
  • ProductEndpoints.cs відповідає за маршрути і metadata;
  • ProductContracts.cs відповідає за схеми даних;
  • ProductStore.cs відповідає за технічне сховище даних;
  • launchSettings.json відповідає за зручний запуск одразу в Scalar.

6. Детально розбираємо Program.cs

6.1. AddOpenApi(...)

Рядок:

builder.Services.AddOpenApi(...)

реєструє генератор OpenAPI-документа. Це не UI. Це саме механізм, який:

  • збирає metadata з endpoint-ів;
  • виводить моделі запитів/відповідей;
  • будує OpenAPI JSON;
  • дозволяє підключати transformers.

6.2. Document transformer

У AddDocumentTransformer(...) ми задаємо інформацію верхнього рівня:

  • Title;
  • Version;
  • Description;
  • Servers.

Це рівень усього документа. Тут немає сенсу описувати окремі endpoint-и. Тут живе все, що стосується API в цілому.

6.3. Schema transformer

AddSchemaTransformer(...) працює на рівні схем. У нашому прикладі він модифікує всі decimal-поля, щоб схема явно показувала format: decimal.

Це важливо з педагогічної точки зору. У великих системах команди часто мають доменні правила:

  • decimal для грошей;
  • усі DateTimeOffset з UTC-семантикою;
  • певні nullable-поля мають спеціальну бізнес-інтерпретацію.

Schema transformer дозволяє донести ці правила до документації без атрибутів на кожному DTO.

6.4. Operation transformer

AddOperationTransformer(...) ми використовуємо для глобального правила: кожна операція документально підтримує 500.

Це не означає, що ми навмисно хочемо повертати 500. Це означає, що ми хочемо, щоб документація не брехала: будь-яке реальне серверне API має сценарій неочікуваної помилки, і споживач повинен бачити це в контракті.

6.5. MapOpenApi(...)

Рядок:

app.MapOpenApi("/openapi/{documentName}.json")

реєструє endpoint, який віддає OpenAPI-документ. Це важливий нюанс: OpenAPI JSON у ASP.NET Core теж є звичайним endpoint-ом, а не якоюсь магічною окремою системою.

Тобто до нього можна застосовувати звичні endpoint-конвенції. Наприклад:

app.MapOpenApi("/openapi/{documentName}.json")
   .AllowAnonymous();

6.6. MapScalarApiReference(...)

Саме цей рядок дає UI:

app.MapScalarApiReference("/scalar", options => ...)

Він каже: «створи сторінку документації на маршруті /scalar і під'єднай її до OpenAPI-документа».


7. Fluent API для Scalar: всі ключові варіанти підключення

MapScalarApiReference(...) має кілька корисних overload-ів. Це важливо, бо в реальних проєктах вам не завжди підходить один і той самий маршрут або однакова конфігурація для всіх користувачів.

Найкорисніші Fluent-методи конфігурації Scalar

WithTitle(...)
method
Задає заголовок API Reference.
WithOpenApiRoutePattern(...)
method
Каже Scalar, де шукати OpenAPI-документ. У нашому випадку це /openapi/{documentName}.json.
ShowOperationId()
method
Відображає operationId у UI. Дуже корисно, якщо ви свідомо задаєте WithName(...) на endpoint-ах.
ExpandAllTags()
method
Розгортає всі секції tags у лівому меню.
SortTagsAlphabetically()
method
Сортує теги, що корисно у великих API.
SortOperationsByMethod()
method
Сортує операції всередині тега за HTTP-методом.
PreserveSchemaPropertyOrder()
method
Зберігає порядок властивостей моделі. Корисно для читабельності DTO.
WithDefaultHttpClient(...)
method
Визначає клієнт за замовчуванням для code samples, наприклад C# + HttpClient.

8. Усі основні Fluent API-варіанти опису endpoint-ів

Тепер переходимо до найважливішого. Коли кажуть «опис endpoint-а», на практиці мають на увазі кілька різних шарів metadata. У Minimal API без атрибутів це робиться не одним методом, а цілим набором fluent-конвенцій.

Працює на одному конкретному маршруті:

  • WithName(...)
  • WithSummary(...)
  • WithDescription(...)
  • WithTags(...)
  • Accepts<T>(...)
  • Produces<T>(...)
  • Produces(...)
  • ProducesProblem(...)
  • ProducesValidationProblem(...)
  • ExcludeFromDescription()
  • WithOpenApi(...)

8.1. WithName(...)

.WithName("GetProductById")

Це не просто «красиве ім'я». У Minimal API ім'я endpoint-а:

  • використовується для link generation;
  • часто стає operationId в OpenAPI;
  • покращує читабельність у Scalar, якщо ви вмикаєте ShowOperationId().

8.2. WithSummary(...)

.WithSummary("Отримати товар за ID")

Це короткий заголовок операції. Саме summary найчастіше відображається в списку операцій у Scalar.

8.3. WithDescription(...)

.WithDescription("Повертає розширену модель одного товару.")

Description має бути довшим, ніж summary. Це місце для контексту:

  • бізнес-сенс операції;
  • важливі обмеження;
  • пояснення edge cases;
  • уточнення щодо route/query/body semantics.

8.4. WithTags(...)

.WithTags("Products")

Tags групують операції в документації. Якщо ви забудете про теги, великий API швидко перетвориться на нечитабельний список із сотні маршрутів.

8.5. Accepts<T>(...)

.Accepts<CreateProductRequest>("application/json")

Цей метод формалізує requestBody. Він потрібен не завжди, бо частину речей generator може вивести сам з сигнатури handler-а, але:

  • він корисний для явності;
  • він корисний при нестандартних content-type;
  • він підкреслює намір прямо в fluent-конфігурації.

8.6. Produces<T>(...)

.Produces<ProductDetailsResponse>(StatusCodes.Status201Created)

Це основний спосіб явно задати тіло успішної відповіді.

8.7. Produces(...)

.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)

Коли тіло відповіді не потрібне або неважливе, достатньо вказати статус-код.

8.8. ProducesProblem(...)

.ProducesProblem(StatusCodes.Status500InternalServerError)

Цей метод показує, що у відповіді використовується ProblemDetails-стиль для помилок.

8.9. ProducesValidationProblem(...)

.ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)

Дуже корисно для endpoint-ів, які приймають тіло запиту й можуть повертати помилки валідації.

8.10. ExcludeFromDescription()

app.MapGet("/_internal/ping", ...)
   .ExcludeFromDescription();

Це варіант для внутрішніх, службових або технічних маршрутів, які не повинні засмічувати публічну документацію.

8.11. WithOpenApi(...)

Це найпотужніший локальний інструмент. Він дозволяє взяти вже згенеровану OpenAPI-операцію і допрацювати її вручну:

  • описати параметри;
  • помітити endpoint як deprecated;
  • додати приклади;
  • підправити summary/description;
  • встановити кастомні responses, extensions тощо.
.WithOpenApi(operation =>
{
    var id = operation.Parameters.First(p => p.Name == "id");
    id.Description = "GUID ідентифікатор товару.";
    return operation;
});
Саме WithOpenApi(...) і transformers рятують нас у сценарії «без атрибутів». У built-in metadata API немає окремого fluent-методу типу DescribeParameter("id", "..."), тому тонкий опис параметрів ми робимо або тут, або глобально через operation transformer.

9. Детально: як описувати endpoint-и без атрибутів

Тепер розберемо кожен наш маршрут як інженерний приклад.

9.1. GET /api/products

group.MapGet("/", GetProducts)
    .WithName("GetProducts")
    .WithSummary("Отримати список товарів")
    .WithDescription("Повертає каталог продуктів з optional query-фільтрами.")
    .Produces<IReadOnlyList<ProductListItemResponse>>(StatusCodes.Status200OK)
    .ProducesProblem(StatusCodes.Status500InternalServerError)
    .WithOpenApi(operation =>
    {
        var search = operation.Parameters.First(p => p.Name == "search");
        search.Description = "Пошук по фрагменту назви.";

        var includeInactive = operation.Parameters.First(p => p.Name == "includeInactive");
        includeInactive.Description = "Чи включати неактивні товари.";

        return operation;
    });

Що тут важливо:

  1. WithName() задає стабільний operationId.
  2. WithSummary() і WithDescription() дають короткий та довгий опис.
  3. Produces<T>() фіксує успішний payload.
  4. WithOpenApi(...) дозволяє описати query-параметри без атрибутів [Description].

9.2. GET /api/products/{id}

group.MapGet("/{id:guid}", GetProductById)
    .WithName("GetProductById")
    .WithSummary("Отримати товар за ID")
    .WithDescription("Повертає розширену модель одного товару.")
    .Produces<ProductDetailsResponse>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound)
    .ProducesProblem(StatusCodes.Status500InternalServerError)
    .WithOpenApi(operation =>
    {
        var id = operation.Parameters.First(p => p.Name == "id");
        id.Description = "GUID ідентифікатор товару.";
        return operation;
    });

Цей приклад демонструє route parameter + кілька статус-кодів.

9.3. POST /api/products

group.MapPost("/", CreateProduct)
    .WithName("CreateProduct")
    .WithSummary("Створити товар")
    .WithDescription("Створює новий товар у пам'яті та повертає його деталі.")
    .Accepts<CreateProductRequest>("application/json")
    .Produces<ProductDetailsResponse>(StatusCodes.Status201Created)
    .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)
    .ProducesProblem(StatusCodes.Status500InternalServerError)
    .WithOpenApi(operation =>
    {
        operation.RequestBody!.Description = "JSON payload для створення товару.";
        return operation;
    });

Тут ми бачимо вже повний набір для mutation-операції:

  • Accepts<T>() для тіла запиту;
  • Produces<T>(201) для успішного створення;
  • ProducesValidationProblem(422) для бізнес- або input-validation;
  • WithOpenApi(...) для опису request body.

9.4. PUT /api/products/{id}

Цей маршрут поєднує все одразу:

  • route parameter;
  • request body;
  • 200;
  • 404;
  • 422.

Це хороший «еталонний» endpoint для API-документації, бо він показує складніший сценарій, ніж простий GET.

9.5. DELETE /api/products/{id}

.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)

Тут навмисно нема Produces<T>(), бо DELETE у нас повертає 204 No Content. Це важливий дидактичний момент: не всі endpoint-и мають JSON-тіло у відповіді.


10. Де саме проходить межа між Route Metadata і OpenAPI customization

Це питання дуже часто плутають.

РівеньІнструментДля чого підходить
Route metadataWithSummary, Produces, Accepts, WithTagsОсновний контракт endpoint-а
Per-operation OpenAPI patchWithOpenApi(...)Точкове ручне доопрацювання конкретної операції
Global document customizationAddDocumentTransformer(...)Назва API, сервери, top-level поля
Global operation customizationAddOperationTransformer(...)Спільні відповіді, security, параметри, умовні правила
Global schema customizationAddSchemaTransformer(...)Опис або формат типів у моделях
Не використовуйте WithOpenApi(...) як місце, де ви описуєте все підряд. Якщо у вас 30 endpoint-ів і кожен отримує вручну по 20 мутацій OpenAPI-об'єкта, документація стає не менш хаотичною, ніж атрибути, від яких ви намагалися піти.

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

  • 80% metadata задаємо простими fluent-методами на endpoint-і;
  • 15% вирішуємо груповими конвенціями через MapGroup;
  • 5% добиваємо WithOpenApi(...) або transformers.

11. Fluent API на рівні Route Group

У нашому коді є ось цей рядок:

var group = app.MapGroup("/api/products")
    .WithTags("Products")
    .WithOpenApi();

Чому це добре:

  • префікс /api/products задано один раз;
  • теги задаються для всієї групи;
  • група одразу «видима» для OpenAPI як логічний блок;
  • далі конкретні endpoint-и дописують лише специфічні деталі.

Що ще часто ставлять на групу

RequireAuthorization()
method
Для всіх endpoint-ів групи одразу.
AddEndpointFilter(...)
method
Логування, валідація, multi-tenant checks, audit.
WithTags(...)
method
Категоризація в документації.
WithOpenApi()
method
Явне позначення, що група бере участь у генерації OpenAPI metadata.
Якщо ви описуєте API «по-фічово», MapGroup майже завжди є кращим стартом, ніж розрізнені app.MapGet(...) по всьому Program.cs. Це не лише питання чистоти коду, а й питання якості документації в Scalar.

12. Parameter descriptions без атрибутів: справжня складність

Саме тут більшість розробників і спотикається. Для summaries, descriptions, responses, tags і request bodies у нас є зручні fluent-методи. Але для опису окремих route/query/header parameter-ів окремого простого fluent-методу в Minimal API немає.

Тому в сценарії без атрибутів залишаються два реальні інструменти:

  1. WithOpenApi(...) на конкретному endpoint-і;
  2. AddOperationTransformer(...) глобально.

Локальний варіант через WithOpenApi(...)

Ми вже бачили його вище:

.WithOpenApi(operation =>
{
    var search = operation.Parameters.First(p => p.Name == "search");
    search.Description = "Пошук по фрагменту назви.";
    return operation;
});

Це найкраще, коли:

  • endpoint-ів небагато;
  • параметр специфічний лише для однієї операції;
  • ви хочете тримати опис прямо біля route definition.

Глобальний варіант через AddOperationTransformer(...)

Приклад:

builder.Services.AddOpenApi(options =>
{
    options.AddOperationTransformer((operation, context, cancellationToken) =>
    {
        if (context.Description.RelativePath == "api/products/{id}" &&
            context.Description.HttpMethod == "GET")
        {
            var id = operation.Parameters.FirstOrDefault(p => p.Name == "id");
            if (id is not null)
            {
                id.Description = "GUID ідентифікатор товару.";
            }
        }

        return Task.CompletedTask;
    });
});

Такий варіант кращий, коли:

  • ви хочете централізувати правила;
  • однакові параметри повторюються в багатьох endpoint-ах;
  • документація має спільні корпоративні норми.
Інженерно правильний висновок тут такий: «без атрибутів» можливо, але треба чесно розуміти, що parameter descriptions є однією з найменш зручних частин такого підходу. Fluent API виграє в читабельності маршруту, але програє в компактності для дрібних описів параметрів.

13. WithOpenApi(...): усі реальні сценарії, які варто знати

Нижче не просто теорія, а список сценаріїв, де WithOpenApi(...) реально виправданий.

Коли WithOpenApi(...) не потрібен

Не треба лізти в нього, якщо те саме можна виразити простіше через:

  • WithSummary(...);
  • WithDescription(...);
  • WithTags(...);
  • Accepts<T>(...);
  • Produces<T>(...);
  • ProducesProblem(...);
  • ProducesValidationProblem(...).

Принцип дуже простий: спочатку використовуйте найбільш декларативний fluent-метод, а лише потім переходьте до ручної мутації OpenAPI-операції.


14. Повний Program.cs ще раз, але як історія рішення

Program.cs
using Microsoft.OpenApi.Models;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<ProductStore>();
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        document.Info.Title = "Minimal API + Scalar Demo";
        document.Info.Version = "v1";
        document.Info.Description =
            "Навчальний проєкт для демонстрації Scalar " +
            "та Fluent OpenAPI metadata.";

        document.Servers = new List<OpenApiServer>
        {
            new() { Url = "https://localhost:7234", Description = "HTTPS local" },
            new() { Url = "http://localhost:5234", Description = "HTTP local" }
        };

        return Task.CompletedTask;
    });

    options.AddSchemaTransformer((schema, context, cancellationToken) =>
    {
        if (context.JsonTypeInfo.Type == typeof(decimal))
        {
            schema.Format = "decimal";
            schema.Description = string.IsNullOrWhiteSpace(schema.Description)
                ? "Decimal monetary value."
                : schema.Description + " Decimal monetary value.";
        }

        return Task.CompletedTask;
    });

    options.AddOperationTransformer((operation, context, cancellationToken) =>
    {
        operation.Responses.TryAdd("500", new OpenApiResponse
        {
            Description = "Unexpected server error"
        });

        return Task.CompletedTask;
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi("/openapi/{documentName}.json")
        .AllowAnonymous();

    app.MapScalarApiReference("/scalar", options =>
    {
        options.WithTitle("Minimal API Scalar Demo")
               .WithOpenApiRoutePattern("/openapi/{documentName}.json")
               .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
               .ShowOperationId()
               .ExpandAllTags()
               .SortTagsAlphabetically()
               .SortOperationsByMethod()
               .PreserveSchemaPropertyOrder();
    })
    .AllowAnonymous();
}

app.MapGet("/_internal/ping", () => TypedResults.Ok(new
{
    status = "ok",
    serverTime = DateTimeOffset.UtcNow
}))
.ExcludeFromDescription();

app.MapProductEndpoints();

app.Run();

Тепер уже можна читати цей файл як коротку історію:

  1. Реєструємо store.
  2. Реєструємо OpenAPI generator.
  3. Додаємо transformers.
  4. У development відкриваємо /openapi/... і /scalar.
  5. Ховаємо технічний endpoint із документації.
  6. Реєструємо продуктову фічу.

Саме так і має виглядати Program.cs, коли проєкт уже зріс вище «Hello World».


15. Як перевірити, що все справді працює

15.1. Запускаємо

dotnet watch

15.2. Перевіряємо OpenAPI JSON

Відкрийте:

  • https://localhost:7234/openapi/v1.json

Якщо ви бачите JSON-документ, генерація OpenAPI працює.

15.3. Перевіряємо Scalar

Відкрийте:

  • https://localhost:7234/scalar

Там ви маєте побачити:

  • групу Products;
  • список із 5 операцій;
  • summaries;
  • operation id;
  • request/response models;
  • query і route parameters;
  • 422 на create/update;
  • hidden технічний endpoint, якого нема в документації.
Verification
$ curl https://localhost:7234/openapi/v1.json
{ "openapi": "...", ... }
$ open https://localhost:7234/scalar
Scalar UI loaded
Tags: Products
Operations: GET /api/products, POST /api/products, ...

15.4. Ручні запити для перевірки metadata

curl "https://localhost:7234/api/products?search=arabica&includeInactive=false"

16. Додаткові сценарії конфігурації Scalar, які корисно знати

Не все треба вставляти одразу в навчальний проєкт, але важливо розуміти межі системи.

16.1. Власний маршрут для документації

app.MapScalarApiReference("/docs");

16.2. Кілька документів

У більш просунутих сценаріях Scalar може працювати не з одним, а з кількома OpenAPI-документами: наприклад, окремо для public, internal, admin. Точний fluent-синтаксис тут залежить від версії пакета Scalar.AspNetCore, тому перед реалізацією перевіряйте актуальний API саме вашої версії.

Інженерний принцип залишається тим самим:

  • у вас є кілька MapOpenApi(...) документів;
  • Scalar отримує інформацію, як між ними перемикатися;
  • один із них робиться default.

16.3. Попередньо налаштований HTTP client для code samples

app.MapScalarApiReference(options =>
{
    options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});

16.4. Dynamic configuration через HttpContext

app.MapScalarApiReference((options, httpContext) =>
{
    options.WithTitle($"API docs for {httpContext.Request.Host}");
});

Це вже корисно в multi-tenant або role-aware сценаріях.


17. Типові помилки


18. Практичні завдання

Рівень 1: Базовий

Відтворіть проєкт зі статті повністю. Досягніть стану, коли:

  • /openapi/v1.json відкривається;
  • /scalar відкривається;
  • /_internal/ping відсутній у документації;
  • POST /api/products показує 422 у Scalar.

Рівень 2: Логіка і metadata

Створіть нову групу /api/orders і опишіть її через той самий стиль:

  • WithTags("Orders") на групі;
  • WithSummary і WithDescription на кожному endpoint-і;
  • Produces / ProducesProblem / ProducesValidationProblem;
  • мінімум один route parameter з описом через WithOpenApi(...).

Рівень 3: Архітектура

Зробіть другу OpenAPI-конфігурацію, наприклад internal, і налаштуйте Scalar на роботу з двома документами:

  • v1;
  • internal.

Після цього забезпечте, щоб internal був вибраний як default document.


19. Резюме

Scalar не генерує контракт

Scalar показує вже наявний OpenAPI-документ. Якість UI прямо залежить від якості metadata, яку ви задали в Minimal API.

Fluent API цілком достатній

Без атрибутів можна повністю описати endpoint-и через WithSummary, WithDescription, WithTags, Accepts, Produces, WithOpenApi, Route Groups і transformers.

Route Group + transformers = масштабованість

Групи знімають дублювання на рівні фічі, а transformers дають глобальні правила для всього документа.

Правильний фінал — /scalar

Якщо наприкінці проєкту ви відкриваєте /scalar і бачите повний, охайний, перевіряємий API reference, значить ви не просто «підключили красивий UI», а правильно зібрали контракт свого Minimal API.
У Minimal API документація не повинна бути afterthought. Найкращий підхід такий: ви проєктуєте маршрут, одразу даєте йому fluent-metadata, потім перевіряєте в /openapi/v1.json, і фінально дивитесь очима споживача в /scalar. Саме так API-документація перестає бути формальністю і стає частиною розробки.

Далі: якщо вам потрібен не сучасний Scalar-шлях, а окремий класичний стек з Swagger UI та Swashbuckle, переходьте до Swagger / Swashbuckle у Minimal API.