Organizing Your Rust API with poem-openapi: Modularizing Endpoints
Building a robust and scalable API in Rust with the powerful poem-openapi
framework is great, but as your project grows, managing all your endpoints in a single file can become unwieldy. This article dives into how to structure your API with multiple files for better organization and maintainability.
The Problem: A Monolithic API
Imagine you're building a REST API for a social media platform using poem-openapi
. You might start with a single file containing all your endpoints:
use poem::{
http::{Method, StatusCode},
web::Data,
EndpointExt,
IntoResponse,
Result,
Route,
};
use poem_openapi::{
payload::Json,
registry::Registry,
types::{ParseError, ToSchema},
OpenApi,
OpenApiError,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct User {
id: u32,
username: String,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct Post {
id: u32,
content: String,
author: u32,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct NewPost {
content: String,
author: u32,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct ErrorResponse {
code: u16,
message: String,
}
impl From<ParseError> for OpenApiError {
fn from(err: ParseError) -> Self {
OpenApiError::new(err.to_string(), StatusCode::BAD_REQUEST)
}
}
#[openapi]
pub struct Api {
#[oai(path = "/users/{id}", method = "GET")]
async fn get_user(&self, id: u32) -> Result<Json<User>> {
// ... implementation for retrieving user
}
#[oai(path = "/posts", method = "POST")]
async fn create_post(&self, new_post: Json<NewPost>) -> Result<Json<Post>> {
// ... implementation for creating a post
}
#[oai(path = "/posts/{id}", method = "GET")]
async fn get_post(&self, id: u32) -> Result<Json<Post>> {
// ... implementation for retrieving a post
}
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let api = Api {};
let registry = Registry::new().with_api(api);
let app = Route::new()
.nest("/", registry)
.data(registry);
poem::Server::new(app).run("127.0.0.1:3000").await
}
This code works, but as the API grows with more endpoints, data models, and logic, managing everything in a single file becomes cumbersome. We need a better way to organize.
The Solution: Divide and Conquer with Modules
The key to better organization is modularity. We can split our API into separate modules, each responsible for a specific part of the functionality. This makes the code more readable, maintainable, and easier to test.
Here's how we can refactor the previous code:
1. Create separate files for different API sections:
user.rs
for user-related endpointspost.rs
for post-related endpoints
2. Structure each module:
// user.rs
mod user {
use poem::{
http::{Method, StatusCode},
web::Data,
EndpointExt,
IntoResponse,
Result,
Route,
};
use poem_openapi::{
payload::Json,
registry::Registry,
types::{ParseError, ToSchema},
OpenApi,
OpenApiError,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct User {
id: u32,
username: String,
}
#[openapi]
pub struct UserApi {
#[oai(path = "/users/{id}", method = "GET")]
async fn get_user(&self, id: u32) -> Result<Json<User>> {
// ... implementation for retrieving user
}
}
pub fn routes() -> Route {
let api = UserApi {};
let registry = Registry::new().with_api(api);
Route::new().nest("/users", registry).data(registry)
}
}
// post.rs
mod post {
use poem::{
http::{Method, StatusCode},
web::Data,
EndpointExt,
IntoResponse,
Result,
Route,
};
use poem_openapi::{
payload::Json,
registry::Registry,
types::{ParseError, ToSchema},
OpenApi,
OpenApiError,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct Post {
id: u32,
content: String,
author: u32,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
struct NewPost {
content: String,
author: u32,
}
#[openapi]
pub struct PostApi {
#[oai(path = "/posts", method = "POST")]
async fn create_post(&self, new_post: Json<NewPost>) -> Result<Json<Post>> {
// ... implementation for creating a post
}
#[oai(path = "/posts/{id}", method = "GET")]
async fn get_post(&self, id: u32) -> Result<Json<Post>> {
// ... implementation for retrieving a post
}
}
pub fn routes() -> Route {
let api = PostApi {};
let registry = Registry::new().with_api(api);
Route::new().nest("/posts", registry).data(registry)
}
}
3. Combine the modules in your main file:
use poem::{
http::{Method, StatusCode},
web::Data,
EndpointExt,
IntoResponse,
Result,
Route,
};
use poem_openapi::{
payload::Json,
registry::Registry,
types::{ParseError, ToSchema},
OpenApi,
OpenApiError,
};
use serde::{Deserialize, Serialize};
mod post;
mod user;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let app = Route::new()
.nest("/", post::routes())
.nest("/", user::routes())
.data(Registry::new());
poem::Server::new(app).run("127.0.0.1:3000").await
}
Advantages of Modularization
- Improved Code Organization: Each module focuses on a specific part of the API, making the code easier to understand and navigate.
- Enhanced Maintainability: Changes to one module are less likely to affect others, reducing the risk of introducing bugs.
- Simplified Testing: You can test individual modules in isolation, speeding up your development process.
- Scalability: As your API grows, you can easily add new modules to handle new features without disrupting existing code.
Further Improvements
- Dependency Injection: Consider using a dependency injection framework to manage your API's dependencies, making your modules even more independent.
- Code Generation: Tools like
poem-openapi
can automatically generate documentation and client code based on your API definitions, saving time and reducing errors.
By organizing your Rust poem-openapi
API into multiple files, you gain a significant advantage in terms of code organization, maintainability, and scalability. This approach allows you to build complex APIs with greater ease and confidence.