A FastAPI-inspired Rust web framework with automatic OpenAPI/Swagger documentation generation.
- Concept: Rust Performance × FastAPI DX
- OpenAPI:
GET /openapi.json - Docs UI:
GET /docs(default: Embedded Scalar) - ReDoc UI:
GET /redoc
- Keep FastAPI-like ergonomics (
#[get],#[post],#[api_model]) while shipping as a native Rust binary. - Generate OpenAPI + docs UI automatically, so the API contract stays close to your code.
- Use extractors, DI, validation, and response shaping without leaving idiomatic Rust.
cargo new ultraapi-hello && cd ultraapi-hello
cargo add ultraapi tokio --features tokio/fulluse ultraapi::prelude::*;
#[get("/health")]
async fn health() -> serde_json::Value {
serde_json::json!({ "status": "ok" })
}
#[tokio::main]
async fn main() {
UltraApiApp::new()
.title("UltraAPI Quickstart")
.version("0.1.0")
.route(__HAYAI_ROUTE_HEALTH)
.serve("0.0.0.0:3000")
.await;
}Then open:
http://localhost:3000/healthhttp://localhost:3000/openapi.jsonhttp://localhost:3000/docs
- Compatibility matrix: docs/compatibility-matrix.md
- Contributing guide: CONTRIBUTING.md
- Weekly release notes template: docs/release-notes-template.md
- Good first issues: label: good first issue
- CI status: badge at the top of this README
- FastAPI-style route definitions:
#[get],#[post],#[put],#[delete] - Automatic OpenAPI generation from serde/schemars: Generate schemas from
#[api_model]type definitions - Built-in /docs: Provides API reference UI out of the box (CDN Swagger UI also available)
- Automatic validation: Returns 422 (Unprocessable Entity) with
#[validate(...)] - DI (Dependency Injection):
Dep<T>,State<T>,Depends<T> - Router composition: Compose prefix / tags / security per router
- WebSocket / SSE:
#[ws],#[sse] - Lifespan hooks: startup/shutdown
UltraAPI supports hooks that run at application startup and shutdown.
use ultraapi::prelude::*;
let app = UltraApiApp::new()
.lifecycle(|lifecycle| {
lifecycle
.on_startup(|state| {
Box::pin(async move {
println!("Starting application...");
// Establish database connections
// Load templates
})
})
.on_shutdown(|state| {
Box::pin(async move {
println!("Shutting down application...");
// Close database connections
// Clean up resources
})
})
});When using the serve() method, the startup hook runs when the server starts, and the shutdown hook runs during graceful shutdown (Ctrl+C).
#[tokio::main]
async fn main() {
UltraApiApp::new()
.lifecycle(|l| l
.on_startup(|_| Box::pin(async { println!(" startup!"); }))
.on_shutdown(|_| Box::pin(async { println!(" shutdown!"); }))
)
.serve("0.0.0.0:3000")
.await;
}In tests, TestClient automatically manages the lifecycle. Startup runs on the first request, and shutdown runs when the test ends (on Drop).
#[tokio::test]
async fn test_my_api() {
let app = UltraApiApp::new()
.lifecycle(|l| l
.on_startup(|_| Box::pin(async { /* Test resources */ }))
.on_shutdown(|_| Box::pin(async { /* Cleanup */ }))
);
let client = TestClient::new(app).await;
// Execute request (startup runs at this point)
let response = client.get("/api/items").await;
// Shutdown is automatically called when test ends
// Or you can call it explicitly
client.shutdown().await;
}When using the router directly (for custom servers or other purposes), into_router() now enables lifespan compatibility mode by default (lazy startup + shutdown on router drop). If you need an explicit handle for deterministic shutdown timing, use into_router_with_lifespan().
let app = UltraApiApp::new()
.lifecycle(|l| l
.on_startup(|_| Box::pin(async { /* Startup logic */ }))
.on_shutdown(|_| Box::pin(async { /* Shutdown logic */ }))
);
let (router, runner) = app.into_router_with_lifespan();
// Start server using router...
// Example: axum::serve(listener, router).await
// Manually trigger shutdown on exit
runner.shutdown().await;- Preventing multiple executions: The startup hook runs only once on the first request. Internal locking prevents duplicate execution.
- Using with
into_router():into_router()enables lifespan compatibility mode (lazy startup + best-effort shutdown when the router is dropped). - Using with
into_router_with_lifespan(): Use this when you need an explicitLifespanRunnerand deterministicshutdown().awaittiming. - Lazy startup: With
into_router()/into_router_with_lifespan()andTestClient, startup runs on the first request (lazy startup).
[dependencies]
ultraapi = "0.1"use ultraapi::prelude::*;
/// User creation request
#[api_model]
#[derive(Debug, Clone)]
struct CreateUser {
#[validate(min_length = 1, max_length = 100)]
name: String,
#[validate(email)]
email: String,
}
/// User
#[api_model]
#[derive(Debug, Clone)]
struct User {
id: i64,
name: String,
email: String,
}use ultraapi::prelude::*;
#[post("/users")]
async fn create_user(body: CreateUser) -> User {
User { id: 1, name: body.name, email: body.email }
}
#[get("/users/{id}")]
async fn get_user(id: i64) -> Result<User, ApiError> {
Ok(User { id, name: "Alice".into(), email: "alice@example.com".into() })
}Macros like #[get] automatically generate route refs (__HAYAI_ROUTE_<FN>).
use ultraapi::prelude::*;
fn api() -> UltraApiRouter {
UltraApiRouter::new("/api")
.tag("users")
.route(__HAYAI_ROUTE_CREATE_USER)
.route(__HAYAI_ROUTE_GET_USER)
}
#[tokio::main]
async fn main() {
UltraApiApp::new()
.title("My API")
.version("1.0.0")
.include(api())
.serve("0.0.0.0:3000")
.await;
}After startup:
- OpenAPI:
GET /openapi.json - Docs:
GET /docs
UltraAPI includes a CLI tool (ultraapi command).
cargo install ultraapi-cliOr run directly from the ultraapi workspace:
cargo run --bin ultraapi -- --help# Run with default settings (0.0.0.0:3000)
ultraapi run ultraapi-example
# Specify host and port
ultraapi run ultraapi-example --host 127.0.0.1 --port 8080
# Enable verbose output
ultraapi -v run ultraapi-example --port 4000# Run in development mode (with auto-reload)
ultraapi dev ultraapi-example --host 0.0.0.0 --port 3001# Start examples/ultraapi-example on port 3001
cargo run --bin ultraapi -- run ultraapi-example --port 3001
# Start in development mode
cargo run --bin ultraapi -- dev ultraapi-example --port 3001- Routes:
#[get],#[post],#[put],#[delete],#[patch],#[head],#[options],#[trace] - Models:
#[api_model] - WebSocket:
#[ws] - SSE:
#[sse]
#[status(200)]etc.: Success status code#[tag("name")]: OpenAPI tag#[security("bearer")]: Security requirement (reflected in OpenAPI and auth middleware)#[security("basicAuth")]: Basic auth (reflected in OpenAPI and auth middleware)#[security("oauth2Password")]: OAuth2 Password Flow (reflected in OpenAPI)#[security("oauth2AuthCode")]: OAuth2 Authorization Code Flow (reflected in OpenAPI)#[security("oauth2Implicit")]: OAuth2 Implicit Flow (reflected in OpenAPI)#[security("bearer&&apiKeyAuth")]: AND requirement (single OpenAPI Security Requirement Object)#[security("bearer||apiKeyAuth")]: OR alternatives within one attribute (multiple#[security(...)]are also OR)#[dependencies(Depends<MyDep>, ...)]: Run route-level dependencies without adding handler args (FastAPI-style)
UltraAPI provides FastAPI-compatible OAuth2 dependency objects:
use ultraapi::prelude::*;
/// OAuth2PasswordBearer: auto_error=true (default)
/// Returns 401 error when token is missing
#[get("/protected")]
async fn protected_endpoint(token: OAuth2PasswordBearer) -> String {
format!("Token: {}", token.0)
}
/// OptionalOAuth2PasswordBearer: auto_error=false
/// Returns None instead of error when token is missing
#[get("/optional-protected")]
async fn optional_protected_endpoint(token: OptionalOAuth2PasswordBearer) -> String {
match token.0 {
Some(t) => format!("Token: {}", t),
None => "No token provided".to_string(),
}
}
/// OAuth2AuthorizationCodeBearer: For Authorization Code Flow
#[get("/auth-code-protected")]
async fn auth_code_protected_endpoint(token: OAuth2AuthorizationCodeBearer) -> String {
format!("Auth Code Token: {}", token.0)
}To use these dependency objects, you need to register the security scheme with the app:
let app = UltraApiApp::new()
.title("OAuth2 API")
.version("0.1.0")
.oauth2_password(
"oauth2Password",
"https://example.com/token",
[("read", "Read access"), ("write", "Write access")],
)
// Or
.oauth2_authorization_code(
"oauth2AuthCode",
"https://example.com/authorize",
"https://example.com/token",
[("read", "Read access")],
);OAuth2PasswordBearer/OptionalOAuth2PasswordBearer: For OAuth2 Password FlowOAuth2AuthorizationCodeBearer/OptionalOAuth2AuthorizationCodeBearer: For OAuth2 Authorization Code Flowauto_error=true(default): Returns 401 when token is missingauto_error=false(Optional* versions): Returns 200 with None when token is missing
UltraAPI provides types and helpers needed for OAuth2 production use. These are accessible from ultraapi::oauth2 or ultraapi::prelude.
For a complete guide on JWT authentication with AuthLayer validator integration, see docs/jwt.md.
use ultraapi::oauth2::{
OAuth2PasswordRequestForm,
TokenResponse,
OAuth2ErrorResponse,
OpaqueTokenValidator,
};use ultraapi::prelude::*;
use ultraapi::middleware::create_bearer_auth_error;
#[post("/token")]
async fn token(
Form(form): Form<OAuth2PasswordRequestForm>,
) -> Result<Json<TokenResponse>, Json<OAuth2ErrorResponse>> {
// Only support password grant
if !form.is_password_grant() {
return Err(Json(OAuth2ErrorResponse::unsupported_grant_type()));
}
// User authentication (verify with database in practice)
let valid = verify_credentials(&form.username, &form.password);
if !valid {
return Err(Json(OAuth2ErrorResponse::invalid_grant(
"Invalid username or password"
)));
}
// Generate token
let access_token = generate_token(&form.username, form.scopes());
let response = TokenResponse::with_scopes(access_token, 3600, form.scopes());
Ok(Json(response))
}Use the OAuth2TokenValidator trait to implement your own token validator:
use ultraapi::middleware::{OAuth2TokenValidator, TokenData, OAuth2AuthError};
struct MyTokenValidator;
#[async_trait::async_trait]
impl OAuth2TokenValidator for MyTokenValidator {
async fn validate(&self, token: &str) -> Result<TokenData, OAuth2AuthError> {
// Implement your own validation logic
// (JWT decode, database lookup, Redis lookup, etc.)
Ok(TokenData::new("user123".to_string(), vec!["read".to_string()]))
}
}For testing and simple use cases, OpaqueTokenValidator is included:
use ultraapi::oauth2::OpaqueTokenValidator;
// Add tokens
let validator = OpaqueTokenValidator::new()
.add_token("valid-token-1", "user1", vec!["read".to_string()])
.add_token("valid-token-2", "user2", vec!["read".to_string(), "write".to_string()]);
// Validate tokens
let result = validator.validate("valid-token-1").await;
match result {
Ok(token_data) => {
println!("User: {}", token_data.sub);
println!("Scopes: {:?}", token_data.scopes());
}
Err(e) => {
println!("Invalid token: {}", e);
}
}
// Validate scopes
let token_data = validator.validate("valid-token-2").await.unwrap();
let result = validator.validate_scopes(&token_data, &["read".to_string()]);
// result Ok if user has "read" scope| Type | Description |
|---|---|
OAuth2PasswordRequestForm |
Password flow request form |
TokenResponse |
Success token response |
OAuth2ErrorResponse |
RFC 6749 compliant error response |
TokenData |
Validated token data |
OAuth2AuthError |
Token validation error |
OAuth2TokenValidator |
Validator trait |
OpaqueTokenValidator |
Example opaque token validator implementation |
create_bearer_auth_error |
Bearer auth error response helper |
When using #[security("oauth2Password")]:
- oauth2Password is added to OpenAPI securityScheme
- Middleware checks Authorization header and extracts Bearer token
- Token is passed to route as
OAuth2PasswordBearerdependency object - When using custom validator, configure
AuthLayerorAuthValidator
When scopes are required:
#[get("/admin")]
#[security("oauth2Password:admin")]
async fn admin_endpoint(token: OAuth2PasswordBearer) -> String {
// "admin" scope required
format!("Admin access for: {}", token.0)
}#[response_class("json"|"html"|"text"|"binary"|"stream"|"xml")]: content-type#[response_model(...)]: response shaping (include/exclude/by_alias)#[summary("...")]: OpenAPI summary#[external_docs(url = "...", description = "...")]: OpenAPI externalDocs#[deprecated]: OpenAPI deprecated
The following attributes are available for fields in structs with #[api_model]:
#[read_only]: Field included only in responses, not in requests (outputsreadOnly: truein OpenAPI)#[write_only]: Field included only in requests, not in responses (outputswriteOnly: truein OpenAPI)#[alias("name")]: Specify field serialization name (equivalent to serde'srename)
use ultraapi::prelude::*;
/// User creation request (password only needed in request)
#[api_model]
#[derive(Debug, Clone)]
struct CreateUser {
/// Username
name: String,
/// Password (request only, not returned in response)
#[write_only]
password: String,
}
/// User response (ID only returned in response)
#[api_model]
#[derive(Debug, Clone)]
struct User {
/// User ID (response only)
#[read_only]
id: i64,
/// Username
name: String,
}- Fields with
#[read_only]are ignored during request body deserialization - Fields with
#[write_only]are excluded during response serialization - OpenAPI Schema properties output
readOnly: true/writeOnly: truerespectively
Default is Embedded (Scalar). To load Swagger UI from CDN:
use ultraapi::prelude::*;
let app = UltraApiApp::new().swagger_cdn("https://unpkg.com/swagger-ui-dist@5");UltraAPI supports OpenAPI 3.1 webhooks and callbacks.
webhooksoutputs to top-levelwebhooksin OpenAPI speccallbacksoutputs tocallbacksof specific operations
These APIs add output to OpenAPI (they do not register to runtime router). However, whether routes are ultimately exposed depends on the app's routing method.
- explicit routing (using
.include(...)): Routes not included are not registered at runtime - implicit routing (using inventory full registration): Routes defined with
#[get]/#[post]etc. are registered at runtime
If you want to "include only in OpenAPI", use explicit routing and don't include(...) webhook/callback routes.
use ultraapi::prelude::*;
#[api_model]
#[derive(Debug, Clone)]
struct PaymentEvent {
event_type: String,
amount: f64,
}
#[post("/webhooks/payment")]
#[tag("webhooks")]
async fn payment_webhook(body: PaymentEvent) -> PaymentEvent {
body
}
let app = UltraApiApp::new()
.webhook("payment", __HAYAI_ROUTE_PAYMENT_WEBHOOK);use ultraapi::prelude::*;
#[api_model]
#[derive(Debug, Clone)]
struct Subscription {
id: i64,
plan: String,
}
#[api_model]
#[derive(Debug, Clone)]
struct SubscriptionEvent {
event_type: String,
subscription_id: i64,
}
#[post("/subscriptions")]
async fn create_subscription(body: Subscription) -> Subscription {
body
}
#[post("/webhooks/subscription")]
async fn subscription_callback(body: SubscriptionEvent) -> SubscriptionEvent {
body
}
let app = UltraApiApp::new().callback(
__HAYAI_ROUTE_CREATE_SUBSCRIPTION,
"subscriptionEvent",
"{$request.body#/callbackUrl}",
__HAYAI_ROUTE_SUBSCRIPTION_CALLBACK,
);For types with #[api_model], the following attributes are available:
#[validate(email)]#[validate(min_length = N)]#[validate(max_length = N)]#[validate(minimum = N)]#[validate(maximum = N)]#[validate(pattern = "...")]#[validate(min_items = N)]
Validation runs automatically during Query/Form/Body extraction, and validation failures return 422 (Unprocessable Entity).
Dep<T>/State<T>: Extract dependencies registered with the appDepends<T>: FastAPI-style dependencies (function-based)yield_depends: Dependencies with cleanup (scope: Function/Request)
UltraAPI supports FastAPI-like sub applications.
use ultraapi::prelude::*;
// Create sub app
let sub_app = UltraApiApp::new()
.title("Sub API")
.version("1.0.0");
// Mount to main app
let app = UltraApiApp::new()
.mount("/api", sub_app);Sub applications have the following characteristics:
- Their own
/docsand/openapi.jsonendpoints (/api/docs,/api/openapi.json) - Sub app routes are not included in main app's OpenAPI (separated)
- Share dependencies with main app
You can serve static files (images, CSS, JS, etc.):
use ultraapi::prelude::*;
let app = UltraApiApp::new()
.static_files("/static", "./static");- First argument: URL path prefix (e.g.,
/static) - Second argument: Path to directory to serve
You can render HTML templates (Jinja2 format):
use ultraapi::prelude::*;
use ultraapi::templates::{Templates, template_response};
// Set template directory
let app = UltraApiApp::new()
.templates_dir("./templates");
// Use templates in handler
#[get("/hello")]
async fn hello(templates: Dep<Templates>) -> impl IntoResponse {
template_response(&templates, "hello.html", serde_json::json!({ "name": "World" }))
}Template features:
Templates::new(dir)- Create Templates from template directoryTemplates::render(name, context)- Render templatetemplate_response(templates, name, context)- Generate HTML responseTemplateResponsetype implementsIntoResponse, automatically setstext/htmlcontent-type
UltraAPI provides StreamingResponse, achieving functionality equivalent to FastAPI's StreamingResponse. Use it when returning arbitrary streams as HTTP responses.
- Accepts any
impl Stream<Item = Result<Bytes, E>>orimpl Stream<Item = Bytes> - Content-Type (media_type) can be specified
- Custom headers can be added
- Status code can be specified
- Error handling: Errors in stream are logged and connection is closed
use ultraapi::prelude::*;
use tokio_stream::iter;
/// Stream endpoint
#[get("/stream")]
async fn stream_handler() -> StreamingResponse {
let stream = iter([
Ok::<_, std::convert::Infallible>(Bytes::from("chunk1\n")),
Ok(Bytes::from("chunk2\n")),
Ok(Bytes::from("chunk3\n")),
]);
StreamingResponse::from_infallible_stream(stream)
}use ultraapi::prelude::*;
use tokio_stream::iter;
/// Text stream
#[get("/stream/text")]
async fn text_stream() -> StreamingResponse {
let stream = iter([
Ok(Bytes::from("line1\n")),
Ok(Bytes::from("line2\n")),
Ok(Bytes::from("line3\n")),
]);
StreamingResponse::from_infallible_stream(stream)
.content_type("text/plain")
}use ultraapi::prelude::*;
use tokio_stream::iter;
/// Stream with custom headers
#[get("/stream/headers")]
async fn stream_with_headers() -> StreamingResponse {
let stream = iter([Ok(Bytes::from("data"))]);
StreamingResponse::from_infallible_stream(stream)
.header("X-Custom-Header", "custom-value")
.header("X-Request-Id", "12345")
}use ultraapi::prelude::*;
use tokio_stream::iter;
use axum::http::StatusCode;
/// Partial content response
#[get("/stream/partial")]
async fn partial_stream() -> StreamingResponse {
let stream = iter([Ok(Bytes::from("partial content"))]);
StreamingResponse::from_infallible_stream(stream)
.status(StatusCode::PARTIAL_CONTENT)
}use ultraapi::prelude::*;
use tokio_stream::iter;
use axum::http::StatusCode;
/// Stream with full options
#[get("/stream/full")]
async fn full_stream() -> StreamingResponse {
let stream = iter([Ok(Bytes::from("full response"))]);
StreamingResponse::from_infallible_stream(stream)
.content_type("application/json")
.header("X-Request-Id", "12345")
.status(StatusCode::OK)
}UltraAPI can add Set-Cookie headers to responses using CookieResponse<T>. Provides functionality similar to FastAPI's Response.set_cookie().
use ultraapi::prelude::*;
/// Login response
#[api_model]
#[derive(Debug, Clone)]
struct LoginResponse {
status: String,
}
/// Login page
#[post("/login")]
#[response_class("cookie")]
async fn login() -> CookieResponse<LoginResponse> {
CookieResponse::new(LoginResponse { status: "ok".to_string() })
.cookie("session", "abc123")
}Use the cookie_options method to set options like HttpOnly, Secure, SameSite, Path, Max-Age, Expires:
use ultraapi::prelude::*;
use time::OffsetDateTime;
/// Secure session cookie
#[post("/login/secure")]
#[response_class("cookie")]
async fn login_secure() -> CookieResponse<LoginResponse> {
// Set expiration to 7 days from now
let expires = OffsetDateTime::now_utc() + time::Duration::days(7);
CookieResponse::new(LoginResponse { status: "ok".to_string() })
.cookie_options("session", "abc123", |opts| {
opts.http_only() // Not accessible from JavaScript
.secure() // Only sent over HTTPS
.path("/") // Valid for entire site
.max_age(86400) // Valid for 24 hours
.expires(expires) // Or absolute datetime
})
}You can set multiple cookies:
use ultraapi::prelude::*;
#[post("/login")]
#[response_class("cookie")]
async fn login() -> CookieResponse<LoginResponse> {
CookieResponse::new(LoginResponse { status: "ok".to_string() })
.cookie("session", "abc123") // Basic cookie
.cookie("user_id", "42") // Multiple cookies
.cookie_options("theme", "dark", |opts| {
opts.same_site_lax() // SameSite=Lax
})
}http_only()- HttpOnly flag (blocks access from JavaScript)secure()- Secure flag (only sent over HTTPS)same_site_strict()- SameSite=Strictsame_site_lax()- SameSite=Laxsame_site_none()- SameSite=None (requires Secure)path(path)- Cookie pathmax_age(seconds)- Relative expiration (seconds)expires(datetime)- Absolute expiration (time::OffsetDateTime)
UltraAPI supports file upload using the Multipart extractor.
use ultraapi::prelude::*;
use axum::extract::Multipart;
/// Upload response
#[api_model]
#[derive(Debug, Clone, Serialize)]
struct UploadResponse {
filename: String,
content_type: String,
size: usize,
}
/// Single file upload endpoint
#[post("/upload")]
#[response_class("json")]
async fn upload_file(multipart: Multipart) -> Result<UploadResponse, ApiError> {
// Get first file field
let mut multipart = multipart;
let field = loop {
match multipart.next_field().await {
Ok(Some(f)) if f.file_name().is_some() => break f,
Ok(Some(_)) => continue, // Skip non-file fields
Ok(None) => return Err(ApiError::bad_request("File not found".to_string())),
Err(e) => return Err(ApiError::bad_request(format!("Invalid multipart: {}", e))),
}
};
let filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let data = field
.bytes()
.await
.map_err(|e| ApiError::bad_request(format!("Failed to read file: {}", e)))?;
let size = data.len();
Ok(UploadResponse {
filename,
content_type,
size,
})
}You can upload multiple files with the same field name:
use ultraapi::prelude::*;
use axum::extract::Multipart;
/// File info
#[api_model]
#[derive(Debug, Clone, Serialize)]
struct FileInfo {
filename: String,
content_type: String,
size: usize,
}
/// Multiple file upload response
#[api_model]
#[derive(Debug, Clone, Serialize)]
struct MultipleUploadResponse {
files: Vec<FileInfo>,
}
/// Multiple file upload endpoint
#[post("/upload/multiple")]
#[response_class("json")]
async fn upload_multiple_files(multipart: Multipart) -> Result<MultipleUploadResponse, ApiError> {
let mut multipart = multipart;
let mut files = Vec::new();
// Process all fields (files)
while let Some(field) = multipart.next_field().await.map_err(|e| ApiError::bad_request(format!("Invalid multipart: {}", e)))? {
let filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let data = field
.bytes()
.await
.map_err(|e| ApiError::bad_request(format!("Failed to read file: {}", e)))?;
let size = data.len();
files.push(FileInfo {
filename,
content_type,
size,
});
}
Ok(MultipleUploadResponse { files })
}You can send text fields and files simultaneously:
use ultraapi::prelude::*;
use axum::extract::Multipart;
#[post("/upload/with-meta")]
#[response_class("json")]
async fn upload_file_with_metadata(
multipart: Multipart,
) -> Result<UploadResponse, ApiError> {
let mut multipart = multipart;
let mut filename = "default.txt".to_string();
let mut content_type = "text/plain".to_string();
let mut size = 0usize;
while let Some(field) = multipart.next_field().await.map_err(|e| ApiError::bad_request(format!("Invalid multipart: {}", e)))? {
let field_name = field.name().unwrap_or_default();
if field_name == "description" {
// Skip description field
let _ = field.text().await;
} else if field_name == "file" {
// Process file field
filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let data = field
.bytes()
.await
.map_err(|e| ApiError::bad_request(format!("Failed to read file: {}", e)))?;
size = data.len();
}
}
Ok(UploadResponse {
filename,
content_type,
size,
})
}In UltraAPI, you can register error handlers to globally handle custom exceptions.
use ultraapi::prelude::*;
use axum::http::StatusCode;
/// Custom exception for business logic
#[derive(Debug, Clone)]
struct BusinessException {
code: String,
message: String,
}
impl BusinessException {
fn new(code: &str, message: &str) -> Self {
Self {
code: code.to_string(),
message: message.to_string(),
}
}
}use std::sync::Arc;
use axum::{body::Body, http::Request, response::IntoResponse, http::StatusCode};
fn make_error_handler() -> CustomErrorHandler {
Arc::new(|_state: AppState, _req: Request<Body>, error: Box<dyn std::any::Any + Send + 'static>| {
Box::pin(async move {
// Downcast and handle custom exception types
if let Some(ex) = error.downcast_ref::<BusinessException>() {
let body = serde_json::json!({
"error": "BusinessError",
"code": ex.code,
"message": ex.message
});
return (StatusCode::BAD_REQUEST, serde_json::to_string(&body).unwrap()).into_response();
}
// Default error response
(StatusCode::INTERNAL_SERVER_ERROR, r#"{"error":"Unknown error"}"#).into_response()
})
})
}
// Register error handler when creating app
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.error_handler_from_arc(make_error_handler());To prevent the entire server from crashing when a panic occurs, you can use the catch_panic() method:
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.catch_panic(); // Catch panics and return 500 errorYou can also combine error handler and panic catch:
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.error_handler_from_arc(make_error_handler())
.catch_panic();In UltraAPI, you can enable middleware that automatically compresses responses. When the client sends Accept-Encoding: gzip or Accept-Encoding: br, the server returns a compressed response.
use ultraapi::prelude::*;
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.gzip(); // Enable gzip + brotli compressionAs settings close to FastAPI's GZipMiddleware, you can specify minimum_size (minimum size to compress) and content_types to compress.
use ultraapi::prelude::*;
use ultraapi::middleware::GZipConfig;
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.gzip_config(
GZipConfig::new()
.minimum_size(1024)
.content_types(vec![
"text/*".to_string(),
"application/json".to_string(),
]),
);You can control compression algorithms individually:
use ultraapi::prelude::*;
use ultraapi::middleware::CompressionConfig;
let app = UltraApiApp::new()
.title("My API")
.version("1.0.0")
.compression(
CompressionConfig::new()
.gzip(true) // Enable gzip
.brotli(false) // Disable brotli
.deflate(false) // Disable deflate
);- If client doesn't send
Accept-Encodingheader, compression is not performed - Small responses (below default threshold) may not be compressed
- Not compressed when
Accept-Encoding: identity
UltraAPI includes a FastAPI-like TestClient. You can test HTTP requests without manually starting a server.
use ultraapi::prelude::*;
// Model definition
#[api_model]
#[derive(Debug, Clone)]
struct User {
id: i64,
name: String,
}
// Route definition
#[get("/users/{id}")]
async fn get_user(id: i64) -> User {
User { id, name: "Alice".to_string() }
}
// Test
#[tokio::test]
async fn test_get_user() {
let app = UltraApiApp::new();
let client = TestClient::new(app).await;
let response = client.get("/users/42").await;
assert_eq!(response.status(), 200);
let user: User = response.json().await.unwrap();
assert_eq!(user.id, 42);
}get(path)- GET requestpost(path, &body)- POST request (JSON)put(path, &body)- PUT request (JSON)delete(path)- DELETE requestpatch(path, &body)- PATCH request (JSON)head(path)- HEAD requestclient()- Get underlyingreqwest::Client(for custom requests)
// From UltraApiApp
let app = UltraApiApp::new().title("My API");
let client = TestClient::new(app).await;
// From Router
let router = UltraApiApp::new().into_router();
let client = TestClient::new_router(router).await;#[get(path)]- Register a GET endpoint#[post(path)]- Register a POST endpoint#[put(path)]- Register a PUT endpoint#[delete(path)]- Register a DELETE endpoint#[api_model]- Generate validation and OpenAPI schema for a struct/enum#[status(N)]- Set custom HTTP status code for a route#[tag("name")]- Add tags for OpenAPI grouping#[security("scheme")]- Apply security scheme to a route#[response_class("json"|"html"|"text"|"binary"|"stream"|"xml")]- Set response content type
UltraAPI supports FastAPI-like response model shaping with include, exclude, and by_alias options:
use ultraapi::prelude::*;
#[api_model]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct UserProfile {
id: i64,
username: String,
email: String,
password_hash: String,
created_at: String,
is_admin: bool,
}
// Include only specific fields in the response
#[get("/users/{id}/public", response_model(include={"id", "username"}))]
async fn get_public_profile(id: i64) -> UserProfile {
UserProfile {
id,
username: "alice".into(),
email: "alice@example.com".into(),
password_hash: "secret".into(),
created_at: "2024-01-01".into(),
is_admin: false,
}
}
// Exclude sensitive fields from the response
#[get("/users/{id}/profile", response_model(exclude={"password_hash"}))]
async fn get_user_profile(id: i64) -> UserProfile {
UserProfile {
id,
username: "alice".into(),
email: "alice@example.com".into(),
password_hash: "secret".into(),
created_at: "2024-01-01".into(),
is_admin: false,
}
}
// Use alias names (from serde(rename)) for serialization
#[get("/users/{id}/api", response_model(by_alias=true))]
async fn get_user_api(id: i64) -> UserProfile {
UserProfile {
id,
username: "alice".into(),
email: "alice@example.com".into(),
password_hash: "secret".into(),
created_at: "2024-01-01".into(),
is_admin: false,
}
}
// Combine include and exclude (include takes precedence)
#[get("/users/{id}/summary", response_model(include={"id", "username"}, exclude={"email"}))]
async fn get_user_summary(id: i64) -> UserProfile {
UserProfile {
id,
username: "alice".into(),
email: "alice@example.com".into(),
password_hash: "secret".into(),
created_at: "2024-01-01".into(),
is_admin: false,
}
}
// Nested include/exclude (FastAPI-style dict/set mix)
#[get(
"/orders/{id}",
response_model(
include={"order_id", "customer": {"email"}, "items": {"__all__": {"sku"}}},
exclude={"customer": {"password_hash"}}
)
)]
async fn get_order(id: i64) -> serde_json::Value {
# let _ = id;
# serde_json::json!({})
}response_model options currently behave as follows:
include/exclude/by_alias: fully supported in runtime shaping (including nested FastAPI-style dict/set selectors, e.g.include={"customer": {"email"}, "items": {"__all__": {"sku"}}}).exclude_none=true: removesnullfields recursively from runtime JSON output.exclude_unset=true: for#[api_model]responses, uses explicit field-set metadata when available (omitted fields are removed, explicitly providednull/empty values are kept). For plain JSON values without field-set metadata, key-presence behavior is used.exclude_defaults=true: removes fields only when their value exactly matches declared field defaults (#[api_model]metadata from#[serde(default)]/#[serde(default = "...")]).
Notes on FastAPI compatibility:
- FastAPI's
exclude_unsetuses model-level field-set metadata. - UltraAPI now consumes field-set metadata for
#[api_model]response shaping paths where metadata is available. exclude_defaultsno longer uses falsy-value heuristics; without field-default metadata, values are kept.
UltraAPI supports custom field attributes for controlling serialization behavior:
use ultraapi::prelude::*;
#[api_model]
#[derive(Debug, Clone)]
struct UserResponse {
// Field alias - serializes with the alias name
#[alias("userId")]
user_id: i64,
// Skip serialization (field not included in JSON output)
#[skip_serializing]
internal_note: String,
// Skip deserialization (field uses default value when parsing JSON)
#[skip_deserializing]
computed_field: String,
// Skip both serialization and deserialization
#[skip]
private_data: String,
}
// Standard serde attributes are also supported:
#[api_model]
#[derive(Debug, Clone)]
struct LegacyResponse {
#[serde(rename = "userId")]
user_id: i64,
#[serde(skip_serializing)]
internal: String,
#[serde(skip)]
hidden: String,
}Note: When using #[skip_deserializing], the field will receive its type's default value (e.g., empty String, 0 for integers) when deserializing, regardless of any value present in the JSON input.
UltraAPI supports FastAPI-like read_only and write_only field attributes for automatic input/output schema separation:
use ultraapi::prelude::*;
#[api_model]
struct User {
/// User ID (only in response - read only)
#[read_only]
id: i64,
/// Username (in both request and response)
username: String,
/// Password (only in request - write only)
#[write_only]
password: String,
/// Email (in both request and response)
email: String,
}Behavior:
#[read_only]: Field appears in responses but NOT in request bodies (setsreadOnly: truein OpenAPI, addsskip_deserializingto serde)#[write_only]: Field appears in requests but NOT in responses (setswriteOnly: truein OpenAPI, addsskip_serializingto serde)
This is useful for:
- Password fields that should be accepted in create requests but never returned
- Auto-generated IDs that are returned but never accepted as input
- Internal timestamps or computed fields
Caveats:
- The
by_alias=trueoption inresponse_modelworks with both#[alias(...)]and#[serde(rename = "...")]attributes - Fields marked with
#[skip_serializing]are still included in the OpenAPI schema (since they can still be deserialized) - For complete control over schema generation, use response_model
include/excludeoptions at the route level
Note: The include/exclude filtering works recursively on nested objects and arrays. When both include and exclude are specified, include takes precedence.
UltraAPI supports specifying different response content types using the response_class attribute. This controls both the runtime response Content-Type header and the OpenAPI specification:
use ultraapi::prelude::*;
// Default JSON response (implicit)
#[get("/users/{id}")]
async fn get_user(id: i64) -> User {
User { id, name: "Alice".into() }
}
// Explicit JSON response
#[get("/users/{id}/json", response_class("json"))]
async fn get_user_json(id: i64) -> User {
User { id, name: "Alice".into() }
}
// HTML response
#[get("/html")]
#[response_class("html")]
async fn get_html() -> String {
"<html><body><h1>Hello</h1></body></html>".to_string()
}
// Plain text response
#[get("/text")]
#[response_class("text")]
async fn get_text() -> String {
"Plain text content".to_string()
}
// Binary/octet-stream response
#[get("/download")]
#[response_class("binary")]
async fn download_file() -> Vec<u8> {
vec![0x00, 0x01, 0x02, 0xFF]
}
// Streaming response (also application/octet-stream)
#[get("/stream")]
#[response_class("stream")]
async fn stream_data() -> String {
"Streaming content".to_string()
}
// XML response
#[get("/data.xml")]
#[response_class("xml")]
async fn get_xml() -> String {
"<data><item>value</item></data>".to_string()
}Valid response_class values:
"json"- Default, returnsapplication/json"html"- Returnstext/html"text"- Returnstext/plain"binary"- Returnsapplication/octet-stream"stream"- Returnsapplication/octet-stream(for streaming responses)"xml"- Returnsapplication/xml
The OpenAPI specification will automatically reflect the correct content-type for each endpoint.
UltraAPI supports multiple security schemes for OpenAPI documentation:
use ultraapi::prelude::*;
// Bearer Authentication (JWT)
let app = UltraApiApp::new()
.bearer_auth();
// API Key Authentication
let app = UltraApiApp::new()
.api_key("apiKeyAuth", "X-API-Key", "header");
// OAuth2 - Implicit Flow
let app = UltraApiApp::new()
.oauth2_implicit(
"oauth2Implicit",
"https://example.com/authorize",
[("read", "Read access"), ("write", "Write access")],
);
// OAuth2 - Password Flow
let app = UltraApiApp::new()
.oauth2_password(
"oauth2Password",
"https://example.com/token",
[("read", "Read access"), ("write", "Write access")],
);
// OAuth2 - Client Credentials Flow
let app = UltraApiApp::new()
.oauth2_client_credentials(
"oauth2ClientCredentials",
"https://example.com/token",
[("read", "Read access")],
);
// OAuth2 - Authorization Code Flow
let app = UltraApiApp::new()
.oauth2_authorization_code(
"oauth2AuthCode",
"https://example.com/authorize",
"https://example.com/token",
[("read", "Read access"), ("write", "Write access")],
);
// OpenID Connect
let app = UltraApiApp::new()
.openid_connect("oidc", "https://example.com/.well-known/openid-configuration");
// Protect routes with #[security("schemeName")]
#[get("/protected")]
#[security("oauth2AuthCode")]
async fn protected_route() -> String {
"secret data".to_string()
}UltraAPI supports runtime enforcement of security requirements via middleware.
Default behavior is now secure-by-default: when routes declare
#[security(...)], auth middleware is auto-enabled to prevent accidental protection gaps.
use ultraapi::prelude::*;
use ultraapi::middleware::{AuthDefaultPolicy, SecuritySchemeConfig, ScopedAuthValidator, MockAuthValidator};
// Explicit enable (still supported)
let app = UltraApiApp::new()
.title("Secure API")
.version("1.0.0")
.bearer_auth()
.middleware(|builder| {
builder.enable_auth() // Enforce #[security] routes at runtime
});
// Legacy compatibility: require explicit auth enablement
let legacy_mode = UltraApiApp::new()
.bearer_auth()
.middleware(|builder| builder.auth_default_policy(AuthDefaultPolicy::ExplicitOnly));
// With custom API keys
let app = UltraApiApp::new()
.api_key("apiKeyAuth", "X-API-Key", "header")
.middleware(|builder| {
builder.enable_auth_with_api_keys(vec!["my-secret-key".to_string()])
});
// API Key in query parameter
let app = UltraApiApp::new()
.security_scheme(
"apiKeyAuth",
ultraapi::openapi::SecurityScheme::ApiKey {
name: "api_key".to_string(),
location: "query".to_string(),
},
)
.middleware(|builder| {
builder
.enable_auth_with_api_keys(vec!["valid-key".to_string()])
.with_security_scheme(
SecuritySchemeConfig::api_key_query("apiKeyAuth", "api_key")
)
});
// API Key in cookie
let app = UltraApiApp::new()
.security_scheme(
"apiKeyAuth",
ultraapi::openapi::SecurityScheme::ApiKey {
name: "session".to_string(),
location: "cookie".to_string(),
},
)
.middleware(|builder| {
builder
.enable_auth_with_api_keys(vec!["session-key".to_string()])
.with_security_scheme(
SecuritySchemeConfig::api_key_cookie("apiKeyAuth", "session")
)
});
// With scope-based authorization
let validator = ScopedAuthValidator::new(MockAuthValidator::new())
.with_scope("admin-token", vec!["read".to_string(), "write".to_string(), "admin".to_string()]);
let app = UltraApiApp::new()
.bearer_auth()
.middleware(|builder| {
builder
.enable_auth_with_validator(validator)
.with_security_scheme(
SecuritySchemeConfig::bearer("bearerAuth")
.with_scopes(vec!["admin".to_string()])
)
});
// Protect routes with scopes
#[get("/admin-only")]
#[security("bearerAuth")]
async fn admin_route() -> String {
"admin data".to_string()
}Dep<T>- Inject dependencies registered with.dep()State<T>- Inject app state with type safetyDepends<T>- FastAPI-style dependency injection with nested support
UltraAPI supports generator-based dependencies with cleanup hooks, similar to FastAPI's yield dependencies:
use ultraapi::prelude::*;
use std::sync::Arc;
// Define a resource with cleanup
struct DatabasePool { connection_string: String }
#[async_trait::async_trait]
impl Generator for DatabasePool {
type Output = Self;
type Error = DependencyError;
async fn generate(self: Arc<Self>, _scope: Scope) -> Result<Self::Output, Self::Error> {
// Setup: connect to database
Ok(Arc::try_unwrap(self).unwrap_or_else(|a| (*a).clone()))
}
async fn cleanup(self: Arc<Self>) -> Result<(), Self::Error> {
// Cleanup: close connection
println!("Closing database connection");
Ok(())
}
}
// Register with function scope (cleanup runs before response)
let app = UltraApiApp::new()
.yield_depends(Arc::new(DatabasePool { connection_string: "...".into() }), Scope::Function);
// Or with request scope (cleanup runs after response)
let app = UltraApiApp::new()
.yield_depends(Arc::new(DatabasePool { connection_string: "...".into() }), Scope::Request);- Function scope: Cleanup runs before the handler returns its response
- Request scope: Cleanup runs after the entire request handling completes, and each request gets a fresh dependency instance
#[validate(email)]- Validate as email address#[validate(min_length = N)]- Minimum string length#[validate(max_length = N)]- Maximum string length#[validate(minimum = N)]- Minimum numeric value#[validate(maximum = N)]- Maximum numeric value#[validate(pattern = "regex")]- Pattern match#[validate(min_items = N)]- Minimum array length
#[read_only]- Field appears only in responses (not in request bodies)
(Derived from#[serde(skip_deserializing)])#[write_only]- Field appears only in requests (not in responses) (Derived from#[serde(skip_serializing)])
Note: OpenAPI
readOnlyandwriteOnlyproperties are automatically extracted from schemars metadata.
GET /openapi.json- Raw OpenAPI 3.1 specGET /docs- Swagger UI
UltraAPI includes golden tests to ensure OpenAPI output parity with FastAPI. These tests compare generated OpenAPI schemas against a known-good snapshot to catch regressions.
Test file: ultraapi/tests/openapi_fastapi_parity_tests.rs
Golden file: ultraapi/tests/golden/openapi_fastapi_parity.json
The test validates:
- Path operations (GET, POST, PUT, DELETE)
- Path parameters with proper
in: pathspecification - Query parameters from struct extractors
- Request body schemas with validation constraints (
minLength,maxLength,minimum,pattern) - Response schemas with
$refto components/schemas - Components/schemas with proper type definitions
To update the golden file (after intentional OpenAPI output changes):
cd ultraapi
UPDATE_GOLDEN=1 cargo test test_openapi_fastapi_parity_regenerateThis will regenerate the golden snapshot at tests/golden/openapi_fastapi_parity.json. Review the diff to ensure changes are intentional, then commit the updated golden file.
examples/ultraapi-example- Basic UltraAPI usageexamples/grpc-example- gRPC integrationexamples/graphql-example- GraphQL integration (async-graphql)examples/sqlx-example- SQLx (ORM) integration
UltraAPI provides official examples for popular integrations:
| Integration | Example | Documentation |
|---|---|---|
| GraphQL | examples/graphql-example |
docs/graphql.md |
| SQLx (ORM) | examples/sqlx-example |
docs/sqlx.md |
- ✅ FastAPI-style route macros (
#[get],#[post],#[put],#[delete],#[patch],#[head],#[options],#[trace]) - ✅ Automatic OpenAPI 3.1 generation
- ✅ Built-in Swagger UI (
/docs) and ReDoc (/redoc) - ✅ serde/schemars integration for schema generation
- ✅ Automatic validation with
#[validate]attributes - ✅ Dependency injection (
Dep<T>,State<T>,Depends<T>) - ✅ Yield dependencies with cleanup (Function/Request scope)
- ✅ Router composition with prefix/tags/security propagation
- ✅ Bearer authentication (JWT)
- ✅ API Key authentication (header/query/cookie)
- ✅ OAuth2 flows (Implicit, Password, Client Credentials, Authorization Code)
- ✅ OpenID Connect
- ✅ Runtime auth enforcement via middleware
- ✅ Scope-based authorization
- ✅ OAuth2 dependency objects (
OAuth2PasswordBearer,OptionalOAuth2PasswordBearer, etc.) - ✅ OAuth2 production components (
OAuth2PasswordRequestForm,TokenResponse, etc.)
- ✅ Response model shaping (include/exclude/by_alias)
- ✅ Response class specification (json/html/text/binary/stream/xml)
- ✅ Field-level attributes (
#[read_only],#[write_only],#[alias]) - ✅ Custom status codes via
#[status] - ✅ Global error handling with custom exceptions
- ✅ Panic catching
- ✅ Response compression (GZip/Brotli)
- ✅ StreamingResponse for streaming data
- ✅ CookieResponse for setting cookies
- ✅ GraphQL helpers (optional feature:
graphql) — seedocs/graphql.md - ✅ SQLx integration (ORM with SQLite/PostgreSQL/MySQL) — see
docs/sqlx.md - ✅ Lifespan hooks (startup/shutdown) with 3 usage patterns
- ✅ WebSocket support (
#[ws]) - ✅ SSE support (
#[sse]) - ✅ Webhooks (OpenAPI 3.1)
- ✅ Callbacks (OpenAPI 3.1)
- ✅ Sub applications (mount)
- ✅ Static files serving
- ✅ Jinja2-style templates
- ✅ File upload (Multipart)
- ✅ TestClient for testing
- ✅ CLI (
ultraapicommand) for running applications - ✅ Development mode
- ✅ Golden tests for OpenAPI parity with FastAPI
MIT