OpenAPI
Volga описывает приложение в формате OpenAPI 3.0 по тому, что уже написано: по шаблонам маршрутов, по экстракторам, которые принимает обработчик, и по типам, которые он возвращает. Не нужно ни макросов, ни рефлексии во время выполнения; то, чего маршрут не может сказать о себе сам, дописывается рядом с ним, в коде.
Включение OpenAPI
Если вы не используете набор фич full, включите фичу openapi в Cargo.toml:
[dependencies]
volga = { version = "...", features = ["openapi"] }
Публикация документа
Настройка документа и его публикация — два отдельных шага, как и везде в Volga: with_open_api() описывает документ, use_open_api() регистрирует эндпоинты, которые его отдают.
use volga::App;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut app = App::new()
.with_open_api(|config| config
.with_title("Example API")
.with_version("1.0.0")
.with_ui());
app.use_open_api();
app.map_get("/hello", || "Hello, World!");
app.run().await
}
Документ отдаётся по адресу /openapi.json, а с with_ui() — ещё и Swagger UI по адресу /openapi (его можно перенести через with_ui_path(..)). Страница UI отдаётся с заголовками ETag и Cache-Control.
Примечание
Один with_open_api() ничего не публикует — debug-сборка сообщает об этом при запуске, — а use_open_api() без настройки вызывает панику. Пока не вызван use_open_api(), наружу ничего не выставлено, так что сборка, которая не должна публиковать своё API, может этот вызов просто опустить.
Те же настройки можно задать в секции [openapi] конфигурационного файла:
[openapi]
title = "Example API"
version = "1.0.0"
ui_enabled = true
Описание маршрута
Сторона запроса выводится сама: параметры пути — из шаблона маршрута, где типизированный сегмент вроде {age:integer} задаёт параметру тип, а тело и query — из Deserialize-типов в Json<T>, Form<T>, Query<T> и NamedPath<T>, вместе с правилами валидирующего экстрактора. Тип, который реализует только Serialize, так исследовать нельзя, поэтому ответы, построенные из него, называются вручную.
Всё остальное задаётся через open_api(..) на маршруте или на группе — для всех её маршрутов; настройки группы объединяются с собственными настройками каждого маршрута.
use serde::{Deserialize, Serialize};
use volga::{App, Json, NamedPath, ok};
#[derive(Default, Deserialize, Serialize)]
struct User {
name: String,
age: u64,
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut app = App::new().with_open_api(|config| config.with_ui());
app.use_open_api();
app.group("/users", |api| {
api.open_api(|cfg| cfg.with_tag("users"));
api.map_get("/{name}/{age}", |user: NamedPath<User>| ok!(user.into_inner()))
.open_api(|cfg| cfg
.with_summary("Echoes a user back")
.produces_json::<User>(200)
.produces_no_schema(400));
api.map_post("/", |user: Json<User>| user)
.open_api(|cfg| cfg.produces_json_example(201, User {
name: "John".into(),
age: 30,
}));
});
app.run().await
}
Остальное в операции покрывает построитель OpenApiRouteConfig:
| Метод | Описывает |
|---|---|
with_summary, with_description, with_operation_id, with_tag / with_tags | саму операцию |
produces_json::<T>(status), produces_json_example(status, value) | JSON-ответ |
produces_text(status), produces_no_schema(status) | текстовый ответ, ответ без тела |
produces_problem(status), produces_problem_example(status, value) | ответ application/problem+json |
produces_form::<T>(status), produces_multipart(status), produces_stream(status), produces_sse(status) | прочие виды ответов |
consumes_json::<T>(), consumes_form::<T>(), consumes_query::<T>(), consumes_multipart(), consumes_stream() | вход, который не называет ни один экстрактор |
with_request_schema(schema), with_query_schema(schema), with_response_schema(status, schema) | что угодно, вручную |
Обработчик, возвращающий Result<T, E>, описывается ответами и T, и E: тип ошибки сообщает, чем он отвечает, через IntoError::describe_openapi().
Несколько документов
API с версиями может публиковать по документу на версию. Каждая спецификация отдаётся по адресу /{name}/openapi.json, а маршрут или группа привязывается к своим документам через with_doc(..) / with_docs(..); маршрут, не привязанный ни к одному, описывается в первом.
use volga::App;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut app = App::new().with_open_api(|config| config
.with_specs(["v1", "v2"])
.with_ui());
app.use_open_api();
// Только в /v1/openapi.json
app.map_get("/users", || "users");
// В обоих документах
app.group("/health", |api| {
api.open_api(|cfg| cfg.with_docs(["v1", "v2"]));
api.map_get("/", || "ok");
});
// Только в /v2/openapi.json
app.map_post("/users", || "created")
.open_api(|cfg| cfg.with_doc("v2"));
app.run().await
}
Описание типа вручную
serde читает структуру с полем #[serde(flatten)] как map, чтобы собрать ключи, которые структура не называет, — а map не говорит, какие ключи принимает. Ни одно поле такой структуры не выводится, включая объявленные рядом с flatten-полем: тело Json<T> или Form<T> описывается как объект без свойств (или как произвольное значение, если структура лежит внутри него, например в Vec), а Query<T> не описывает ни одного параметра.
Начиная с 0.11.2 debug-сборка называет каждый такой вход при запуске. Опишите его вручную через OpenApiSchema — with_request_schema() для тела, with_query_schema() для query; последний описывает по параметру на каждое свойство, обязательному, если его называет список required схемы:
use std::collections::HashMap;
use serde::Deserialize;
use volga::{App, Json, Query, openapi::OpenApiSchema};
#[derive(Deserialize)]
struct Search {
name: String,
page: u32,
// Все прочие ключи: именно из-за этого serde читает `Search` как map
#[serde(flatten)]
filters: HashMap<String, String>,
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut app = App::new().with_open_api(|config| config.with_ui());
app.use_open_api();
let schema = OpenApiSchema::object()
.with_property("name", OpenApiSchema::string())
.with_property("page", OpenApiSchema::integer())
.with_required(["name", "page"]);
app.map_post("/search", |search: Json<Search>| search.name.clone())
.open_api(|cfg| cfg.with_request_schema(schema.clone()));
app.map_get("/search", |search: Query<Search>| search.name.clone())
.open_api(|cfg| cfg.with_query_schema(schema));
app.run().await
}
Параметр, уже описанный под тем же именем, заменяется, а вход, описанный вручную, пропадает из undescribed_inputs() — списка, из которого строится сообщение при запуске. О настоящих map, например о теле HashMap<String, T>, не сообщается.
Параметры пути всегда называются по шаблону маршрута, что бы ни описывал экстрактор, поэтому их типы указываются там же: {id:integer}.
Маршруты, по-разному называющие одну позицию
OpenAPI 3.0 допускает один шаблонный путь на позицию. GET /users/{id} рядом с POST /users/{name} описываются под одним путём, названным так, как записано большинство маршрутов в нём, а debug-сборка сообщает о каждом маршруте, чьи параметры были переименованы, — см. Именование параметров принадлежит маршруту.
Куда попадают сообщения при запуске
Сообщения выше выдаёт только debug-сборка. С включённой фичей tracing — а full её включает — они отправляются как события уровня WARN и видны только после установки подписчика tracing; без этой фичи они печатаются в stderr.
Полный пример — в примере OpenAPI.