restrepo 0.5.12

A collection of components for building restful webservices with actix-web
Documentation
//! Configuration and setup for json data services. Handles guard setup, content type specification and route scoping.
use std::sync::Arc;

use crate::{server::ApiErrorV1, service::ResourceController, utils::sanitize_path};
use actix_web::{Scope, web::JsonConfig};
use mime;
use tracing::{instrument, trace};

/// The top level api configuration type, setting up all resource controllers under a common base path.
/// Creates a default [JsonConfig], sets up [JsonContentGuard] and provides a builder like pattern
/// for setting up [ResourceControllers](ResourceController) and further configuration.  
/// To provision an [App](actix_web::App), use the [install_service](ApiService::install_service) method
/// with [App::configure](actix_web::App::configure).
///
/// #### Example
/// ```
/// use actix_web::{App, Responder, HttpResponse, http::Method, web::Path};
/// use restrepo::service::{ApiService, ResourceConfig, ResourceController};
///
/// struct Greeter;
///
/// impl Greeter {
///     async fn get_greeting(name: Path<String>) -> impl Responder {
///        HttpResponse::Ok().body(format!("Hello, {name}!"))
///     }
/// }
///
/// impl ResourceController for Greeter {
///     fn resources(&self) -> Vec<ResourceConfig> {
///         vec![
///             ResourceConfig::new("Greeter")
///                 .with_path("/{name}")
///                 .with_route(Method::GET, Self::get_greeting)
///         ]
///     }
///
///     fn path(&self) -> &str {
///         "/greet"
///     }
/// }
///
/// let api = ApiService::new("/api").register_controller(Greeter).build();
///
/// App::new().configure(|cfg| { cfg.service(api); });
///```
#[derive(Clone)]
pub struct ApiService {
    path: String,
    json_config: JsonConfig,
    controllers: Vec<Arc<dyn ResourceController + 'static>>,
}

impl ApiService {
    /// Create new [ApiService] at path
    pub fn new<P: Into<String>>(path: P) -> Self {
        Self {
            path: sanitize_path(path.into()),
            json_config: JsonConfig::default()
                .limit(5 * 1024)
                .content_type(|mime| mime.subtype() == mime::JSON)
                .error_handler(|err, _| ApiErrorV1::from(err).into()),
            controllers: Vec::new(),
        }
    }

    /// Set custom [JsonConfig].
    pub fn with_json_config(mut self, json_config: JsonConfig) -> Self {
        self.json_config = json_config;
        self
    }

    #[instrument(skip_all, name = "SERVICE_SETUP", level = "debug")]
    pub fn register_controller(mut self, controller: impl ResourceController + 'static) -> Self {
        trace!("Registering controller for: {}", controller.path());
        self.controllers.push(Arc::new(controller));
        self
    }

    pub fn build(&self) -> Scope {
        let mut api_scope = Scope::new(&self.path).app_data(self.json_config.clone());
        api_scope = self
            .controllers
            .clone()
            .into_iter()
            .fold(api_scope, |api_scope, controller| {
                api_scope.configure(|cfg| controller.controller_config(cfg))
            });
        api_scope
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{
        App, HttpResponse,
        http::StatusCode,
        middleware::ErrorHandlers,
        test::{self, TestRequest},
        web::{self, Json, to},
    };
    use serde_json::Value;

    #[actix_web::test]
    async fn json_extractor_configuration() {
        let app = test::init_service(
            App::new()
                .app_data(
                    JsonConfig::default()
                        .limit(5)
                        .content_type_required(true)
                        .content_type(|c| c == mime::TEXT_PLAIN),
                )
                .route(
                    "/foo/bar",
                    to(|data: Json<String>| async move { format!("Hi {data}") }),
                ),
        )
        .await;

        let req = TestRequest::post()
            .set_json("Hi")
            .insert_header(("content-type", "application/json "))
            .uri("/foo/bar");
        let res = test::call_service(&app, req.to_request()).await;
        assert_eq!(res.status(), StatusCode::BAD_REQUEST);

        let req = TestRequest::default().uri("/foo/bar");
        let res = test::call_service(
            &app,
            req.insert_header(("content-type", "application/json"))
                .to_request(),
        )
        .await;
        assert_eq!(res.status(), StatusCode::BAD_REQUEST);

        let req = TestRequest::post().uri("/foo/bar");
        let res = test::call_service(&app, req.set_json("Too large").to_request()).await;
        assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
        assert!(
            res.response()
                .headers()
                .get("content-type")
                .unwrap()
                .to_str()
                .unwrap()
                .starts_with("text/plain")
        );
    }

    #[actix_web::test]
    async fn json_service_errors() {
        let test_server = test::init_service(
            App::new()
                .wrap(ErrorHandlers::new().default_handler(crate::server::to_json_error_response))
                .route("/t/notfound", web::to(HttpResponse::NotFound))
                .route(
                    "/t/internalerror",
                    web::to(HttpResponse::InternalServerError),
                )
                .route("/t/paymentrequired", web::to(HttpResponse::PaymentRequired)),
        )
        .await;

        let notfound = test::TestRequest::get().uri("/t/notfound").to_request();
        let internalerror = test::TestRequest::get()
            .uri("/t/internalerror")
            .to_request();
        let paymentrequired = test::TestRequest::get()
            .uri("/t/paymentrequired")
            .to_request();

        let notfound_response: Value = test::call_and_read_body_json(&test_server, notfound).await;
        let paymentrequired_response: Value =
            test::call_and_read_body_json(&test_server, paymentrequired).await;
        let internalerror_response: Value =
            test::call_and_read_body_json(&test_server, internalerror).await;

        let notfound_json: serde_json::Value =
            serde_json::from_str(r#"{"status": 404,"message":"Not Found"}"#).unwrap();
        let internalerror_json: serde_json::Value =
            serde_json::from_str(r#"{"status": 500,"message":"Internal Server Error"}"#).unwrap();
        let paymentrequired_json: serde_json::Value =
            serde_json::from_str(r#"{"status": 402,"message":"Payment Required"}"#).unwrap();

        assert_eq!(notfound_json, notfound_response);
        assert_eq!(internalerror_json, internalerror_response);
        assert_eq!(paymentrequired_json, paymentrequired_response);
    }
}