Rust poem-openapi: endpoints in multiple files

4 min read 05-10-2024
Rust poem-openapi: endpoints in multiple files


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 endpoints
  • post.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.