OpenAPI/Swagger в Axum
OpenAPI (ранее известный как Swagger) — это спецификация для описания RESTful API. Интеграция Axum с OpenAPI позволяет автоматически генерировать документацию API и предоставлять интерактивный интерфейс для тестирования.
Содержание
- Библиотеки и инструменты
- Базовая настройка
- Аннотирование маршрутов
- Документирование схем данных
- Документирование параметров
- Расширенные возможности
- Обслуживание UI Swagger
- Лучшие практики
Библиотеки и инструменты
Для интеграции Axum с OpenAPI мы будем использовать библиотеку utoipa
, которая предоставляет макросы для аннотирования и генерации документации OpenAPI:
[dependencies]
axum = "0.7.2"
utoipa = { version = "4.1.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "5.0.0", features = ["axum"] }
Базовая настройка
Для начала настроим базовую документацию OpenAPI:
use axum::{
routing::{get, post},
Router,
};
use utoipa::{
OpenApi,
openapi::security::{SecurityScheme, ApiKey, ApiKeyValue, SecurityRequirement},
Modify,
};
use utoipa_swagger_ui::SwaggerUi;
// Определение API
#[derive(OpenApi)]
#[openapi(
// Основная информация
info(
title = "Axum API",
version = "1.0.0",
description = "API для примера интеграции Axum с OpenAPI",
license(
name = "MIT",
url = "https://opensource.org/licenses/MIT"
),
contact(
name = "API Разработчик",
email = "dev@example.com",
url = "https://example.com"
)
),
// Серверы для тестирования
servers(
(url = "http://localhost:3000", description = "Локальный сервер разработки"),
(url = "https://api.example.com", description = "Продакшн сервер")
),
// Пути к обработчикам, которые нужно включить в документацию
paths(
get_users,
get_user_by_id,
create_user,
update_user,
delete_user
),
// Компоненты (схемы) для повторного использования
components(
schemas(User, CreateUserRequest, UpdateUserRequest, ErrorResponse)
),
// Определение безопасности
modifiers(&SecurityAddon)
)]
struct ApiDoc;
// Расширение для добавления безопасности
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
// Добавляем определение API-ключа
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(
ApiKeyValue::new("X-API-Key")
))
);
// Добавляем требование ко всем методам
let security_requirement = SecurityRequirement::new("api_key", &[]);
openapi.security = vec![security_requirement];
}
}
// Основная функция
#[tokio::main]
async fn main() {
// Маршрутизатор API
let api_router = Router::new()
.route("/users", get(get_users).post(create_user))
.route("/users/:id", get(get_user_by_id).put(update_user).delete(delete_user));
// Добавляем Swagger UI
let app = Router::new()
.merge(api_router)
.merge(
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi())
);
// Запуск сервера
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("Swagger UI доступен на http://localhost:3000/swagger-ui/");
axum::serve(listener, app).await.unwrap();
}
Аннотирование маршрутов
Теперь определим модели и аннотируем обработчики:
use axum::{
extract::{Path, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::{ToSchema, IntoParams};
use std::sync::{Arc, Mutex};
// Модель данных пользователя
#[derive(Serialize, Deserialize, ToSchema, Clone)]
struct User {
#[schema(example = 1)]
id: u64,
#[schema(example = "Иван Иванов")]
name: String,
#[schema(example = "ivan@example.com")]
email: String,
#[schema(nullable = true, example = "Администратор")]
role: Option<String>,
}
// Модель запроса на создание пользователя
#[derive(Deserialize, ToSchema)]
struct CreateUserRequest {
#[schema(example = "Иван Иванов")]
name: String,
#[schema(example = "ivan@example.com")]
email: String,
}
// Модель запроса на обновление пользователя
#[derive(Deserialize, ToSchema)]
struct UpdateUserRequest {
#[schema(example = "Иван Иванов")]
name: Option<String>,
#[schema(example = "ivan@example.com")]
email: Option<String>,
#[schema(nullable = true, example = "Администратор")]
role: Option<String>,
}
// Модель ответа с ошибкой
#[derive(Serialize, ToSchema)]
struct ErrorResponse {
#[schema(example = "Пользователь не найден")]
message: String,
}
// Параметр пути для ID пользователя
#[derive(IntoParams)]
struct UserIdPath {
#[schema(example = 1)]
id: u64,
}
// Хранилище пользователей для примера
#[derive(Clone)]
struct AppState {
users: Arc<Mutex<Vec<User>>>,
}
// Получение списка пользователей
#[utoipa::path(
get,
path = "/users",
tag = "Пользователи",
responses(
(status = 200, description = "Список пользователей получен успешно", body = Vec<User>)
)
)]
async fn get_users() -> Json<Vec<User>> {
// Реализация...
Json(vec![
User { id: 1, name: "Иван".to_string(), email: "ivan@example.com".to_string(), role: Some("Администратор".to_string()) },
User { id: 2, name: "Мария".to_string(), email: "maria@example.com".to_string(), role: None },
])
}
// Получение пользователя по ID
#[utoipa::path(
get,
path = "/users/{id}",
tag = "Пользователи",
params(
UserIdPath
),
responses(
(status = 200, description = "Пользователь найден", body = User),
(status = 404, description = "Пользователь не найден", body = ErrorResponse)
)
)]
async fn get_user_by_id(
Path(id): Path<u64>,
) -> impl IntoResponse {
// Для примера возвращаем пользователя с ID 1
if id == 1 {
Json(User {
id: 1,
name: "Иван".to_string(),
email: "ivan@example.com".to_string(),
role: Some("Администратор".to_string()),
}).into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
message: format!("Пользователь с ID {} не найден", id),
}),
).into_response()
}
}
// Создание пользователя
#[utoipa::path(
post,
path = "/users",
tag = "Пользователи",
request_body = CreateUserRequest,
responses(
(status = 201, description = "Пользователь создан успешно", body = User),
(status = 400, description = "Некорректные данные", body = ErrorResponse)
)
)]
async fn create_user(
Json(payload): Json<CreateUserRequest>,
) -> impl IntoResponse {
// Реализация создания пользователя
let user = User {
id: 3, // Для примера генерируем ID
name: payload.name,
email: payload.email,
role: None,
};
(StatusCode::CREATED, Json(user))
}
// Обновление пользователя
#[utoipa::path(
put,
path = "/users/{id}",
tag = "Пользователи",
params(
UserIdPath
),
request_body = UpdateUserRequest,
responses(
(status = 200, description = "Пользователь обновлен успешно", body = User),
(status = 404, description = "Пользователь не найден", body = ErrorResponse)
)
)]
async fn update_user(
Path(id): Path<u64>,
Json(payload): Json<UpdateUserRequest>,
) -> impl IntoResponse {
// Реализация обновления пользователя
if id == 1 {
let user = User {
id,
name: payload.name.unwrap_or_else(|| "Иван".to_string()),
email: payload.email.unwrap_or_else(|| "ivan@example.com".to_string()),
role: payload.role,
};
Json(user).into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
message: format!("Пользователь с ID {} не найден", id),
}),
).into_response()
}
}
// Удаление пользователя
#[utoipa::path(
delete,
path = "/users/{id}",
tag = "Пользователи",
params(
UserIdPath
),
responses(
(status = 204, description = "Пользователь удален успешно"),
(status = 404, description = "Пользователь не найден", body = ErrorResponse)
)
)]
async fn delete_user(
Path(id): Path<u64>,
) -> impl IntoResponse {
// Реализация удаления пользователя
if id == 1 {
StatusCode::NO_CONTENT.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
message: format!("Пользователь с ID {} не найден", id),
}),
).into_response()
}
}
Документирование схем данных
Для более продвинутой документации схем данных можно использовать дополнительные аннотации:
use utoipa::ToSchema;
use serde::{Deserialize, Serialize};
// Перечисление с комментариями
#[derive(Serialize, Deserialize, ToSchema)]
#[schema(example = "admin")]
enum UserRole {
#[schema(rename = "admin")]
Admin,
#[schema(rename = "user")]
User,
#[schema(rename = "guest")]
Guest,
}
// Сложная структура с вложенными полями
#[derive(Serialize, Deserialize, ToSchema)]
struct UserProfile {
#[schema(example = "Иван Иванов")]
full_name: String,
#[schema(example = "Программист с опытом работы в Rust")]
bio: Option<String>,
#[schema(example = "Москва")]
location: Option<String>,
contact_info: ContactInfo,
#[schema(example = json!(["rust", "web development", "api design"]))]
skills: Vec<String>,
role: UserRole,
}
#[derive(Serialize, Deserialize, ToSchema)]
struct ContactInfo {
#[schema(example = "ivan@example.com")]
email: String,
#[schema(example = "+7 999 123 45 67")]
phone: Option<String>,
#[schema(example = "https://github.com/ivan")]
website: Option<String>,
}
// Объединение разных схем
#[derive(Serialize, ToSchema)]
#[schema(one_of = [OkResponse, ErrorResponse])]
enum ApiResponse {
#[schema(schema_with = "OkResponseSchema")]
Ok(OkResponse),
#[schema(schema_with = "ErrorResponseSchema")]
Error(ErrorResponse),
}
#[derive(Serialize, ToSchema)]
struct OkResponse {
#[schema(example = "Операция выполнена успешно")]
message: String,
data: Option<serde_json::Value>,
}
#[schema(example = json!({"message": "Операция выполнена успешно", "data": {"id": 1}}))]
fn OkResponseSchema() -> utoipa::openapi::Schema {
OkResponse::schema()
}
#[schema(example = json!({"message": "Произошла ошибка", "code": "NOT_FOUND"}))]
fn ErrorResponseSchema() -> utoipa::openapi::Schema {
ErrorResponse::schema()
}
Документирование параметров
Параметры запроса и пути также можно детально документировать:
use utoipa::IntoParams;
// Параметры запроса с документацией
#[derive(Deserialize, IntoParams)]
struct UserQueryParams {
#[param(description = "Фильтр по роли пользователя", example = "admin")]
role: Option<String>,
#[param(description = "Поиск по имени", example = "Иван")]
name: Option<String>,
#[param(description = "Количество записей на странице", default = 10,
minimum = 1, maximum = 100, example = 20)]
limit: Option<u64>,
#[param(description = "Смещение для пагинации", default = 0, example = 40)]
offset: Option<u64>,
#[param(description = "Поле для сортировки", example = "name",
schema_with = SortFieldSchema)]
sort_by: Option<String>,
#[param(description = "Порядок сортировки", example = "asc",
schema_with = SortOrderSchema)]
order: Option<String>,
}
fn SortFieldSchema() -> utoipa::openapi::Schema {
utoipa::openapi::SchemaBuilder::new()
.schema_type(utoipa::openapi::SchemaType::String)
.enum_values(Some(["id", "name", "email", "created_at"]))
.description(Some("Поле для сортировки"))
.build()
}
fn SortOrderSchema() -> utoipa::openapi::Schema {
utoipa::openapi::SchemaBuilder::new()
.schema_type(utoipa::openapi::SchemaType::String)
.enum_values(Some(["asc", "desc"]))
.description(Some("Порядок сортировки"))
.build()
}
// Использование в обработчике
#[utoipa::path(
get,
path = "/users",
tag = "Пользователи",
params(
UserQueryParams
),
responses(
(status = 200, description = "Список пользователей получен успешно", body = Vec<User>)
)
)]
async fn get_users_with_filter(
Query(params): Query<UserQueryParams>,
) -> Json<Vec<User>> {
// Реализация с фильтрацией...
}
Расширенные возможности
Группировка по тегам
#[derive(OpenApi)]
#[openapi(
tags(
(name = "Пользователи", description = "API для управления пользователями"),
(name = "Аутентификация", description = "API для авторизации и аутентификации"),
(name = "Профили", description = "API для управления профилями пользователей")
),
paths(
// Пути для Пользователей
get_users,
get_user_by_id,
create_user,
update_user,
delete_user,
// Пути для Аутентификации
login,
logout,
refresh_token,
// Пути для Профилей
get_profile,
update_profile
),
components(
schemas(User, UserProfile, LoginRequest, TokenResponse, ProfileUpdateRequest)
)
)]
struct ApiDoc;
Документирование заголовков
#[utoipa::path(
get,
path = "/protected-resource",
tag = "Защищенные ресурсы",
security(
("api_key" = [])
),
params(
("X-Request-ID" = String, Header, description = "Уникальный ID запроса", example = "req-123456"),
),
responses(
(status = 200, description = "Ресурс получен успешно", body = Resource,
headers(
("X-Rate-Limit-Limit" = String, description = "Количество запросов в час"),
("X-Rate-Limit-Remaining" = i32, description = "Оставшееся количество запросов")
)
),
(status = 401, description = "Неавторизованный доступ", body = ErrorResponse),
(status = 429, description = "Слишком много запросов", body = ErrorResponse)
)
)]
async fn get_protected_resource() -> impl IntoResponse {
// Реализация...
}
Обслуживание UI Swagger
Настройка Swagger UI с дополнительными параметрами:
use utoipa_swagger_ui::{SwaggerUi, Url};
// Подготовка маршрутизатора
let app = Router::new()
.merge(api_router)
.merge(
SwaggerUi::new("/swagger-ui")
// Базовый URL для JSON спецификации
.url("/api-docs/openapi.json", ApiDoc::openapi())
// Дополнительные спецификации для микросервисов
.url("/api-docs/auth-openapi.json", AuthApiDoc::openapi())
.url("/api-docs/billing-openapi.json", BillingApiDoc::openapi())
// Настройка UI
.config(|config| {
config
// Разрешить выбор различных спецификаций
.urls_primary_name("Основное API")
.default_model_expand_depth(3)
.default_model_rendering(utoipa_swagger_ui::ModelRendering::Example)
// Дополнительные настройки
.doc_expansion(utoipa_swagger_ui::DocExpansion::List)
.filter(true) // Включить поиск
.try_it_out_enabled(true) // Включить тестирование API
// Настройки аутентификации для Swagger UI
.persist_authorization(true)
})
);
Лучшие практики
1. Структурируйте документацию по тегам
Группируйте API-точки по функциональным областям, используя теги:
#[utoipa::path(
get,
path = "/users",
tag = "Пользователи",
)]
async fn get_users() -> Json<Vec<User>> {
// ...
}
#[utoipa::path(
post,
path = "/auth/login",
tag = "Аутентификация",
)]
async fn login() -> Json<TokenResponse> {
// ...
}
2. Используйте подробные описания
Предоставляйте детальные описания для всех компонентов API:
#[derive(ToSchema)]
#[schema(description = "Информация о пользователе системы")]
struct User {
#[schema(description = "Уникальный идентификатор пользователя")]
id: u64,
#[schema(description = "Полное имя пользователя")]
name: String,
#[schema(description = "Адрес электронной почты, используется для входа в систему")]
email: String,
}
3. Добавляйте примеры
#[utoipa::path(
post,
path = "/users",
tag = "Пользователи",
request_body(content = CreateUserRequest, description = "Данные для создания пользователя",
example = json!({"name": "Иван Петров", "email": "ivan@example.com"})),
responses(
(status = 201, description = "Пользователь создан успешно",
body = User, example = json!({"id": 1, "name": "Иван Петров", "email": "ivan@example.com"})),
(status = 400, description = "Неверные данные запроса",
body = ErrorResponse, example = json!({"message": "Email уже занят"}))
)
)]
async fn create_user(Json(payload): Json<CreateUserRequest>) -> impl IntoResponse {
// ...
}
4. Используйте расширенные валидации
#[derive(Deserialize, ToSchema)]
struct CreateUserRequest {
#[schema(example = "Иван Иванов", min_length = 2, max_length = 100)]
name: String,
#[schema(example = "ivan@example.com", format = "email", pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")]
email: String,
#[schema(example = "password123", min_length = 8, max_length = 100)]
password: String,
#[schema(nullable = true, example = "Администратор")]
role: Option<String>,
}
5. Отделите спецификацию от реализации
Выделите создание OpenAPI спецификации в отдельный модуль:
// openapi.rs
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
info(title = "Axum API", version = "1.0.0"),
paths(
handlers::users::get_users,
handlers::users::get_user_by_id,
// ...
),
components(
schemas(models::User, models::CreateUserRequest)
)
)]
pub struct ApiDoc;
// main.rs
mod openapi;
#[tokio::main]
async fn main() {
let app = Router::new()
// ...
.merge(
SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", openapi::ApiDoc::openapi())
);
// ...
}
6. Версионирование API
// Для API v1
#[derive(OpenApi)]
#[openapi(
info(title = "My API v1", version = "1.0.0"),
paths(
api_v1::get_users,
api_v1::get_user_by_id,
),
components(
schemas(models_v1::User, models_v1::CreateUserRequest)
)
)]
pub struct ApiDocV1;
// Для API v2
#[derive(OpenApi)]
#[openapi(
info(title = "My API v2", version = "2.0.0"),
paths(
api_v2::get_users,
api_v2::get_user_by_id,
),
components(
schemas(models_v2::User, models_v2::CreateUserRequest)
)
)]
pub struct ApiDocV2;
// Маршрутизация в main.rs
let app = Router::new()
.nest("/api/v1", api_v1_router)
.nest("/api/v2", api_v2_router)
.merge(
SwaggerUi::new("/swagger-ui")
.url("/api-docs/v1/openapi.json", ApiDocV1::openapi())
.url("/api-docs/v2/openapi.json", ApiDocV2::openapi())
);
7. Интеграция с аутентификацией
#[utoipa::path(
get,
path = "/protected",
tag = "Защищенные ресурсы",
security(
("jwt" = [])
),
responses(
(status = 200, description = "Доступ разрешен"),
(status = 401, description = "Не авторизован"),
(status = 403, description = "Доступ запрещен")
)
)]
async fn protected_endpoint() -> impl IntoResponse {
// ...
}
// В определении OpenAPI добавляем схему безопасности
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
// JWT авторизация
components.add_security_scheme(
"jwt",
SecurityScheme::Http(utoipa::openapi::security::Http::new(
utoipa::openapi::security::HttpAuthScheme::Bearer,
Some("JWT"),
))
);
// API-ключ
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(
ApiKeyValue::new("X-API-Key")
))
);
}
}
Интеграция Axum с OpenAPI/Swagger предоставляет мощные возможности для автоматической генерации документации API, которая всегда остается актуальной и соответствует реализации. Это повышает удобство использования API и упрощает процесс разработки.