rustyroad 1.7.2

Rusty Road is a framework written in Rust that is based on Ruby on Rails. It is designed to provide the familiar conventions and ease of use of Ruby on Rails, while also taking advantage of the performance and efficiency of Rust.
Documentation
//! Actix CRUD procedure rendering.

use super::naming::{literal, pascal, snake};
use super::types::base_type;
use crate::database::introspection::{Schema, Table};

/// Prefix shared with the TypeScript API target and generated OpenAPI document.
pub(super) const ROUTE_PREFIX: &str = "/api";

/// Renders `procedures.rs`.
pub(super) fn render(schema: &Schema) -> String {
    let tables = schema
        .tables
        .iter()
        .filter(|table| table.has_simple_key())
        .collect::<Vec<_>>();
    let mut output = preamble();
    for table in &tables {
        output.push_str(&handlers(table, schema));
    }
    output.push_str(&configuration(&tables));
    output
}

fn preamble() -> String {
    String::from(
        "// Generated by `rustyroad pull --language rust`; do not edit.\n\n\
         use actix_web::{http::StatusCode, web, HttpResponse, ResponseError};\n\
         use sqlx::PgPool;\n\n\
         use super::{models::*, repositories};\n\n\
         /// Error response shared by every generated procedure.\n\
         #[derive(Debug, serde::Serialize)]\n\
         struct ErrorBody {\n\
             error: String,\n\
         }\n\n\
         /// Failures returned by generated procedures.\n\
         #[derive(Debug)]\n\
         pub enum ApiError {\n\
             NotFound(&'static str),\n\
             Database(sqlx::Error),\n\
         }\n\n\
         impl std::fmt::Display for ApiError {\n\
             fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n\
                 match self {\n\
                     Self::NotFound(entity) => write!(formatter, \"{entity} not found\"),\n\
                     Self::Database(_) => formatter.write_str(\"database operation failed\"),\n\
                 }\n\
             }\n\
         }\n\n\
         impl std::error::Error for ApiError {\n\
             fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n\
                 match self {\n\
                     Self::Database(error) => Some(error),\n\
                     Self::NotFound(_) => None,\n\
                 }\n\
             }\n\
         }\n\n\
         impl ResponseError for ApiError {\n\
             fn status_code(&self) -> StatusCode {\n\
                 match self {\n\
                     Self::NotFound(_) => StatusCode::NOT_FOUND,\n\
                     Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,\n\
                 }\n\
             }\n\n\
             fn error_response(&self) -> HttpResponse {\n\
                 HttpResponse::build(self.status_code()).json(ErrorBody { error: self.to_string() })\n\
             }\n\
         }\n\n\
         impl From<sqlx::Error> for ApiError {\n\
             fn from(error: sqlx::Error) -> Self {\n\
                 Self::Database(error)\n\
             }\n\
         }\n\n",
    )
}

fn handlers(table: &Table, schema: &Schema) -> String {
    let function = snake(&table.name);
    let model = pascal(&table.name);
    let key = table
        .column(&table.primary_key[0])
        .expect("introspected primary key should reference a column");
    let key_type = base_type(&key.sql_type, schema);

    format!(
        "/// `GET {prefix}/{table_name}`\n\
         async fn list_{function}(pool: web::Data<PgPool>) -> Result<web::Json<Vec<{model}>>, ApiError> {{\n\
             Ok(web::Json(repositories::list_{function}(pool.get_ref()).await?))\n\
         }}\n\n\
         /// `GET {prefix}/{table_name}/{{id}}`\n\
         async fn get_{function}(pool: web::Data<PgPool>, id: web::Path<{key_type}>) -> Result<web::Json<{model}>, ApiError> {{\n\
             repositories::find_{function}(pool.get_ref(), id.into_inner())\n\
                 .await?\n\
                 .map(web::Json)\n\
                 .ok_or(ApiError::NotFound({entity:?}))\n\
         }}\n\n\
         /// `POST {prefix}/{table_name}`\n\
         async fn create_{function}(pool: web::Data<PgPool>, input: web::Json<New{model}>) -> Result<(web::Json<{model}>, StatusCode), ApiError> {{\n\
             let row = repositories::create_{function}(pool.get_ref(), input.into_inner()).await?;\n\
             Ok((web::Json(row), StatusCode::CREATED))\n\
         }}\n\n\
         /// `PATCH {prefix}/{table_name}/{{id}}`\n\
         async fn update_{function}(pool: web::Data<PgPool>, id: web::Path<{key_type}>, input: web::Json<Patch{model}>) -> Result<web::Json<{model}>, ApiError> {{\n\
             repositories::update_{function}(pool.get_ref(), id.into_inner(), input.into_inner())\n\
                 .await?\n\
                 .map(web::Json)\n\
                 .ok_or(ApiError::NotFound({entity:?}))\n\
         }}\n\n\
         /// `DELETE {prefix}/{table_name}/{{id}}`\n\
         async fn delete_{function}(pool: web::Data<PgPool>, id: web::Path<{key_type}>) -> Result<HttpResponse, ApiError> {{\n\
             if repositories::delete_{function}(pool.get_ref(), id.into_inner()).await? {{\n\
                 Ok(HttpResponse::NoContent().finish())\n\
             }} else {{\n\
                 Err(ApiError::NotFound({entity:?}))\n\
             }}\n\
         }}\n\n",
        prefix = ROUTE_PREFIX,
        table_name = table.name,
        entity = model,
    )
}

fn configuration(tables: &[&Table]) -> String {
    let registrations = tables
        .iter()
        .map(|table| format!("        .configure(configure_{})", snake(&table.name)))
        .collect::<Vec<_>>()
        .join("\n");
    let table_configurations = tables
        .iter()
        .map(|table| {
            let function = snake(&table.name);
            format!(
                "fn configure_{function}(cfg: &mut web::ServiceConfig) {{\n\
                     cfg.service(\n\
                         web::resource({collection})\n\
                             .route(web::get().to(list_{function}))\n\
                             .route(web::post().to(create_{function})),\n\
                     )\n\
                     .service(\n\
                         web::resource({member})\n\
                             .route(web::get().to(get_{function}))\n\
                             .route(web::patch().to(update_{function}))\n\
                             .route(web::delete().to(delete_{function})),\n\
                     );\n\
                 }}\n",
                collection = literal(&format!("/{}", table.name)),
                member = literal(&format!("/{}/{{id}}", table.name)),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "/// Registers every generated CRUD procedure below `{prefix}`.\n\
         pub fn configure_generated(cfg: &mut web::ServiceConfig) {{\n\
             cfg.service(web::scope({prefix_literal})\n{registrations});\n\
         }}\n\n\
         {table_configurations}",
        prefix = ROUTE_PREFIX,
        prefix_literal = literal(ROUTE_PREFIX),
    )
}

/// Renders the developer-owned `api.rs` composition point.
pub(super) fn composition() -> String {
    String::from(
        "// Written once by `rustyroad pull --language rust`, then yours.\n\
         // Generated procedures are refreshed in procedures.rs; custom routes belong here.\n\n\
         use actix_web::web;\n\n\
         use super::procedures;\n\n\
         /// Registers generated procedures plus your hand-written services.\n\
         pub fn configure(cfg: &mut web::ServiceConfig) {\n\
             procedures::configure_generated(cfg);\n\
             // Add hand-written services here: cfg.service(my_service);\n\
         }\n",
    )
}

/// Renders the developer-owned module facade.
pub(super) fn module() -> String {
    String::from(
        "// Written once by `rustyroad pull --language rust`, then yours.\n\n\
         pub mod api;\n\
         pub mod models;\n\
         pub mod procedures;\n\
         pub mod repositories;\n\n\
         pub use api::configure;\n",
    )
}