Skip to content

OpenAPI/Swagger в Axum

OpenAPI (ранее известный как Swagger) — это спецификация для описания RESTful API. Интеграция Axum с OpenAPI позволяет автоматически генерировать документацию API и предоставлять интерактивный интерфейс для тестирования.

Содержание

Библиотеки и инструменты

Для интеграции Axum с OpenAPI мы будем использовать библиотеку utoipa, которая предоставляет макросы для аннотирования и генерации документации OpenAPI:

toml
[dependencies]
axum = "0.7.2"
utoipa = { version = "4.1.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "5.0.0", features = ["axum"] }

Базовая настройка

Для начала настроим базовую документацию OpenAPI:

rust
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();
}

Аннотирование маршрутов

Теперь определим модели и аннотируем обработчики:

rust
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()
    }
}

Документирование схем данных

Для более продвинутой документации схем данных можно использовать дополнительные аннотации:

rust
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()
}

Документирование параметров

Параметры запроса и пути также можно детально документировать:

rust
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>> {
    // Реализация с фильтрацией...
}

Расширенные возможности

Группировка по тегам

rust
#[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;

Документирование заголовков

rust
#[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 с дополнительными параметрами:

rust
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-точки по функциональным областям, используя теги:

rust
#[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:

rust
#[derive(ToSchema)]
#[schema(description = "Информация о пользователе системы")]
struct User {
    #[schema(description = "Уникальный идентификатор пользователя")]
    id: u64,
    
    #[schema(description = "Полное имя пользователя")]
    name: String,
    
    #[schema(description = "Адрес электронной почты, используется для входа в систему")]
    email: String,
}

3. Добавляйте примеры

rust
#[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. Используйте расширенные валидации

rust
#[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 спецификации в отдельный модуль:

rust
// 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

rust
// Для 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. Интеграция с аутентификацией

rust
#[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 и упрощает процесс разработки.