Minimal API, групувати маршрути, організовувати проєкт і проектувати HTTP-контракти. Але в реальному житті цього недостатньо. Команда бекенду не може просто сказати фронтенду: «дивись у Program.cs». Потрібна жива документація, яка будується з реального коду, оновлюється разом із ним і дозволяє одразу тестувати endpoint-и. Саме тут у гру входить Scalar.У цьому матеріалі ми не будемо використовувати атрибути на кшталт [Produces], [Tags], [EndpointSummary] або [Description]. Усе, що стосується документації, ми зробимо через Fluent API: WithSummary(), Produces(), Accepts(), WithOpenApi(), MapGroup(), AddOpenApi(...) transformers і MapScalarApiReference(...).Що побудуємо
dotnet new web/openapi/v1.json/scalar/api/products з повним Fluent-описом metadataЩо розберемо
WithOpenApi(...) для тонкого налаштування операції;MapScalarApiReference(...).Результат наприкінці
https://localhost:{port}/scalar;Minimal API + OpenAPI + Scalar. Якщо вам потрібна окрема теорія про сам стандарт OpenAPI, дивіться матеріал про OpenAPI як контракт.openapi.jsonКоли ви вмикаєте OpenAPI в ASP.NET Core, ви отримуєте машиночитаний документ. Це чудово для генераторів клієнтів, тестів і контрактних перевірок. Але для людини сирий JSON на кшталт /openapi/v1.json незручний:
Scalar вирішує саме цю проблему. Він бере OpenAPI-документ і рендерить з нього інтерактивний reference UI.
Ключова думка: Scalar не вигадує контракт сам. Він лише красиво і зручно показує той OpenAPI-документ, який ви згенерували з коду або з контракту.
У 2026 році для нового навчального прикладу найрозумніший стартовий варіант такий:
.NET 9 або новіше. Саме з .NET 9 у ASP.NET Core є вбудована підтримка AddOpenApi() і MapOpenApi().Microsoft.AspNetCore.OpenApi для генерації OpenAPI-документа з Minimal API.Scalar.AspNetCore для рендерингу документації на маршруті /scalar.Minimal API + AddOpenApi + Scalar.AspNetCore дає чистіший стек, ніж старий маршрут через SwaggerGen як основну інтеграцію. Це не означає, що Swashbuckle поганий; це означає, що для навчального прикладу з акцентом на сучасний ASP.NET Core простіше починати з вбудованого OpenAPI API.Використовуємо:
builder.Services.AddOpenApi()app.MapOpenApi()app.MapScalarApiReference()Це і є основний шлях у цій статті.
Якщо ви сидите на .NET 8, Scalar теж можна підключити, але зазвичай через генератор на кшталт Swashbuckle.AspNetCore:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
if (app.Environment.IsDevelopment())
{
app.MapSwagger("/openapi/{documentName}.json");
app.MapScalarApiReference();
}
Усе інше в статті про Fluent metadata майже повністю зберігає цінність, але точні інтеграційні точки OpenAPI будуть трохи іншими.
Program.cs.Ми збудуємо невеликий каталог продуктів із такими 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;200, 201, 204, 404, 422, 500;WithOpenApi(...);mkdir MinimalApiScalarDemo
cd MinimalApiScalarDemo
dotnet new web --framework net9.0
mkdir MinimalApiScalarDemo
cd MinimalApiScalarDemo
dotnet new web --framework net9.0
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore
Поки що ми не побачимо ні Scalar, ні OpenAPI, бо ще нічого не підключали. Але важливо перевірити, що сам проєкт стартує.
dotnet watch. Натискаєте Ctrl + S у редакторі, і сервер сам перезапускається. Це критично знижує тертя, коли ви налаштовуєте metadata endpoint-ів і одразу дивитесь результат у /scalar. Увесь подальший сенс статті зводиться до одного критерію: після кожної зміни ви можете відкрити /scalar і побачити, як змінився контракт.
Ми не будемо складати всю логіку в один Program.cs. Для теми документації це погана ідея, бо змішаються:
Побудуємо ось таку структуру:
У темі документації багато хто робить помилку: кидає всі MapGet/MapPost у Program.cs, а потім не може зрозуміти, де закінчується логіка API і де починається логіка опису документації.
Тут ми розвели відповідальність:
Program.cs відповідає за композицію;ProductEndpoints.cs відповідає за маршрути і metadata;ProductContracts.cs відповідає за схеми даних;ProductStore.cs відповідає за технічне сховище даних;launchSettings.json відповідає за зручний запуск одразу в Scalar.Program.csAddOpenApi(...)Рядок:
builder.Services.AddOpenApi(...)
реєструє генератор OpenAPI-документа. Це не UI. Це саме механізм, який:
У AddDocumentTransformer(...) ми задаємо інформацію верхнього рівня:
Title;Version;Description;Servers.Це рівень усього документа. Тут немає сенсу описувати окремі endpoint-и. Тут живе все, що стосується API в цілому.
AddSchemaTransformer(...) працює на рівні схем. У нашому прикладі він модифікує всі decimal-поля, щоб схема явно показувала format: decimal.
Це важливо з педагогічної точки зору. У великих системах команди часто мають доменні правила:
decimal для грошей;DateTimeOffset з UTC-семантикою;Schema transformer дозволяє донести ці правила до документації без атрибутів на кожному DTO.
AddOperationTransformer(...) ми використовуємо для глобального правила: кожна операція документально підтримує 500.
500. Це означає, що ми хочемо, щоб документація не брехала: будь-яке реальне серверне API має сценарій неочікуваної помилки, і споживач повинен бачити це в контракті.MapOpenApi(...)Рядок:
app.MapOpenApi("/openapi/{documentName}.json")
реєструє endpoint, який віддає OpenAPI-документ. Це важливий нюанс: OpenAPI JSON у ASP.NET Core теж є звичайним endpoint-ом, а не якоюсь магічною окремою системою.
Тобто до нього можна застосовувати звичні endpoint-конвенції. Наприклад:
app.MapOpenApi("/openapi/{documentName}.json")
.AllowAnonymous();
MapScalarApiReference(...)Саме цей рядок дає UI:
app.MapScalarApiReference("/scalar", options => ...)
Він каже: «створи сторінку документації на маршруті /scalar і під'єднай її до OpenAPI-документа».
MapScalarApiReference(...) має кілька корисних overload-ів. Це важливо, бо в реальних проєктах вам не завжди підходить один і той самий маршрут або однакова конфігурація для всіх користувачів.
app.MapScalarApiReference();
Дає стандартний маршрут /scalar з конфігурацією за замовчуванням.
app.MapScalarApiReference("/docs");
Корисно, якщо у вас корпоративна угода про URL на кшталт /docs, /reference або /api-docs.
app.MapScalarApiReference(options =>
{
options.WithTitle("My API")
.ShowOperationId();
});
Це найчастіший сценарій: маршрут стандартний, але поведінку Scalar ви тонко налаштовуєте.
app.MapScalarApiReference("/docs", options =>
{
options.WithTitle("My API Docs");
});
Оптимально, коли хочеться і нестандартний URL, і контроль над виглядом.
app.MapScalarApiReference((options, httpContext) =>
{
var isAdmin = httpContext.User.IsInRole("Admin");
options.WithTitle(isAdmin ? "Admin API" : "Public API");
});
Це вже більш просунутий сценарій. Наприклад, різні ролі бачать різний title, різні документи або різні prefilled auth settings.
/openapi/{documentName}.json.operationId у UI. Дуже корисно, якщо ви свідомо задаєте WithName(...) на endpoint-ах.C# + HttpClient.app.MapScalarApiReference("/scalar", options =>
{
options.WithTitle("Catalog API")
.WithOpenApiRoutePattern("/openapi/{documentName}.json")
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
.ShowOperationId()
.ExpandAllTags()
.SortTagsAlphabetically()
.SortOperationsByMethod()
.PreserveSchemaPropertyOrder();
});
Тепер переходимо до найважливішого. Коли кажуть «опис endpoint-а», на практиці мають на увазі кілька різних шарів metadata. У Minimal API без атрибутів це робиться не одним методом, а цілим набором fluent-конвенцій.
Працює на одному конкретному маршруті:
WithName(...)WithSummary(...)WithDescription(...)WithTags(...)Accepts<T>(...)Produces<T>(...)Produces(...)ProducesProblem(...)ProducesValidationProblem(...)ExcludeFromDescription()WithOpenApi(...)Працює на групі маршрутів і зменшує дублювання:
MapGroup("/api/products").WithTags("Products").WithOpenApi().RequireAuthorization().AddEndpointFilter(...)Це найкращий рівень для загальних правил фічі.
Працює не через RouteHandlerBuilder, а через OpenAPI generator:
AddDocumentTransformer(...)AddOperationTransformer(...)AddSchemaTransformer(...)Це рівень для правил, які мають зачепити весь документ.
WithName(...).WithName("GetProductById")
Це не просто «красиве ім'я». У Minimal API ім'я endpoint-а:
operationId в OpenAPI;ShowOperationId().WithSummary(...).WithSummary("Отримати товар за ID")
Це короткий заголовок операції. Саме summary найчастіше відображається в списку операцій у Scalar.
WithDescription(...).WithDescription("Повертає розширену модель одного товару.")
Description має бути довшим, ніж summary. Це місце для контексту:
WithTags(...).WithTags("Products")
Tags групують операції в документації. Якщо ви забудете про теги, великий API швидко перетвориться на нечитабельний список із сотні маршрутів.
Accepts<T>(...).Accepts<CreateProductRequest>("application/json")
Цей метод формалізує requestBody. Він потрібен не завжди, бо частину речей generator може вивести сам з сигнатури handler-а, але:
Produces<T>(...).Produces<ProductDetailsResponse>(StatusCodes.Status201Created)
Це основний спосіб явно задати тіло успішної відповіді.
Produces(...).Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
Коли тіло відповіді не потрібне або неважливе, достатньо вказати статус-код.
ProducesProblem(...).ProducesProblem(StatusCodes.Status500InternalServerError)
Цей метод показує, що у відповіді використовується ProblemDetails-стиль для помилок.
ProducesValidationProblem(...).ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity)
Дуже корисно для endpoint-ів, які приймають тіло запиту й можуть повертати помилки валідації.
ExcludeFromDescription()app.MapGet("/_internal/ping", ...)
.ExcludeFromDescription();
Це варіант для внутрішніх, службових або технічних маршрутів, які не повинні засмічувати публічну документацію.
WithOpenApi(...)Це найпотужніший локальний інструмент. Він дозволяє взяти вже згенеровану OpenAPI-операцію і допрацювати її вручну:
.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.Тепер розберемо кожен наш маршрут як інженерний приклад.
GET /api/productsgroup.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;
});
Що тут важливо:
WithName() задає стабільний operationId.WithSummary() і WithDescription() дають короткий та довгий опис.Produces<T>() фіксує успішний payload.WithOpenApi(...) дозволяє описати query-параметри без атрибутів [Description].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 + кілька статус-кодів.
POST /api/productsgroup.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.PUT /api/products/{id}Цей маршрут поєднує все одразу:
200;404;422.Це хороший «еталонний» endpoint для API-документації, бо він показує складніший сценарій, ніж простий GET.
DELETE /api/products/{id}.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
Тут навмисно нема Produces<T>(), бо DELETE у нас повертає 204 No Content. Це важливий дидактичний момент: не всі endpoint-и мають JSON-тіло у відповіді.
Це питання дуже часто плутають.
| Рівень | Інструмент | Для чого підходить |
|---|---|---|
| Route metadata | WithSummary, Produces, Accepts, WithTags | Основний контракт endpoint-а |
| Per-operation OpenAPI patch | WithOpenApi(...) | Точкове ручне доопрацювання конкретної операції |
| Global document customization | AddDocumentTransformer(...) | Назва API, сервери, top-level поля |
| Global operation customization | AddOperationTransformer(...) | Спільні відповіді, security, параметри, умовні правила |
| Global schema customization | AddSchemaTransformer(...) | Опис або формат типів у моделях |
WithOpenApi(...) як місце, де ви описуєте все підряд. Якщо у вас 30 endpoint-ів і кожен отримує вручну по 20 мутацій OpenAPI-об'єкта, документація стає не менш хаотичною, ніж атрибути, від яких ви намагалися піти.Правильне правило таке:
MapGroup;WithOpenApi(...) або transformers.У нашому коді є ось цей рядок:
var group = app.MapGroup("/api/products")
.WithTags("Products")
.WithOpenApi();
Чому це добре:
/api/products задано один раз;MapGroup майже завжди є кращим стартом, ніж розрізнені app.MapGet(...) по всьому Program.cs. Це не лише питання чистоти коду, а й питання якості документації в Scalar.Саме тут більшість розробників і спотикається. Для summaries, descriptions, responses, tags і request bodies у нас є зручні fluent-методи. Але для опису окремих route/query/header parameter-ів окремого простого fluent-методу в Minimal API немає.
Тому в сценарії без атрибутів залишаються два реальні інструменти:
WithOpenApi(...) на конкретному endpoint-і;AddOperationTransformer(...) глобально.WithOpenApi(...)Ми вже бачили його вище:
.WithOpenApi(operation =>
{
var search = operation.Parameters.First(p => p.Name == "search");
search.Description = "Пошук по фрагменту назви.";
return operation;
});
Це найкраще, коли:
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;
});
});
Такий варіант кращий, коли:
WithOpenApi(...): усі реальні сценарії, які варто знатиНижче не просто теорія, а список сценаріїв, де WithOpenApi(...) реально виправданий.
.WithOpenApi(operation =>
{
var id = operation.Parameters.First(p => p.Name == "id");
id.Description = "GUID ідентифікатор товару.";
return operation;
})
.WithOpenApi(operation =>
{
operation.Deprecated = true;
return operation;
})
.WithOpenApi(operation =>
{
operation.Summary = "Кастомний summary";
operation.Description = "Кастомний description";
return operation;
})
.WithOpenApi(operation =>
{
operation.RequestBody!.Description = "JSON payload для створення товару.";
return operation;
})
.WithOpenApi(operation =>
{
operation.Responses["201"].Description = "Resource created successfully.";
return operation;
})
WithOpenApi(...) не потрібенНе треба лізти в нього, якщо те саме можна виразити простіше через:
WithSummary(...);WithDescription(...);WithTags(...);Accepts<T>(...);Produces<T>(...);ProducesProblem(...);ProducesValidationProblem(...).Принцип дуже простий: спочатку використовуйте найбільш декларативний fluent-метод, а лише потім переходьте до ручної мутації OpenAPI-операції.
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();
Тепер уже можна читати цей файл як коротку історію:
/openapi/... і /scalar.Саме так і має виглядати Program.cs, коли проєкт уже зріс вище «Hello World».
dotnet watch
Відкрийте:
https://localhost:7234/openapi/v1.jsonЯкщо ви бачите JSON-документ, генерація OpenAPI працює.
Відкрийте:
https://localhost:7234/scalarТам ви маєте побачити:
Products;422 на create/update;curl "https://localhost:7234/api/products?search=arabica&includeInactive=false"
curl -X POST "https://localhost:7234/api/products" \
-H "Content-Type: application/json" \
-d '{
"name": "Espresso Cup",
"price": 89.00,
"isActive": true
}'
curl -X POST "https://localhost:7234/api/products" \
-H "Content-Type: application/json" \
-d '{
"name": "",
"price": 0,
"isActive": true
}'
Не все треба вставляти одразу в навчальний проєкт, але важливо розуміти межі системи.
app.MapScalarApiReference("/docs");
У більш просунутих сценаріях Scalar може працювати не з одним, а з кількома OpenAPI-документами: наприклад, окремо для public, internal, admin. Точний fluent-синтаксис тут залежить від версії пакета Scalar.AspNetCore, тому перед реалізацією перевіряйте актуальний API саме вашої версії.
Інженерний принцип залишається тим самим:
MapOpenApi(...) документів;app.MapScalarApiReference(options =>
{
options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});
HttpContextapp.MapScalarApiReference((options, httpContext) =>
{
options.WithTitle($"API docs for {httpContext.Request.Host}");
});
Це вже корисно в multi-tenant або role-aware сценаріях.
Produces, Accepts, WithSummary, WithDescription, а лише потім ручні патчі.ExcludeFromDescription().WithTags, RequireAuthorization і shared rules, документація стає дубльованою, а код крихким.Відтворіть проєкт зі статті повністю. Досягніть стану, коли:
/openapi/v1.json відкривається;/scalar відкривається;/_internal/ping відсутній у документації;POST /api/products показує 422 у Scalar.GET /api/products додайте ще один query-параметр sortBy, але без атрибутів. Опис параметра зробіть через WithOpenApi(...).Створіть нову групу /api/orders і опишіть її через той самий стиль:
WithTags("Orders") на групі;WithSummary і WithDescription на кожному endpoint-і;Produces / ProducesProblem / ProducesValidationProblem;WithOpenApi(...).500 з AddOperationTransformer(...) у більш складний варіант: додавайте 500 лише тим операціям, які знаходяться під /api/.Зробіть другу OpenAPI-конфігурацію, наприклад internal, і налаштуйте Scalar на роботу з двома документами:
v1;internal.Після цього забезпечте, щоб internal був вибраний як default document.
/api/products і налаштуйте документацію так, щоб у Scalar було видно security requirements. Поясніть, яка частина конфігурації належить до ASP.NET authorization, а яка до OpenAPI/Scalar.Scalar не генерує контракт
Fluent API цілком достатній
WithSummary, WithDescription, WithTags, Accepts, Produces, WithOpenApi, Route Groups і transformers.Route Group + transformers = масштабованість
Правильний фінал — /scalar
/scalar і бачите повний, охайний, перевіряємий API reference, значить ви не просто «підключили красивий UI», а правильно зібрали контракт свого Minimal API./openapi/v1.json, і фінально дивитесь очима споживача в /scalar. Саме так API-документація перестає бути формальністю і стає частиною розробки.Далі: якщо вам потрібен не сучасний Scalar-шлях, а окремий класичний стек з Swagger UI та Swashbuckle, переходьте до Swagger / Swashbuckle у Minimal API.
Структура проєкту: від хаосу до архітектури
Еволюція організації Minimal API проєкту: від одного файлу до Extension Methods, Route Groups, Feature Folders та Vertical Slice Architecture.
Swagger / Swashbuckle у Minimal API: окремий класичний шлях
Окремий матеріал про Swagger UI та Swashbuckle в ASP.NET Core Minimal API: коли використовувати, як підключити в .NET 8, як працює AddEndpointsApiExplorer, AddSwaggerGen, UseSwagger, UseSwaggerUI та як документувати endpoint-и.