use super::naming::{literal, pascal, snake};
use super::types::base_type;
use crate::database::introspection::{Schema, Table};
pub(super) const ROUTE_PREFIX: &str = "/api";
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),
)
}
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",
)
}
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",
)
}