salvo-openapi
Salvo OpenAPI Integration
[dependencies]
salvo = { version = "0.89.3", features = ["oapi"] }
serde = { version = "1", features = ["derive"] }
#[endpoint] vs #[handler]
#[handler] is the plain Salvo handler. #[endpoint] registers the same handler
AND generates OpenAPI operation metadata. Use #[endpoint] on any route you want
documented.
Auto-documentation rules
Documentation is only generated for things the macro can see at compile time:
- Parameters: use extractors (
PathParam<T>,QueryParam<T, REQUIRED>,JsonBody<T>,FormBody<T>, or a struct derivingToParameters).req.param()/req.query()are invisible to the generator. - Responses: the return type must implement
EndpointOutRegister.Json<T>(withT: ToSchema),StatusCode,&'static str,String,StatusError, andResult<T, E>where both implEndpointOutRegisterall work.
BAD (nothing documented):
#[endpoint]
async fn get_user(req: &mut Request) -> Json<User> {
let id = req.param::<i64>("id").unwrap();
let page = req.query::<i32>("page");
Json(User { /* ... */ })
}
GOOD (params + success + error all documented):
#[endpoint]
async fn get_user(
id: PathParam<i64>,
page: QueryParam<i32, false>,
) -> Result<Json<User>, StatusError> { /* ... */ }
Basic setup
use salvo::oapi::extract::*;
use salvo::prelude::*;
#[endpoint]
async fn hello(name: QueryParam<String, false>) -> String {
format!("Hello, {}!", name.as_deref().unwrap_or("World"))
}
#[tokio::main]
async fn main() {
let router = Router::new().push(Router::with_path("hello").get(hello));
let doc = OpenApi::new("My API", "1.0.0").merge_router(&router);
let router = router
.unshift(doc.into_router("/api-doc/openapi.json"))
.unshift(SwaggerUi::new("/api-doc/openapi.json").into_router("/swagger-ui"));
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Extractors
PathParam<T> is always required. QueryParam<T, REQUIRED> takes a const bool
(defaults to true). Call .into_inner() (or 0 for PathParam) to get the value.
use salvo::oapi::extract::{PathParam, QueryParam, JsonBody};
#[endpoint]
async fn get_user(id: PathParam<i64>) -> String {
format!("User ID: {}", id.into_inner())
}
#[endpoint]
async fn search(q: QueryParam<String, false>) -> String {
format!("Search: {}", q.as_deref().unwrap_or(""))
}
#[endpoint]
async fn create_user(user: JsonBody<CreateUser>) -> StatusCode {
let _u = user.into_inner();
StatusCode::CREATED
}
Schemas with ToSchema
Doc comments on fields become descriptions. Validation and examples use the
#[salvo(schema(...))] attribute.
use salvo::oapi::ToSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ToSchema)]
#[salvo(schema(example = json!({"id": 1, "name": "John", "email": "john@example.com"})))]
struct User {
id: i64,
/// User's full name
#[salvo(schema(pattern = "^[a-zA-Z ]+$"))]
name: String,
#[salvo(schema(format = "email"))]
email: String,
#[salvo(schema(minimum = 1, maximum = 150))]
age: Option<u8>,
}
Query structs with ToParameters
use salvo::oapi::ToParameters;
use serde::Deserialize;
#[derive(Deserialize, ToParameters)]
struct Pagination {
/// Page number
#[salvo(parameter(default = 1, minimum = 1))]
page: Option<u32>,
/// Items per page
#[salvo(parameter(default = 20, minimum = 1, maximum = 100))]
per_page: Option<u32>,
}
#[endpoint]
async fn list_users(pagination: Pagination) -> Json<Vec<User>> {
Json(vec![])
}
Endpoint attributes
Supported inside #[endpoint(...)]: operation_id, tags, summary, description,
request_body, responses, status_codes, parameters, security, deprecated.
#[derive(Serialize, ToSchema)]
struct ErrorResponse { message: String }
#[endpoint(
tags("users"),
summary = "Get user by ID",
description = "Returns a single user.",
status_codes(200, 404),
responses(
(status_code = 200, description = "User found", body = User),
(status_code = 404, description = "Not found", body = ErrorResponse),
)
)]
async fn get_user(id: PathParam<i64>) -> Result<Json<User>, StatusError> {
Ok(Json(User { /* ... */ }))
}
status_codes(...) filters the auto-generated responses to just the listed codes.
responses(...) adds explicit entries with custom schemas.
OpenApi metadata
OpenApi itself only exposes info(), servers(), security(),
add_security_scheme(), tags(), and merge_router(). There is no
description() / contact_name() / license_name() on OpenApi — set them on
Info/Contact/License and pass via .info(...).
use salvo::oapi::{OpenApi, Info, Contact, License};
let doc = OpenApi::new("My API", "1.0.0")
.info(
Info::new("My API", "1.0.0")
.description("A comprehensive user management API")
.contact(Contact::new().name("API Support").email("support@example.com"))
.license(License::new("MIT")),
)
.merge_router(&router);
Swagger UI
let router = router
.unshift(doc.into_router("/api-doc/openapi.json"))
.unshift(SwaggerUi::new("/api-doc/openapi.json").into_router("/swagger-ui"));
Security schemes
use salvo::oapi::security::{Http, HttpAuthScheme, SecurityScheme};
let doc = OpenApi::new("My API", "1.0.0")
.info(Info::new("My API", "1.0.0").description("API with authentication"))
.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
)
.merge_router(&router);
#[endpoint(security(("bearer_auth" = [])))]
async fn get_profile() -> &'static str { "Protected profile" }
File upload
#[derive(Serialize, ToSchema)]
struct UploadResponse { filename: String, size: u64 }
#[endpoint(tags("files"), request_body(content = "multipart/form-data"))]
async fn upload_file(req: &mut Request) -> Result<Json<UploadResponse>, StatusError> {
let file = req.file("file").await.ok_or_else(StatusError::bad_request)?;
Ok(Json(UploadResponse {
filename: file.name().unwrap_or("unnamed").to_string(),
size: file.size(),
}))
}
Because the body is raw multipart, the handler takes &mut Request directly. The
request_body attribute supplies the missing OpenAPI shape.
Custom error types for rich docs
Implement Writer + EndpointOutRegister to document a custom error variant:
use salvo::oapi::{Components, EndpointOutRegister, Operation, ToSchema};
use salvo::prelude::*;
use serde::Serialize;
#[derive(Debug, Serialize, ToSchema)]
struct ApiError { code: i32, message: String }
impl Writer for ApiError {
async fn write(self, _req: &mut Request, _depot: &mut Depot, res: &mut Response) {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Json(self));
}
}
impl EndpointOutRegister for ApiError {
fn register(components: &mut Components, operation: &mut Operation) {
operation.responses.insert(
"400".into(),
salvo::oapi::Response::new("Bad Request")
.add_content("application/json", Self::to_schema(components)),
);
}
}
#[endpoint]
async fn create_user(body: JsonBody<CreateUser>) -> Result<Json<User>, ApiError> {
/* ... */
}
Related Skills
- salvo-data-extraction: Extract parameters for documentation
- salvo-error-handling: Document error responses
- salvo-auth: Document security schemes
More from salvo-rs/salvo-skills
salvo-auth
Implement authentication and authorization using JWT, Basic Auth, or custom schemes. Use for securing API endpoints and user management.
15salvo-cors
Configure Cross-Origin Resource Sharing (CORS) and security headers. Use for APIs accessed from browsers on different domains.
15salvo-proxy
Implement reverse proxy to forward requests to backend services. Use for load balancing, API gateways, and microservices routing.
15salvo-tls-acme
Configure TLS/HTTPS with automatic certificate management via ACME (Let's Encrypt). Use for production deployments with secure connections.
14salvo-logging
Implement request logging, tracing, and observability. Use for debugging, monitoring, and production observability.
14salvo-error-handling
Handle errors gracefully with custom error types, status codes, and error pages. Use for building robust APIs with proper error responses.
14