From d8502e79e24e90de090ef0cd27678643cf83107f Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 08:48:23 +0700 Subject: [PATCH 01/30] feat(examples): add comprehensive feature examples Restructure examples to demonstrate each entity-derive feature: - basic: renamed from axum-crud, simple CRUD operations - filtering: #[filter], #[filter(like)], #[filter(range)] - relations: #[belongs_to], #[has_many] - events: #[entity(events)] with event enums - hooks: #[entity(hooks)] with lifecycle hooks - commands: #[entity(commands)], #[command] CQRS pattern - transactions: #[entity(transactions)] atomic operations - soft-delete: #[entity(soft_delete)] with restore - streams: #[entity(streams)] async streaming - full-app: complete e-commerce app with all features All examples have publish = false to exclude from crates.io. --- examples/{axum-crud => basic}/Cargo.toml | 3 +- examples/{axum-crud => basic}/README.md | 0 .../{axum-crud => basic}/docker-compose.yml | 0 .../migrations/001_create_users.sql | 0 examples/{axum-crud => basic}/src/main.rs | 0 examples/commands/Cargo.toml | 22 + .../20240101000000_create_accounts.sql | 9 + examples/commands/src/main.rs | 263 +++++++ examples/events/Cargo.toml | 21 + .../20240101000000_create_orders.sql | 17 + examples/events/src/main.rs | 265 +++++++ examples/filtering/Cargo.toml | 21 + .../20240101000000_create_products.sql | 27 + examples/filtering/src/main.rs | 216 ++++++ examples/full-app/Cargo.toml | 28 + .../20240101000000_create_schema.sql | 127 ++++ examples/full-app/src/main.rs | 702 ++++++++++++++++++ examples/hooks/Cargo.toml | 22 + .../20240101000000_create_users.sql | 9 + examples/hooks/src/main.rs | 288 +++++++ examples/relations/Cargo.toml | 21 + .../20240101000000_create_tables.sql | 43 ++ examples/relations/src/main.rs | 221 ++++++ examples/soft-delete/Cargo.toml | 21 + .../20240101000000_create_documents.sql | 19 + examples/soft-delete/src/main.rs | 267 +++++++ examples/streams/Cargo.toml | 23 + .../migrations/20240101000000_create_logs.sql | 24 + examples/streams/src/main.rs | 281 +++++++ examples/transactions/Cargo.toml | 22 + .../20240101000000_create_tables.sql | 26 + examples/transactions/src/main.rs | 286 +++++++ 32 files changed, 3293 insertions(+), 1 deletion(-) rename examples/{axum-crud => basic}/Cargo.toml (90%) rename examples/{axum-crud => basic}/README.md (100%) rename examples/{axum-crud => basic}/docker-compose.yml (100%) rename examples/{axum-crud => basic}/migrations/001_create_users.sql (100%) rename examples/{axum-crud => basic}/src/main.rs (100%) create mode 100644 examples/commands/Cargo.toml create mode 100644 examples/commands/migrations/20240101000000_create_accounts.sql create mode 100644 examples/commands/src/main.rs create mode 100644 examples/events/Cargo.toml create mode 100644 examples/events/migrations/20240101000000_create_orders.sql create mode 100644 examples/events/src/main.rs create mode 100644 examples/filtering/Cargo.toml create mode 100644 examples/filtering/migrations/20240101000000_create_products.sql create mode 100644 examples/filtering/src/main.rs create mode 100644 examples/full-app/Cargo.toml create mode 100644 examples/full-app/migrations/20240101000000_create_schema.sql create mode 100644 examples/full-app/src/main.rs create mode 100644 examples/hooks/Cargo.toml create mode 100644 examples/hooks/migrations/20240101000000_create_users.sql create mode 100644 examples/hooks/src/main.rs create mode 100644 examples/relations/Cargo.toml create mode 100644 examples/relations/migrations/20240101000000_create_tables.sql create mode 100644 examples/relations/src/main.rs create mode 100644 examples/soft-delete/Cargo.toml create mode 100644 examples/soft-delete/migrations/20240101000000_create_documents.sql create mode 100644 examples/soft-delete/src/main.rs create mode 100644 examples/streams/Cargo.toml create mode 100644 examples/streams/migrations/20240101000000_create_logs.sql create mode 100644 examples/streams/src/main.rs create mode 100644 examples/transactions/Cargo.toml create mode 100644 examples/transactions/migrations/20240101000000_create_tables.sql create mode 100644 examples/transactions/src/main.rs diff --git a/examples/axum-crud/Cargo.toml b/examples/basic/Cargo.toml similarity index 90% rename from examples/axum-crud/Cargo.toml rename to examples/basic/Cargo.toml index eaa095c..adcad46 100644 --- a/examples/axum-crud/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -2,10 +2,11 @@ # SPDX-License-Identifier: MIT [package] -name = "axum-crud-example" +name = "example-basic" version = "0.1.0" edition = "2024" publish = false +description = "Basic CRUD example with entity-derive and Axum" [dependencies] entity-derive = { path = "../..", features = ["postgres", "api"] } diff --git a/examples/axum-crud/README.md b/examples/basic/README.md similarity index 100% rename from examples/axum-crud/README.md rename to examples/basic/README.md diff --git a/examples/axum-crud/docker-compose.yml b/examples/basic/docker-compose.yml similarity index 100% rename from examples/axum-crud/docker-compose.yml rename to examples/basic/docker-compose.yml diff --git a/examples/axum-crud/migrations/001_create_users.sql b/examples/basic/migrations/001_create_users.sql similarity index 100% rename from examples/axum-crud/migrations/001_create_users.sql rename to examples/basic/migrations/001_create_users.sql diff --git a/examples/axum-crud/src/main.rs b/examples/basic/src/main.rs similarity index 100% rename from examples/axum-crud/src/main.rs rename to examples/basic/src/main.rs diff --git a/examples/commands/Cargo.toml b/examples/commands/Cargo.toml new file mode 100644 index 0000000..335947d --- /dev/null +++ b/examples/commands/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-commands" +version = "0.1.0" +edition = "2024" +publish = false +description = "CQRS commands example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/commands/migrations/20240101000000_create_accounts.sql b/examples/commands/migrations/20240101000000_create_accounts.sql new file mode 100644 index 0000000..e946cf3 --- /dev/null +++ b/examples/commands/migrations/20240101000000_create_accounts.sql @@ -0,0 +1,9 @@ +-- Create accounts table for commands example + +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/examples/commands/src/main.rs b/examples/commands/src/main.rs new file mode 100644 index 0000000..2d2089c --- /dev/null +++ b/examples/commands/src/main.rs @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Commands Example with entity-derive +//! +//! Demonstrates CQRS command pattern: +//! - `#[entity(commands)]` enables commands +//! - `#[command(Name)]` defines a command +//! - `#[command(Name, requires_id)]` for existing entity + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::post, +}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Commands +// ============================================================================ + +/// Account entity with CQRS commands. +#[derive(Debug, Clone, Entity)] +#[entity(table = "accounts", commands)] +#[command(Register)] +#[command(Activate, requires_id)] +#[command(Deactivate, requires_id)] +#[command(UpdateEmail, requires_id)] +pub struct Account { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub email: String, + + #[field(create, update, response)] + pub name: String, + + #[field(update, response)] + pub active: bool, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +// Generated commands: +// - RegisterAccount { email, name, active } +// - ActivateAccount { id } +// - DeactivateAccount { id } +// - UpdateEmailAccount { id, email, name, active } + +// ============================================================================ +// Command Handler +// ============================================================================ + +#[derive(Debug)] +struct CommandError(String); + +impl std::fmt::Display for CommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for CommandError {} + +struct AccountCommandHandler { + pool: Arc, +} + +impl AccountCommandHandler { + async fn handle_register(&self, cmd: RegisterAccount) -> Result { + tracing::info!("[CMD] Register: email={}", cmd.email); + + let dto = CreateAccountRequest { + email: cmd.email.to_lowercase(), + name: cmd.name, + active: false, // New accounts start inactive + }; + + self.pool + .create(dto) + .await + .map_err(|e| CommandError(e.to_string())) + } + + async fn handle_activate(&self, cmd: ActivateAccount) -> Result { + tracing::info!("[CMD] Activate: id={}", cmd.id); + + let dto = UpdateAccountRequest { + email: None, + name: None, + active: Some(true), + }; + + self.pool + .update(cmd.id, dto) + .await + .map_err(|e| CommandError(e.to_string())) + } + + async fn handle_deactivate(&self, cmd: DeactivateAccount) -> Result { + tracing::info!("[CMD] Deactivate: id={}", cmd.id); + + let dto = UpdateAccountRequest { + email: None, + name: None, + active: Some(false), + }; + + self.pool + .update(cmd.id, dto) + .await + .map_err(|e| CommandError(e.to_string())) + } + + async fn handle_update_email( + &self, + cmd: UpdateEmailAccount, + ) -> Result { + tracing::info!("[CMD] UpdateEmail: id={}, email={:?}", cmd.id, cmd.email); + + let dto = UpdateAccountRequest { + email: cmd.email.map(|e| e.to_lowercase()), + name: cmd.name, + active: cmd.active, + }; + + self.pool + .update(cmd.id, dto) + .await + .map_err(|e| CommandError(e.to_string())) + } +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + handler: Arc, +} + +// ============================================================================ +// HTTP Handlers - Command Endpoints +// ============================================================================ + +async fn register( + State(state): State, + Json(cmd): Json, +) -> Result { + let account = state + .handler + .handle_register(cmd) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok((StatusCode::CREATED, Json(AccountResponse::from(account)))) +} + +async fn activate( + State(state): State, + Path(id): Path, +) -> Result { + let cmd = ActivateAccount { id }; + let account = state + .handler + .handle_activate(cmd) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok(Json(AccountResponse::from(account))) +} + +async fn deactivate( + State(state): State, + Path(id): Path, +) -> Result { + let cmd = DeactivateAccount { id }; + let account = state + .handler + .handle_deactivate(cmd) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok(Json(AccountResponse::from(account))) +} + +async fn update_email( + State(state): State, + Path(id): Path, + Json(mut cmd): Json, +) -> Result { + cmd.id = id; + let account = state + .handler + .handle_update_email(cmd) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + Ok(Json(AccountResponse::from(account))) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + // Command endpoints (verbs, not resources) + .route("/commands/register", post(register)) + .route("/commands/accounts/{id}/activate", post(activate)) + .route("/commands/accounts/{id}/deactivate", post(deactivate)) + .route("/commands/accounts/{id}/update-email", post(update_email)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_commands=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let handler = AccountCommandHandler { + pool: Arc::new(pool), + }; + + let state = AppState { + handler: Arc::new(handler), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Try: POST /commands/register"); + tracing::info!(" POST /commands/accounts/{{id}}/activate"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/events/Cargo.toml b/examples/events/Cargo.toml new file mode 100644 index 0000000..b763fe3 --- /dev/null +++ b/examples/events/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-events" +version = "0.1.0" +edition = "2024" +publish = false +description = "Lifecycle events example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full", "sync"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/events/migrations/20240101000000_create_orders.sql b/examples/events/migrations/20240101000000_create_orders.sql new file mode 100644 index 0000000..a03daf3 --- /dev/null +++ b/examples/events/migrations/20240101000000_create_orders.sql @@ -0,0 +1,17 @@ +-- Create orders table for events example + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL, + product VARCHAR(255) NOT NULL, + quantity INTEGER NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Sample data +INSERT INTO orders (id, customer_name, product, quantity, status) VALUES + (gen_random_uuid(), 'Alice', 'Laptop', 1, 'pending'), + (gen_random_uuid(), 'Bob', 'Mouse', 2, 'shipped'), + (gen_random_uuid(), 'Charlie', 'Keyboard', 1, 'delivered'); diff --git a/examples/events/src/main.rs b/examples/events/src/main.rs new file mode 100644 index 0000000..69744f5 --- /dev/null +++ b/examples/events/src/main.rs @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Events Example with entity-derive +//! +//! Demonstrates lifecycle events: +//! - `#[entity(events)]` generates event enum +//! - Events for Created, Updated, Deleted +//! - Event handling for audit logging + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post, patch, delete}, +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use tokio::sync::broadcast; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Events +// ============================================================================ + +/// Order entity with lifecycle events. +#[derive(Debug, Clone, Entity)] +#[entity(table = "orders", events)] +pub struct Order { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub customer_name: String, + + #[field(create, update, response)] + pub product: String, + + #[field(create, update, response)] + pub quantity: i32, + + #[field(create, update, response)] + pub status: String, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + #[field(response)] + #[auto] + pub updated_at: DateTime, +} + +// Generated by macro: +// pub enum OrderEvent { +// Created(Order), +// Updated { id: Uuid, changes: UpdateOrderRequest }, +// Deleted(Uuid), +// } + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, + events: broadcast::Sender, +} + +// ============================================================================ +// Event Handler +// ============================================================================ + +/// Process order events for audit logging. +fn handle_event(event: &OrderEvent) -> String { + match event { + OrderEvent::Created(order) => { + format!( + "[AUDIT] Order created: id={}, customer={}, product={}", + order.id, order.customer_name, order.product + ) + } + OrderEvent::Updated { id, changes } => { + let mut changed = Vec::new(); + if changes.customer_name.is_some() { + changed.push("customer_name"); + } + if changes.product.is_some() { + changed.push("product"); + } + if changes.quantity.is_some() { + changed.push("quantity"); + } + if changes.status.is_some() { + changed.push("status"); + } + format!("[AUDIT] Order updated: id={}, changed={:?}", id, changed) + } + OrderEvent::Deleted(id) => { + format!("[AUDIT] Order deleted: id={}", id) + } + } +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +#[derive(Deserialize)] +struct CreateOrder { + customer_name: String, + product: String, + quantity: i32, +} + +async fn create_order( + State(state): State, + Json(input): Json, +) -> Result { + let dto = CreateOrderRequest { + customer_name: input.customer_name, + product: input.product, + quantity: input.quantity, + status: "pending".to_string(), + }; + + let order = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Emit event + let event = OrderEvent::Created(order.clone()); + let log = handle_event(&event); + tracing::info!("{}", log); + let _ = state.events.send(log); + + Ok((StatusCode::CREATED, Json(OrderResponse::from(order)))) +} + +async fn update_order( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result { + // Emit event before update + let event = OrderEvent::Updated { + id, + changes: dto.clone(), + }; + let log = handle_event(&event); + tracing::info!("{}", log); + let _ = state.events.send(log); + + let order = state + .pool + .update(id, dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(OrderResponse::from(order))) +} + +async fn delete_order( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state + .pool + .delete(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + // Emit event + let event = OrderEvent::Deleted(id); + let log = handle_event(&event); + tracing::info!("{}", log); + let _ = state.events.send(log); + + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +async fn list_orders( + State(state): State, +) -> Result { + let orders = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = orders.into_iter().map(OrderResponse::from).collect(); + Ok(Json(responses)) +} + +async fn get_order( + State(state): State, + Path(id): Path, +) -> Result { + let order = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(OrderResponse::from(order))) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/orders", post(create_order).get(list_orders)) + .route("/orders/{id}", get(get_order).patch(update_order).delete(delete_order)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_events=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let (tx, _rx) = broadcast::channel(100); + + let state = AppState { + pool: Arc::new(pool), + events: tx, + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Watch logs for [AUDIT] events"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/filtering/Cargo.toml b/examples/filtering/Cargo.toml new file mode 100644 index 0000000..b469fef --- /dev/null +++ b/examples/filtering/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-filtering" +version = "0.1.0" +edition = "2024" +publish = false +description = "Type-safe filtering example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/filtering/migrations/20240101000000_create_products.sql b/examples/filtering/migrations/20240101000000_create_products.sql new file mode 100644 index 0000000..2a30ab7 --- /dev/null +++ b/examples/filtering/migrations/20240101000000_create_products.sql @@ -0,0 +1,27 @@ +-- Create products table for filtering example +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, + price BIGINT NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for filtered columns +CREATE INDEX idx_products_category ON products(category); +CREATE INDEX idx_products_price ON products(price); +CREATE INDEX idx_products_active ON products(active); +CREATE INDEX idx_products_created_at ON products(created_at); + +-- Sample data +INSERT INTO products (id, name, category, price, stock, active) VALUES + (gen_random_uuid(), 'iPhone 15 Pro', 'electronics', 129900, 50, true), + (gen_random_uuid(), 'MacBook Air M3', 'electronics', 149900, 30, true), + (gen_random_uuid(), 'AirPods Pro', 'electronics', 24900, 100, true), + (gen_random_uuid(), 'USB-C Cable', 'accessories', 1990, 500, true), + (gen_random_uuid(), 'Phone Case', 'accessories', 2990, 200, true), + (gen_random_uuid(), 'Old Phone Model', 'electronics', 49900, 5, false), + (gen_random_uuid(), 'Wireless Charger', 'accessories', 4990, 75, true), + (gen_random_uuid(), 'iPad Mini', 'electronics', 64900, 25, true); diff --git a/examples/filtering/src/main.rs b/examples/filtering/src/main.rs new file mode 100644 index 0000000..a9ddf19 --- /dev/null +++ b/examples/filtering/src/main.rs @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Filtering Example with entity-derive +//! +//! Demonstrates type-safe query filtering: +//! - `#[filter]` for exact match +//! - `#[filter(like)]` for pattern matching +//! - `#[filter(range)]` for date/number ranges + +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Filters +// ============================================================================ + +/// Product entity with various filter types. +#[derive(Debug, Clone, Entity)] +#[entity(table = "products")] +pub struct Product { + #[id] + pub id: Uuid, + + /// Product name - supports pattern matching. + #[field(create, update, response)] + #[filter(like)] + pub name: String, + + /// Product category - exact match filter. + #[field(create, update, response)] + #[filter] + pub category: String, + + /// Price in cents - range filter. + #[field(create, update, response)] + #[filter(range)] + pub price: i64, + + /// Stock quantity - range filter. + #[field(create, update, response)] + #[filter(range)] + pub stock: i32, + + /// Is product active - exact match. + #[field(create, update, response)] + #[filter] + pub active: bool, + + /// Creation timestamp - range filter. + #[field(response)] + #[auto] + #[filter(range)] + pub created_at: DateTime, +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// Query Parameters +// ============================================================================ + +/// Query parameters that map to generated ProductQuery. +#[derive(Debug, Deserialize)] +struct ProductQueryParams { + /// Filter by name pattern (ILIKE). + name: Option, + /// Filter by exact category. + category: Option, + /// Minimum price. + price_min: Option, + /// Maximum price. + price_max: Option, + /// Minimum stock. + stock_min: Option, + /// Only active products. + active: Option, + /// Pagination limit. + #[serde(default = "default_limit")] + limit: i64, + /// Pagination offset. + #[serde(default)] + offset: i64, +} + +fn default_limit() -> i64 { + 20 +} + +impl From for ProductQuery { + fn from(p: ProductQueryParams) -> Self { + Self { + name: p.name, + category: p.category, + price_min: p.price_min, + price_max: p.price_max, + stock_min: p.stock_min, + stock_max: None, + active: p.active, + created_at_min: None, + created_at_max: None, + } + } +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +/// List products with filters. +/// +/// Examples: +/// - GET /products?category=electronics +/// - GET /products?name=phone&price_max=100000 +/// - GET /products?active=true&stock_min=10 +async fn list_products( + State(state): State, + Query(params): Query, +) -> Result { + let limit = params.limit; + let offset = params.offset; + let query: ProductQuery = params.into(); + + let products = state + .pool + .list_filtered(&query, limit, offset) + .await + .map_err(|e| { + tracing::error!("Database error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let responses: Vec = products.into_iter().map(ProductResponse::from).collect(); + Ok(Json(responses)) +} + +/// Get filter statistics. +async fn get_categories( + State(state): State, +) -> Result { + let products = state.pool.list(1000, 0).await.map_err(|e| { + tracing::error!("Database error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let mut categories: Vec = products + .iter() + .map(|p| p.category.clone()) + .collect(); + categories.sort(); + categories.dedup(); + + Ok(Json(categories)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/products", get(list_products)) + .route("/categories", get(get_categories)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_filtering=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Try: GET /products?category=electronics&price_max=50000"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/full-app/Cargo.toml b/examples/full-app/Cargo.toml new file mode 100644 index 0000000..7dbe741 --- /dev/null +++ b/examples/full-app/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-full-app" +version = "0.1.0" +edition = "2024" +publish = false +description = "Complete application example showcasing all entity-derive features" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres"] } +axum = "0.8" +tokio = { version = "1", features = ["full", "sync"] } +tokio-stream = "0.1" +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +futures = "0.3" +tower-http = { version = "0.6", features = ["trace", "cors"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } diff --git a/examples/full-app/migrations/20240101000000_create_schema.sql b/examples/full-app/migrations/20240101000000_create_schema.sql new file mode 100644 index 0000000..6c6744b --- /dev/null +++ b/examples/full-app/migrations/20240101000000_create_schema.sql @@ -0,0 +1,127 @@ +-- Full Application Schema +-- Demonstrates all entity-derive features in one application + +-- ============================================================================ +-- Users (soft_delete, events, hooks) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'customer', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); + +-- ============================================================================ +-- Categories (basic CRUD) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- Products (relations, filtering, soft_delete) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY, + category_id UUID NOT NULL REFERENCES categories(id), + name VARCHAR(255) NOT NULL, + description TEXT, + price BIGINT NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_products_category ON products(category_id); +CREATE INDEX idx_products_price ON products(price); +CREATE INDEX idx_products_deleted_at ON products(deleted_at); + +-- ============================================================================ +-- Orders (transactions, events, relations) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + status VARCHAR(50) NOT NULL DEFAULT 'pending', + total BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_orders_user ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status); + +-- ============================================================================ +-- Order Items (relations) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id), + quantity INTEGER NOT NULL, + unit_price BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_order_items_order ON order_items(order_id); +CREATE INDEX idx_order_items_product ON order_items(product_id); + +-- ============================================================================ +-- Audit Logs (streams) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY, + entity_type VARCHAR(100) NOT NULL, + entity_id UUID NOT NULL, + action VARCHAR(50) NOT NULL, + user_id UUID REFERENCES users(id), + old_data JSONB, + new_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + +-- ============================================================================ +-- Sample Data +-- ============================================================================ + +-- Categories +INSERT INTO categories (id, name, description) VALUES + ('c0000000-0000-0000-0000-000000000001', 'Electronics', 'Electronic devices and accessories'), + ('c0000000-0000-0000-0000-000000000002', 'Books', 'Physical and digital books'), + ('c0000000-0000-0000-0000-000000000003', 'Clothing', 'Apparel and fashion items'); + +-- Users +INSERT INTO users (id, email, name, role) VALUES + ('u0000000-0000-0000-0000-000000000001', 'admin@example.com', 'Admin User', 'admin'), + ('u0000000-0000-0000-0000-000000000002', 'alice@example.com', 'Alice Johnson', 'customer'), + ('u0000000-0000-0000-0000-000000000003', 'bob@example.com', 'Bob Smith', 'customer'); + +-- Products +INSERT INTO products (id, category_id, name, description, price, stock) VALUES + ('p0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001', 'Laptop Pro', '15-inch professional laptop', 149999, 50), + ('p0000000-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-000000000001', 'Wireless Mouse', 'Ergonomic wireless mouse', 4999, 200), + ('p0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000002', 'Rust Programming', 'Learn Rust programming language', 3999, 100), + ('p0000000-0000-0000-0000-000000000004', 'c0000000-0000-0000-0000-000000000003', 'Developer T-Shirt', 'Comfortable cotton t-shirt', 2499, 150); diff --git a/examples/full-app/src/main.rs b/examples/full-app/src/main.rs new file mode 100644 index 0000000..92ba750 --- /dev/null +++ b/examples/full-app/src/main.rs @@ -0,0 +1,702 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Full Application Example with entity-derive +//! +//! A complete e-commerce application demonstrating ALL entity-derive features: +//! - Relations (`#[belongs_to]`, `#[has_many]`) +//! - Soft Delete (`#[entity(soft_delete)]`) +//! - Transactions (`#[entity(transactions)]`) +//! - Events (`#[entity(events)]`) +//! - Hooks (`#[entity(hooks)]`) +//! - Commands (`#[entity(commands)]`) +//! - Streams (`#[entity(streams)]`) +//! - Filtering (`#[filter]`, `#[filter(like)]`, `#[filter(range)]`) + +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, +}; +use chrono::{DateTime, Utc}; +use entity_core::prelude::*; +use entity_derive::Entity; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definitions +// ============================================================================ + +/// User entity with soft delete, events, and hooks. +#[derive(Debug, Clone, Entity)] +#[entity(table = "users", soft_delete, events, hooks)] +pub struct User { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + #[filter(like)] + pub email: String, + + #[field(create, update, response)] + #[filter(like)] + pub name: String, + + #[field(create, update, response)] + #[filter] + pub role: String, + + #[field(update, response)] + pub active: bool, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + #[field(response)] + #[auto] + pub updated_at: DateTime, + + #[field(skip)] + pub deleted_at: Option>, + + /// User's orders + #[has_many(Order, foreign_key = "user_id")] + pub orders: Vec, +} + +/// Category entity (basic CRUD). +#[derive(Debug, Clone, Entity)] +#[entity(table = "categories")] +pub struct Category { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + #[filter(like)] + pub name: String, + + #[field(create, update, response)] + pub description: Option, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + /// Products in this category + #[has_many(Product, foreign_key = "category_id")] + pub products: Vec, +} + +/// Product entity with relations, filtering, and soft delete. +#[derive(Debug, Clone, Entity)] +#[entity(table = "products", soft_delete, transactions)] +pub struct Product { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub category_id: Uuid, + + #[field(create, update, response)] + #[filter(like)] + pub name: String, + + #[field(create, update, response)] + pub description: Option, + + #[field(create, update, response)] + #[filter(range)] + pub price: i64, + + #[field(create, update, response)] + #[filter(range)] + pub stock: i32, + + #[field(update, response)] + pub active: bool, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + #[field(response)] + #[auto] + pub updated_at: DateTime, + + #[field(skip)] + pub deleted_at: Option>, + + /// Parent category + #[belongs_to(Category)] + pub category: Option, +} + +/// Order entity with transactions and events. +#[derive(Debug, Clone, Entity)] +#[entity(table = "orders", transactions, events)] +#[command(PlaceOrder)] +#[command(UpdateStatus, requires_id)] +pub struct Order { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub user_id: Uuid, + + #[field(create, update, response)] + #[filter] + pub status: String, + + #[field(update, response)] + pub total: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + #[field(response)] + #[auto] + pub updated_at: DateTime, + + /// Customer who placed the order + #[belongs_to(User)] + pub user: Option, + + /// Items in this order + #[has_many(OrderItem, foreign_key = "order_id")] + pub items: Vec, +} + +/// Order item entity (line items). +#[derive(Debug, Clone, Entity)] +#[entity(table = "order_items", transactions)] +pub struct OrderItem { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub order_id: Uuid, + + #[field(create, response)] + pub product_id: Uuid, + + #[field(create, response)] + pub quantity: i32, + + #[field(create, response)] + pub unit_price: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + /// Parent order + #[belongs_to(Order)] + pub order: Option, + + /// Product reference + #[belongs_to(Product)] + pub product: Option, +} + +/// Audit log for streaming. +#[derive(Debug, Clone, Entity)] +#[entity(table = "audit_logs", streams)] +pub struct AuditLog { + #[id] + pub id: Uuid, + + #[field(create, response)] + #[filter] + pub entity_type: String, + + #[field(create, response)] + pub entity_id: Uuid, + + #[field(create, response)] + #[filter] + pub action: String, + + #[field(create, response)] + pub user_id: Option, + + #[field(create, response)] + pub old_data: Option, + + #[field(create, response)] + pub new_data: Option, + + #[field(response)] + #[auto] + #[filter(range)] + pub created_at: DateTime, +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// Request/Response DTOs +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct PlaceOrderRequest { + user_id: Uuid, + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct OrderItemInput { + product_id: Uuid, + quantity: i32, +} + +#[derive(Debug, Serialize)] +struct OrderWithItems { + #[serde(flatten)] + order: OrderResponse, + items: Vec, + total_formatted: String, +} + +// ============================================================================ +// User Handlers +// ============================================================================ + +async fn list_users( + State(state): State, + Query(filter): Query, +) -> Result { + let users = state + .pool + .list_filtered(filter, 100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = users.into_iter().map(UserResponse::from).collect(); + Ok(Json(responses)) +} + +async fn get_user( + State(state): State, + Path(id): Path, +) -> Result { + let user = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(UserResponse::from(user))) +} + +async fn create_user( + State(state): State, + Json(dto): Json, +) -> Result { + let user = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) +} + +async fn delete_user( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state + .pool + .delete(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +// ============================================================================ +// Category Handlers +// ============================================================================ + +async fn list_categories(State(state): State) -> Result { + let categories: Vec = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = categories + .into_iter() + .map(CategoryResponse::from) + .collect(); + Ok(Json(responses)) +} + +async fn get_category_with_products( + State(state): State, + Path(id): Path, +) -> Result { + let category = state + .pool + .find_by_id_with_products(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(CategoryResponse::from(category))) +} + +// ============================================================================ +// Product Handlers +// ============================================================================ + +async fn list_products( + State(state): State, + Query(filter): Query, +) -> Result { + let products = state + .pool + .list_filtered(filter, 100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = products.into_iter().map(ProductResponse::from).collect(); + Ok(Json(responses)) +} + +async fn get_product( + State(state): State, + Path(id): Path, +) -> Result { + let product = state + .pool + .find_by_id_with_category(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(ProductResponse::from(product))) +} + +async fn create_product( + State(state): State, + Json(dto): Json, +) -> Result { + let product = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(ProductResponse::from(product)))) +} + +async fn update_product( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result { + let product = state + .pool + .update(id, dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(ProductResponse::from(product))) +} + +// ============================================================================ +// Order Handlers (with Transactions) +// ============================================================================ + +/// Place an order atomically. +/// +/// This demonstrates transactions across multiple entities: +/// 1. Create order +/// 2. Create order items +/// 3. Update product stock +/// 4. Calculate and update order total +async fn place_order( + State(state): State, + Json(req): Json, +) -> Result { + if req.items.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Order must have items".into())); + } + + let result = Transaction::new(&*state.pool) + .with_orders() + .with_order_items() + .with_products() + .run(|mut ctx| async move { + // Step 1: Create the order + let order = ctx + .orders() + .create(CreateOrderRequest { + user_id: req.user_id, + status: "pending".to_string(), + total: 0, + }) + .await?; + + let mut total: i64 = 0; + let mut created_items = Vec::new(); + + // Step 2: Process each item + for item in &req.items { + // Get product and check stock + let product = ctx + .products() + .find_by_id(item.product_id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + + if product.stock < item.quantity { + return Err(sqlx::Error::Protocol(format!( + "Insufficient stock for {}: {} < {}", + product.name, product.stock, item.quantity + ))); + } + + // Create order item + let order_item = ctx + .order_items() + .create(CreateOrderItemRequest { + order_id: order.id, + product_id: item.product_id, + quantity: item.quantity, + unit_price: product.price, + }) + .await?; + + created_items.push(order_item); + total += product.price * item.quantity as i64; + + // Update stock + ctx.products() + .update( + item.product_id, + UpdateProductRequest { + category_id: None, + name: None, + description: None, + price: None, + stock: Some(product.stock - item.quantity), + active: None, + }, + ) + .await?; + } + + // Step 3: Update order total + let final_order = ctx + .orders() + .update( + order.id, + UpdateOrderRequest { + status: None, + total: Some(total), + }, + ) + .await?; + + Ok((final_order, created_items)) + }) + .await; + + match result { + Ok((order, items)) => { + tracing::info!("Order {} placed successfully", order.id); + let response = OrderWithItems { + order: OrderResponse::from(order), + items: items.into_iter().map(OrderItemResponse::from).collect(), + total_formatted: format!("${:.2}", items.iter().map(|i| i.unit_price * i.quantity as i64).sum::() as f64 / 100.0), + }; + Ok((StatusCode::CREATED, Json(response))) + } + Err(e) => { + tracing::error!("Order placement failed: {}", e); + Err((StatusCode::BAD_REQUEST, e.to_string())) + } + } +} + +async fn list_orders( + State(state): State, + Query(filter): Query, +) -> Result { + let orders = state + .pool + .list_filtered(filter, 100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = orders.into_iter().map(OrderResponse::from).collect(); + Ok(Json(responses)) +} + +async fn get_order( + State(state): State, + Path(id): Path, +) -> Result { + let order = state + .pool + .find_by_id_with_items(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(OrderResponse::from(order))) +} + +async fn update_order_status( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result { + let order = state + .pool + .update(id, dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(OrderResponse::from(order))) +} + +// ============================================================================ +// Audit Log Handlers (Streaming) +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct AuditQuery { + entity_type: Option, + action: Option, + limit: Option, +} + +async fn stream_audit_logs( + State(state): State, + Query(query): Query, +) -> Result { + let filter = AuditLogFilter { + entity_type: query.entity_type, + action: query.action, + created_at_min: None, + created_at_max: None, + }; + + let mut stream = state + .pool + .stream_filtered(filter) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let limit = query.limit.unwrap_or(100) as usize; + let mut logs = Vec::with_capacity(limit); + + while let Some(result) = stream.next().await { + if logs.len() >= limit { + break; + } + if let Ok(log) = result { + logs.push(AuditLogResponse::from(log)); + } + } + + Ok(Json(logs)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + // User routes + .route("/users", get(list_users).post(create_user)) + .route("/users/{id}", get(get_user).delete(delete_user)) + // Category routes + .route("/categories", get(list_categories)) + .route("/categories/{id}", get(get_category_with_products)) + // Product routes + .route("/products", get(list_products).post(create_product)) + .route("/products/{id}", get(get_product).patch(update_product)) + // Order routes + .route("/orders", get(list_orders).post(place_order)) + .route( + "/orders/{id}", + get(get_order).patch(update_order_status), + ) + // Audit routes + .route("/audit", get(stream_audit_logs)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_full_app=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("================================================="); + tracing::info!("Full Application Example - All Features Combined"); + tracing::info!("================================================="); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!(""); + tracing::info!("Features demonstrated:"); + tracing::info!(" - Relations: Category -> Products, User -> Orders"); + tracing::info!(" - Soft Delete: Users, Products"); + tracing::info!(" - Transactions: Order placement"); + tracing::info!(" - Filtering: Products by price, Users by role"); + tracing::info!(" - Streams: Audit log processing"); + tracing::info!(""); + tracing::info!("Endpoints:"); + tracing::info!(" GET/POST /users"); + tracing::info!(" GET/POST /products?price_min=&price_max="); + tracing::info!(" GET /categories/{{id}} (with products)"); + tracing::info!(" POST /orders (atomic order placement)"); + tracing::info!(" GET /audit?entity_type=&action="); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/hooks/Cargo.toml b/examples/hooks/Cargo.toml new file mode 100644 index 0000000..4a770e4 --- /dev/null +++ b/examples/hooks/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-hooks" +version = "0.1.0" +edition = "2024" +publish = false +description = "Lifecycle hooks example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/hooks/migrations/20240101000000_create_users.sql b/examples/hooks/migrations/20240101000000_create_users.sql new file mode 100644 index 0000000..4217f5b --- /dev/null +++ b/examples/hooks/migrations/20240101000000_create_users.sql @@ -0,0 +1,9 @@ +-- Create users table for hooks example + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/examples/hooks/src/main.rs b/examples/hooks/src/main.rs new file mode 100644 index 0000000..1756122 --- /dev/null +++ b/examples/hooks/src/main.rs @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Hooks Example with entity-derive +//! +//! Demonstrates lifecycle hooks: +//! - `#[entity(hooks)]` generates hooks trait +//! - before_create, after_create +//! - before_update, after_update +//! - before_delete, after_delete + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post, patch, delete}, +}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Hooks +// ============================================================================ + +/// User entity with lifecycle hooks. +#[derive(Debug, Clone, Entity)] +#[entity(table = "users", hooks)] +pub struct User { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub email: String, + + #[field(create, update, response)] + pub name: String, + + #[field(create, skip)] + pub password_hash: String, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +// Generated trait by macro: +// #[async_trait] +// pub trait UserHooks: Send + Sync { +// type Error: std::error::Error + Send + Sync; +// async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>; +// async fn after_create(&self, entity: &User) -> Result<(), Self::Error>; +// async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>; +// async fn after_update(&self, entity: &User) -> Result<(), Self::Error>; +// async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; +// async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>; +// } + +// ============================================================================ +// Hooks Implementation +// ============================================================================ + +#[derive(Debug)] +struct HookError(String); + +impl std::fmt::Display for HookError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for HookError {} + +struct MyUserHooks; + +#[async_trait] +impl UserHooks for MyUserHooks { + type Error = HookError; + + async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> { + // Normalize email to lowercase + dto.email = dto.email.to_lowercase(); + + // Validate email format + if !dto.email.contains('@') { + return Err(HookError("Invalid email format".into())); + } + + // In real app: hash password here + // dto.password_hash = hash_password(&dto.password_hash); + + tracing::info!("[HOOK] before_create: email normalized to {}", dto.email); + Ok(()) + } + + async fn after_create(&self, entity: &User) -> Result<(), Self::Error> { + tracing::info!("[HOOK] after_create: user {} created", entity.id); + // In real app: send welcome email, create related records, etc. + Ok(()) + } + + async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> { + if let Some(ref mut email) = dto.email { + *email = email.to_lowercase(); + } + tracing::info!("[HOOK] before_update: updating user {}", id); + Ok(()) + } + + async fn after_update(&self, entity: &User) -> Result<(), Self::Error> { + tracing::info!("[HOOK] after_update: user {} updated", entity.id); + Ok(()) + } + + async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> { + tracing::info!("[HOOK] before_delete: about to delete user {}", id); + // In real app: check if user can be deleted, archive data, etc. + Ok(()) + } + + async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> { + tracing::info!("[HOOK] after_delete: user {} deleted", id); + // In real app: cleanup related data, send notification, etc. + Ok(()) + } +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, + hooks: Arc, +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +async fn create_user( + State(state): State, + Json(mut dto): Json, +) -> Result { + // Run before_create hook + state + .hooks + .before_create(&mut dto) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let user = state + .pool + .create(dto) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Run after_create hook + state + .hooks + .after_create(&user) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) +} + +async fn update_user( + State(state): State, + Path(id): Path, + Json(mut dto): Json, +) -> Result { + // Run before_update hook + state + .hooks + .before_update(&id, &mut dto) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let user = state + .pool + .update(id, dto) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Run after_update hook + state + .hooks + .after_update(&user) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(UserResponse::from(user))) +} + +async fn delete_user( + State(state): State, + Path(id): Path, +) -> Result { + // Run before_delete hook + state + .hooks + .before_delete(&id) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let deleted = state + .pool + .delete(id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if deleted { + // Run after_delete hook + state + .hooks + .after_delete(&id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "User not found".into())) + } +} + +async fn list_users( + State(state): State, +) -> Result { + let users = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = users.into_iter().map(UserResponse::from).collect(); + Ok(Json(responses)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/users", post(create_user).get(list_users)) + .route("/users/{id}", patch(update_user).delete(delete_user)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_hooks=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + hooks: Arc::new(MyUserHooks), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Watch logs for [HOOK] messages"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/relations/Cargo.toml b/examples/relations/Cargo.toml new file mode 100644 index 0000000..3c924c9 --- /dev/null +++ b/examples/relations/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-relations" +version = "0.1.0" +edition = "2024" +publish = false +description = "Entity relations example with belongs_to and has_many" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/relations/migrations/20240101000000_create_tables.sql b/examples/relations/migrations/20240101000000_create_tables.sql new file mode 100644 index 0000000..e27746b --- /dev/null +++ b/examples/relations/migrations/20240101000000_create_tables.sql @@ -0,0 +1,43 @@ +-- Create tables for relations example + +CREATE TABLE IF NOT EXISTS authors ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS comments ( + id UUID PRIMARY KEY, + text TEXT NOT NULL, + commenter_name VARCHAR(255) NOT NULL, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_posts_author_id ON posts(author_id); +CREATE INDEX idx_comments_post_id ON comments(post_id); + +-- Sample data +INSERT INTO authors (id, name, email) VALUES + ('a1000000-0000-0000-0000-000000000001', 'John Doe', 'john@example.com'), + ('a1000000-0000-0000-0000-000000000002', 'Jane Smith', 'jane@example.com'); + +INSERT INTO posts (id, title, content, author_id) VALUES + ('b1000000-0000-0000-0000-000000000001', 'Hello World', 'My first post content', 'a1000000-0000-0000-0000-000000000001'), + ('b1000000-0000-0000-0000-000000000002', 'Rust is Great', 'Why I love Rust...', 'a1000000-0000-0000-0000-000000000001'), + ('b1000000-0000-0000-0000-000000000003', 'Web Development', 'Tips for web dev', 'a1000000-0000-0000-0000-000000000002'); + +INSERT INTO comments (id, text, commenter_name, post_id) VALUES + ('c1000000-0000-0000-0000-000000000001', 'Great post!', 'Reader1', 'b1000000-0000-0000-0000-000000000001'), + ('c1000000-0000-0000-0000-000000000002', 'Thanks for sharing', 'Reader2', 'b1000000-0000-0000-0000-000000000001'), + ('c1000000-0000-0000-0000-000000000003', 'I agree!', 'Reader3', 'b1000000-0000-0000-0000-000000000002'); diff --git a/examples/relations/src/main.rs b/examples/relations/src/main.rs new file mode 100644 index 0000000..355fed8 --- /dev/null +++ b/examples/relations/src/main.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Relations Example with entity-derive +//! +//! Demonstrates entity relationships: +//! - `#[belongs_to(Entity)]` for foreign keys +//! - `#[has_many(Entity)]` for one-to-many + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definitions with Relations +// ============================================================================ + +/// Author entity - has many posts. +#[derive(Debug, Clone, Entity)] +#[entity(table = "authors")] +#[has_many(Post)] +pub struct Author { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub name: String, + + #[field(create, update, response)] + pub email: String, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +/// Post entity - belongs to author, has many comments. +#[derive(Debug, Clone, Entity)] +#[entity(table = "posts")] +#[has_many(Comment)] +pub struct Post { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub title: String, + + #[field(create, update, response)] + pub content: String, + + /// Foreign key to author. + #[field(create, response)] + #[belongs_to(Author)] + pub author_id: Uuid, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +/// Comment entity - belongs to post. +#[derive(Debug, Clone, Entity)] +#[entity(table = "comments")] +pub struct Comment { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub text: String, + + #[field(create, response)] + pub commenter_name: String, + + /// Foreign key to post. + #[field(create, response)] + #[belongs_to(Post)] + pub post_id: Uuid, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +/// Get author with their posts. +async fn get_author_with_posts( + State(state): State, + Path(id): Path, +) -> Result { + let author = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Use generated find_posts method + let posts: Vec = state + .pool + .find_posts(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ + "author": AuthorResponse::from(author), + "posts": posts.into_iter().map(PostResponse::from).collect::>() + }))) +} + +/// Get post with author and comments. +async fn get_post_with_details( + State(state): State, + Path(id): Path, +) -> Result { + let post = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Use generated find_author method (from belongs_to) + let author: Option = state + .pool + .find_author(post.author_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Use generated find_comments method (from has_many) + let comments: Vec = state + .pool + .find_comments(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ + "post": PostResponse::from(post), + "author": author.map(AuthorResponse::from), + "comments": comments.into_iter().map(CommentResponse::from).collect::>() + }))) +} + +/// List all authors. +async fn list_authors( + State(state): State, +) -> Result { + let authors = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = authors.into_iter().map(AuthorResponse::from).collect(); + Ok(Json(responses)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/authors", get(list_authors)) + .route("/authors/{id}", get(get_author_with_posts)) + .route("/posts/{id}", get(get_post_with_details)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_relations=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Try: GET /authors/{{id}} to see author with posts"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/soft-delete/Cargo.toml b/examples/soft-delete/Cargo.toml new file mode 100644 index 0000000..b900f9c --- /dev/null +++ b/examples/soft-delete/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-soft-delete" +version = "0.1.0" +edition = "2024" +publish = false +description = "Soft delete example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/soft-delete/migrations/20240101000000_create_documents.sql b/examples/soft-delete/migrations/20240101000000_create_documents.sql new file mode 100644 index 0000000..d56838a --- /dev/null +++ b/examples/soft-delete/migrations/20240101000000_create_documents.sql @@ -0,0 +1,19 @@ +-- Create documents table for soft delete example + +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Index for soft delete queries +CREATE INDEX idx_documents_deleted_at ON documents(deleted_at); + +-- Sample documents +INSERT INTO documents (id, title, content, author) VALUES + ('d0000000-0000-0000-0000-000000000001', 'Getting Started', 'Welcome to our documentation...', 'Alice'), + ('d0000000-0000-0000-0000-000000000002', 'API Reference', 'Full API documentation...', 'Bob'), + ('d0000000-0000-0000-0000-000000000003', 'Best Practices', 'Development guidelines...', 'Charlie'); diff --git a/examples/soft-delete/src/main.rs b/examples/soft-delete/src/main.rs new file mode 100644 index 0000000..a8b2cd3 --- /dev/null +++ b/examples/soft-delete/src/main.rs @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Soft Delete Example with entity-derive +//! +//! Demonstrates soft delete functionality: +//! - `#[entity(soft_delete)]` enables soft delete +//! - `delete()` sets `deleted_at` instead of DELETE +//! - `hard_delete()` permanently removes +//! - `restore()` recovers deleted records +//! - Queries automatically filter deleted records + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post, patch}, +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Soft Delete +// ============================================================================ + +/// Document entity with soft delete support. +#[derive(Debug, Clone, Entity)] +#[entity(table = "documents", soft_delete)] +pub struct Document { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub title: String, + + #[field(create, update, response)] + pub content: String, + + #[field(create, response)] + pub author: String, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + /// Required for soft_delete - stores deletion timestamp. + #[field(skip)] + pub deleted_at: Option>, +} + +// Generated methods: +// - delete(id) -> sets deleted_at = NOW() +// - hard_delete(id) -> DELETE FROM +// - restore(id) -> sets deleted_at = NULL +// - find_by_id() -> WHERE deleted_at IS NULL +// - list() -> WHERE deleted_at IS NULL +// - find_by_id_with_deleted() -> includes deleted +// - list_with_deleted() -> includes deleted + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +/// Create a new document. +async fn create_document( + State(state): State, + Json(dto): Json, +) -> Result { + let doc = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(DocumentResponse::from(doc)))) +} + +/// List active documents (excludes deleted). +async fn list_documents( + State(state): State, +) -> Result { + let docs = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = docs.into_iter().map(DocumentResponse::from).collect(); + Ok(Json(responses)) +} + +/// List ALL documents including deleted. +async fn list_all_documents( + State(state): State, +) -> Result { + let docs = state + .pool + .list_with_deleted(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = docs.into_iter().map(DocumentResponse::from).collect(); + Ok(Json(responses)) +} + +/// Get document by ID. +async fn get_document( + State(state): State, + Path(id): Path, +) -> Result { + let doc = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(DocumentResponse::from(doc))) +} + +/// Update document. +async fn update_document( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result { + let doc = state + .pool + .update(id, dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(DocumentResponse::from(doc))) +} + +/// Soft delete a document. +async fn delete_document( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state + .pool + .delete(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + tracing::info!("Document {} soft deleted", id); + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +/// Restore a soft-deleted document. +async fn restore_document( + State(state): State, + Path(id): Path, +) -> Result { + let restored = state + .pool + .restore(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if restored { + tracing::info!("Document {} restored", id); + + // Fetch and return restored document + let doc = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(DocumentResponse::from(doc))) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +/// Permanently delete a document. +async fn hard_delete_document( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state + .pool + .hard_delete(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + tracing::info!("Document {} permanently deleted", id); + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/documents", get(list_documents).post(create_document)) + .route("/documents/all", get(list_all_documents)) + .route( + "/documents/{id}", + get(get_document).patch(update_document).delete(delete_document), + ) + .route("/documents/{id}/restore", post(restore_document)) + .route("/documents/{id}/hard-delete", delete(hard_delete_document)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_soft_delete=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Endpoints:"); + tracing::info!(" DELETE /documents/{{id}} - soft delete"); + tracing::info!(" POST /documents/{{id}}/restore - restore"); + tracing::info!(" DELETE /documents/{{id}}/hard-delete - permanent delete"); + tracing::info!(" GET /documents/all - list including deleted"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/streams/Cargo.toml b/examples/streams/Cargo.toml new file mode 100644 index 0000000..fbb31bf --- /dev/null +++ b/examples/streams/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-streams" +version = "0.1.0" +edition = "2024" +publish = false +description = "Real-time streams example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +axum = "0.8" +tokio = { version = "1", features = ["full", "sync"] } +tokio-stream = "0.1" +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures = "0.3" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/streams/migrations/20240101000000_create_logs.sql b/examples/streams/migrations/20240101000000_create_logs.sql new file mode 100644 index 0000000..8a17558 --- /dev/null +++ b/examples/streams/migrations/20240101000000_create_logs.sql @@ -0,0 +1,24 @@ +-- Create logs table for streams example + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY, + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(100) NOT NULL, + resource_id UUID NOT NULL, + user_id UUID, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for efficient streaming queries +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); + +-- Sample data (large dataset for streaming) +INSERT INTO audit_logs (id, action, resource_type, resource_id, user_id, details) VALUES + ('10000000-0000-0000-0000-000000000001', 'create', 'user', 'u0000000-0000-0000-0000-000000000001', 'u0000000-0000-0000-0000-000000000001', '{"email": "alice@example.com"}'), + ('10000000-0000-0000-0000-000000000002', 'update', 'user', 'u0000000-0000-0000-0000-000000000001', 'u0000000-0000-0000-0000-000000000001', '{"field": "name"}'), + ('10000000-0000-0000-0000-000000000003', 'create', 'order', 'o0000000-0000-0000-0000-000000000001', 'u0000000-0000-0000-0000-000000000001', '{"total": 99.99}'), + ('10000000-0000-0000-0000-000000000004', 'update', 'order', 'o0000000-0000-0000-0000-000000000001', 'u0000000-0000-0000-0000-000000000002', '{"status": "shipped"}'), + ('10000000-0000-0000-0000-000000000005', 'delete', 'session', 's0000000-0000-0000-0000-000000000001', 'u0000000-0000-0000-0000-000000000001', null); diff --git a/examples/streams/src/main.rs b/examples/streams/src/main.rs new file mode 100644 index 0000000..60f08b0 --- /dev/null +++ b/examples/streams/src/main.rs @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Streams Example with entity-derive +//! +//! Demonstrates async streaming for large datasets: +//! - `#[entity(streams)]` enables streaming support +//! - `stream_all()` returns async Stream +//! - Memory-efficient processing of large result sets +//! - Supports filtering during stream + +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Streams +// ============================================================================ + +/// Audit log entity with streaming support. +#[derive(Debug, Clone, Entity)] +#[entity(table = "audit_logs", streams)] +pub struct AuditLog { + #[id] + pub id: Uuid, + + #[field(create, response)] + #[filter] + pub action: String, + + #[field(create, response)] + #[filter] + pub resource_type: String, + + #[field(create, response)] + pub resource_id: Uuid, + + #[field(create, response)] + pub user_id: Option, + + #[field(create, response)] + pub details: Option, + + #[field(response)] + #[auto] + #[filter(range)] + pub created_at: DateTime, +} + +// Generated streaming methods: +// - stream_all() -> impl Stream> +// - stream_filtered(filter) -> impl Stream> +// - stream_by_action(action) -> impl Stream +// - stream_by_resource_type(type) -> impl Stream + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// Query Parameters +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct LogQuery { + action: Option, + resource_type: Option, + limit: Option, +} + +// ============================================================================ +// Statistics Response +// ============================================================================ + +#[derive(Debug, Serialize)] +struct StreamStats { + total_processed: usize, + actions: std::collections::HashMap, + resource_types: std::collections::HashMap, +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +/// Stream and aggregate logs - demonstrates memory-efficient processing. +/// +/// Instead of loading all records into memory, we process them one by one. +async fn aggregate_logs( + State(state): State, + Query(query): Query, +) -> Result { + let filter = AuditLogFilter { + action: query.action, + resource_type: query.resource_type, + created_at_min: None, + created_at_max: None, + }; + + let mut stream = state + .pool + .stream_filtered(filter) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut stats = StreamStats { + total_processed: 0, + actions: std::collections::HashMap::new(), + resource_types: std::collections::HashMap::new(), + }; + + let limit = query.limit.unwrap_or(1000) as usize; + + // Process stream without loading all into memory + while let Some(result) = stream.next().await { + if stats.total_processed >= limit { + break; + } + + match result { + Ok(log) => { + stats.total_processed += 1; + *stats.actions.entry(log.action).or_insert(0) += 1; + *stats.resource_types.entry(log.resource_type).or_insert(0) += 1; + } + Err(e) => { + tracing::error!("Stream error: {}", e); + break; + } + } + } + + Ok(Json(stats)) +} + +/// Stream logs as JSON array with chunked processing. +async fn list_logs_streamed( + State(state): State, + Query(query): Query, +) -> Result { + let filter = AuditLogFilter { + action: query.action, + resource_type: query.resource_type, + created_at_min: None, + created_at_max: None, + }; + + let mut stream = state + .pool + .stream_filtered(filter) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let limit = query.limit.unwrap_or(100) as usize; + let mut logs = Vec::with_capacity(limit.min(100)); + + while let Some(result) = stream.next().await { + if logs.len() >= limit { + break; + } + + match result { + Ok(log) => logs.push(AuditLogResponse::from(log)), + Err(e) => { + tracing::error!("Stream error: {}", e); + break; + } + } + } + + Ok(Json(logs)) +} + +/// Create a new audit log entry. +async fn create_log( + State(state): State, + Json(dto): Json, +) -> Result { + let log = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(AuditLogResponse::from(log)))) +} + +/// Export logs by action - demonstrates filtered streaming. +async fn export_by_action( + State(state): State, + axum::extract::Path(action): axum::extract::Path, +) -> Result { + let filter = AuditLogFilter { + action: Some(action.clone()), + resource_type: None, + created_at_min: None, + created_at_max: None, + }; + + let mut stream = state + .pool + .stream_filtered(filter) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut logs = Vec::new(); + + while let Some(result) = stream.next().await { + match result { + Ok(log) => logs.push(AuditLogResponse::from(log)), + Err(_) => break, + } + } + + tracing::info!("Exported {} logs for action '{}'", logs.len(), action); + Ok(Json(logs)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/logs", get(list_logs_streamed).post(create_log)) + .route("/logs/aggregate", get(aggregate_logs)) + .route("/logs/export/{action}", get(export_by_action)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_streams=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Endpoints:"); + tracing::info!(" GET /logs - stream logs with filtering"); + tracing::info!(" GET /logs/aggregate - aggregate stats from stream"); + tracing::info!(" GET /logs/export/{{action}} - export by action"); + + axum::serve(listener, app(state)).await.unwrap(); +} diff --git a/examples/transactions/Cargo.toml b/examples/transactions/Cargo.toml new file mode 100644 index 0000000..70906cd --- /dev/null +++ b/examples/transactions/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# SPDX-License-Identifier: MIT + +[package] +name = "example-transactions" +version = "0.1.0" +edition = "2024" +publish = false +description = "Multi-entity transactions example with entity-derive" + +[dependencies] +entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres"] } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/transactions/migrations/20240101000000_create_tables.sql b/examples/transactions/migrations/20240101000000_create_tables.sql new file mode 100644 index 0000000..95cb830 --- /dev/null +++ b/examples/transactions/migrations/20240101000000_create_tables.sql @@ -0,0 +1,26 @@ +-- Create tables for transactions example + +CREATE TABLE IF NOT EXISTS bank_accounts ( + id UUID PRIMARY KEY, + owner_name VARCHAR(255) NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS transfer_logs ( + id UUID PRIMARY KEY, + from_account_id UUID NOT NULL REFERENCES bank_accounts(id), + to_account_id UUID NOT NULL REFERENCES bank_accounts(id), + amount BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_transfer_logs_from ON transfer_logs(from_account_id); +CREATE INDEX idx_transfer_logs_to ON transfer_logs(to_account_id); + +-- Sample accounts with initial balances +INSERT INTO bank_accounts (id, owner_name, balance) VALUES + ('a0000000-0000-0000-0000-000000000001', 'Alice', 100000), + ('a0000000-0000-0000-0000-000000000002', 'Bob', 50000), + ('a0000000-0000-0000-0000-000000000003', 'Charlie', 75000); diff --git a/examples/transactions/src/main.rs b/examples/transactions/src/main.rs new file mode 100644 index 0000000..81bb97c --- /dev/null +++ b/examples/transactions/src/main.rs @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Transactions Example with entity-derive +//! +//! Demonstrates multi-entity transactions: +//! - `#[entity(transactions)]` generates transaction adapter +//! - Atomic operations across multiple entities +//! - Automatic rollback on error + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use chrono::{DateTime, Utc}; +use entity_core::prelude::*; +use entity_derive::Entity; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================ +// Entity Definitions with Transactions +// ============================================================================ + +/// Bank account with transaction support. +#[derive(Debug, Clone, Entity)] +#[entity(table = "bank_accounts", transactions)] +pub struct BankAccount { + #[id] + pub id: Uuid, + + #[field(create, update, response)] + pub owner_name: String, + + #[field(create, update, response)] + pub balance: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +/// Transfer log for audit. +#[derive(Debug, Clone, Entity)] +#[entity(table = "transfer_logs", transactions)] +pub struct TransferLog { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub from_account_id: Uuid, + + #[field(create, response)] + pub to_account_id: Uuid, + + #[field(create, response)] + pub amount: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + pool: Arc, +} + +// ============================================================================ +// Transfer Request +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct TransferRequest { + from_account_id: Uuid, + to_account_id: Uuid, + amount: i64, +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +/// Transfer money between accounts atomically. +/// +/// If ANY step fails, all changes are rolled back. +async fn transfer( + State(state): State, + Json(req): Json, +) -> Result { + if req.amount <= 0 { + return Err((StatusCode::BAD_REQUEST, "Amount must be positive".into())); + } + + let result = Transaction::new(&*state.pool) + .with_bank_accounts() + .with_transfer_logs() + .run(|mut ctx| async move { + // Step 1: Get source account + let from = ctx + .bank_accounts() + .find_by_id(req.from_account_id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + + // Step 2: Check balance + if from.balance < req.amount { + return Err(sqlx::Error::Protocol(format!( + "Insufficient funds: {} < {}", + from.balance, req.amount + ))); + } + + // Step 3: Get destination account + let to = ctx + .bank_accounts() + .find_by_id(req.to_account_id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + + // Step 4: Subtract from source + ctx.bank_accounts() + .update( + req.from_account_id, + UpdateBankAccountRequest { + owner_name: None, + balance: Some(from.balance - req.amount), + }, + ) + .await?; + + // Step 5: Add to destination + ctx.bank_accounts() + .update( + req.to_account_id, + UpdateBankAccountRequest { + owner_name: None, + balance: Some(to.balance + req.amount), + }, + ) + .await?; + + // Step 6: Create audit log + let log = ctx + .transfer_logs() + .create(CreateTransferLogRequest { + from_account_id: req.from_account_id, + to_account_id: req.to_account_id, + amount: req.amount, + }) + .await?; + + Ok(log) + }) + .await; + + match result { + Ok(log) => { + tracing::info!( + "Transfer successful: {} -> {} amount={}", + req.from_account_id, + req.to_account_id, + req.amount + ); + Ok((StatusCode::OK, Json(TransferLogResponse::from(log)))) + } + Err(e) => { + tracing::error!("Transfer failed: {}", e); + Err((StatusCode::BAD_REQUEST, e.to_string())) + } + } +} + +/// List all accounts. +async fn list_accounts( + State(state): State, +) -> Result { + let accounts = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = + accounts.into_iter().map(BankAccountResponse::from).collect(); + Ok(Json(responses)) +} + +/// Get account by ID. +async fn get_account( + State(state): State, + Path(id): Path, +) -> Result { + let account = state + .pool + .find_by_id(id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(BankAccountResponse::from(account))) +} + +/// Create a new account. +async fn create_account( + State(state): State, + Json(dto): Json, +) -> Result { + let account = state + .pool + .create(dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(BankAccountResponse::from(account)))) +} + +/// List transfer history. +async fn list_transfers( + State(state): State, +) -> Result { + let logs: Vec = state + .pool + .list(100, 0) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let responses: Vec = + logs.into_iter().map(TransferLogResponse::from).collect(); + Ok(Json(responses)) +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +fn app(state: AppState) -> Router { + Router::new() + .route("/accounts", get(list_accounts).post(create_account)) + .route("/accounts/{id}", get(get_account)) + .route("/transfer", post(transfer)) + .route("/transfers", get(list_transfers)) + .with_state(state) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("example_transactions=debug") + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); + + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + let state = AppState { + pool: Arc::new(pool), + }; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Try: POST /transfer with {{from_account_id, to_account_id, amount}}"); + + axum::serve(listener, app(state)).await.unwrap(); +} From 60024c9e6a42e17eb04b5ae7df869473dd8bdf62 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:11:25 +0700 Subject: [PATCH 02/30] fix(examples): add required dependencies and fix compilation - Add workspace.exclude for examples directories - Add postgres/api features to example crates - Add async-trait and utoipa dependencies - Add entity-core where needed - Fix SPDX headers in migration files - Fix type annotations for multi-entity repos - Add utoipa::path attributes to basic example - Update gitignore for example targets --- .gitignore | 1 + Cargo.toml | 12 +++++ examples/basic/Cargo.toml | 8 ++- examples/basic/src/main.rs | 50 ++++++++++++++++++- examples/commands/Cargo.toml | 8 ++- .../20240101000000_create_accounts.sql | 3 ++ examples/events/Cargo.toml | 11 +++- .../20240101000000_create_orders.sql | 3 ++ examples/filtering/Cargo.toml | 10 +++- .../20240101000000_create_products.sql | 3 ++ examples/filtering/src/main.rs | 20 ++++---- examples/full-app/Cargo.toml | 8 ++- .../20240101000000_create_schema.sql | 3 ++ examples/hooks/Cargo.toml | 8 ++- .../20240101000000_create_users.sql | 3 ++ examples/relations/Cargo.toml | 10 +++- .../20240101000000_create_tables.sql | 3 ++ examples/relations/src/main.rs | 29 ++++------- examples/soft-delete/Cargo.toml | 10 +++- .../20240101000000_create_documents.sql | 3 ++ examples/streams/Cargo.toml | 10 +++- .../migrations/20240101000000_create_logs.sql | 3 ++ examples/transactions/Cargo.toml | 10 +++- .../20240101000000_create_tables.sql | 3 ++ 24 files changed, 193 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 96ef6c0..3a24ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +examples/**/target/ diff --git a/Cargo.toml b/Cargo.toml index 7ae1a0f..74389d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,18 @@ [workspace] resolver = "3" members = ["crates/*"] +exclude = [ + "examples/basic", + "examples/filtering", + "examples/relations", + "examples/events", + "examples/hooks", + "examples/commands", + "examples/transactions", + "examples/soft-delete", + "examples/streams", + "examples/full-app", +] [workspace.package] version = "0.3.0" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index adcad46..f200972 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Basic CRUD example with entity-derive and Axum" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 3aa0fc6..55ce2c9 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -16,7 +16,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{delete, get, patch, post}, + routing::{get, post}, }; use chrono::{DateTime, Utc}; use entity_derive::Entity; @@ -133,6 +133,15 @@ impl IntoResponse for AppError { // ============================================================================ /// Create a new user. +#[utoipa::path( + post, + path = "/users", + request_body = CreateUserRequest, + responses( + (status = 201, description = "User created", body = UserResponse), + (status = 500, description = "Internal error"), + ) +)] async fn create_user( State(state): State, Json(dto): Json, @@ -142,6 +151,15 @@ async fn create_user( } /// Get user by ID. +#[utoipa::path( + get, + path = "/users/{id}", + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, description = "User found", body = UserResponse), + (status = 404, description = "User not found"), + ) +)] async fn get_user( State(state): State, Path(id): Path, @@ -151,6 +169,16 @@ async fn get_user( } /// Update user by ID. +#[utoipa::path( + patch, + path = "/users/{id}", + params(("id" = Uuid, Path, description = "User ID")), + request_body = UpdateUserRequest, + responses( + (status = 200, description = "User updated", body = UserResponse), + (status = 404, description = "User not found"), + ) +)] async fn update_user( State(state): State, Path(id): Path, @@ -161,6 +189,15 @@ async fn update_user( } /// Delete user by ID. +#[utoipa::path( + delete, + path = "/users/{id}", + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 204, description = "User deleted"), + (status = 404, description = "User not found"), + ) +)] async fn delete_user( State(state): State, Path(id): Path, @@ -174,6 +211,17 @@ async fn delete_user( } /// List users with pagination. +#[utoipa::path( + get, + path = "/users", + params( + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Skip results"), + ), + responses( + (status = 200, description = "List of users", body = Vec), + ) +)] async fn list_users( State(state): State, Query(params): Query, diff --git a/examples/commands/Cargo.toml b/examples/commands/Cargo.toml index 335947d..9a92968 100644 --- a/examples/commands/Cargo.toml +++ b/examples/commands/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "CQRS commands example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } diff --git a/examples/commands/migrations/20240101000000_create_accounts.sql b/examples/commands/migrations/20240101000000_create_accounts.sql index e946cf3..118b7d2 100644 --- a/examples/commands/migrations/20240101000000_create_accounts.sql +++ b/examples/commands/migrations/20240101000000_create_accounts.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create accounts table for commands example CREATE TABLE IF NOT EXISTS accounts ( diff --git a/examples/events/Cargo.toml b/examples/events/Cargo.toml index b763fe3..07182b1 100644 --- a/examples/events/Cargo.toml +++ b/examples/events/Cargo.toml @@ -8,8 +8,15 @@ edition = "2024" publish = false description = "Lifecycle events example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres"] } axum = "0.8" tokio = { version = "1", features = ["full", "sync"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } @@ -17,5 +24,7 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/events/migrations/20240101000000_create_orders.sql b/examples/events/migrations/20240101000000_create_orders.sql index a03daf3..14ab784 100644 --- a/examples/events/migrations/20240101000000_create_orders.sql +++ b/examples/events/migrations/20240101000000_create_orders.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create orders table for events example CREATE TABLE IF NOT EXISTS orders ( diff --git a/examples/filtering/Cargo.toml b/examples/filtering/Cargo.toml index b469fef..84c42c3 100644 --- a/examples/filtering/Cargo.toml +++ b/examples/filtering/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Type-safe filtering example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } @@ -17,5 +23,7 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/filtering/migrations/20240101000000_create_products.sql b/examples/filtering/migrations/20240101000000_create_products.sql index 2a30ab7..5e54fe6 100644 --- a/examples/filtering/migrations/20240101000000_create_products.sql +++ b/examples/filtering/migrations/20240101000000_create_products.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create products table for filtering example CREATE TABLE IF NOT EXISTS products ( id UUID PRIMARY KEY, diff --git a/examples/filtering/src/main.rs b/examples/filtering/src/main.rs index a9ddf19..74b8be4 100644 --- a/examples/filtering/src/main.rs +++ b/examples/filtering/src/main.rs @@ -110,13 +110,15 @@ impl From for ProductQuery { Self { name: p.name, category: p.category, - price_min: p.price_min, - price_max: p.price_max, - stock_min: p.stock_min, - stock_max: None, + price_from: p.price_min, + price_to: p.price_max, + stock_from: p.stock_min, + stock_to: None, active: p.active, - created_at_min: None, - created_at_max: None, + created_at_from: None, + created_at_to: None, + limit: Some(p.limit), + offset: Some(p.offset), } } } @@ -135,13 +137,13 @@ async fn list_products( State(state): State, Query(params): Query, ) -> Result { - let limit = params.limit; - let offset = params.offset; + // Convert to generated ProductQuery (includes limit/offset) let query: ProductQuery = params.into(); + // Use generated query method for type-safe filtering with pagination let products = state .pool - .list_filtered(&query, limit, offset) + .query(query) .await .map_err(|e| { tracing::error!("Database error: {e}"); diff --git a/examples/full-app/Cargo.toml b/examples/full-app/Cargo.toml index 7dbe741..67d73c6 100644 --- a/examples/full-app/Cargo.toml +++ b/examples/full-app/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Complete application example showcasing all entity-derive features" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } entity-core = { path = "../../crates/entity-core", features = ["postgres"] } axum = "0.8" tokio = { version = "1", features = ["full", "sync"] } diff --git a/examples/full-app/migrations/20240101000000_create_schema.sql b/examples/full-app/migrations/20240101000000_create_schema.sql index 6c6744b..aa8c1d0 100644 --- a/examples/full-app/migrations/20240101000000_create_schema.sql +++ b/examples/full-app/migrations/20240101000000_create_schema.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Full Application Schema -- Demonstrates all entity-derive features in one application diff --git a/examples/hooks/Cargo.toml b/examples/hooks/Cargo.toml index 4a770e4..bfccbea 100644 --- a/examples/hooks/Cargo.toml +++ b/examples/hooks/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Lifecycle hooks example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } diff --git a/examples/hooks/migrations/20240101000000_create_users.sql b/examples/hooks/migrations/20240101000000_create_users.sql index 4217f5b..f8100ef 100644 --- a/examples/hooks/migrations/20240101000000_create_users.sql +++ b/examples/hooks/migrations/20240101000000_create_users.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create users table for hooks example CREATE TABLE IF NOT EXISTS users ( diff --git a/examples/relations/Cargo.toml b/examples/relations/Cargo.toml index 3c924c9..9d86cb5 100644 --- a/examples/relations/Cargo.toml +++ b/examples/relations/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Entity relations example with belongs_to and has_many" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } @@ -17,5 +23,7 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/relations/migrations/20240101000000_create_tables.sql b/examples/relations/migrations/20240101000000_create_tables.sql index e27746b..22d55d5 100644 --- a/examples/relations/migrations/20240101000000_create_tables.sql +++ b/examples/relations/migrations/20240101000000_create_tables.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create tables for relations example CREATE TABLE IF NOT EXISTS authors ( diff --git a/examples/relations/src/main.rs b/examples/relations/src/main.rs index 355fed8..9cf5cda 100644 --- a/examples/relations/src/main.rs +++ b/examples/relations/src/main.rs @@ -108,17 +108,14 @@ async fn get_author_with_posts( State(state): State, Path(id): Path, ) -> Result { - let author = state - .pool - .find_by_id(id) + // Use fully qualified syntax when multiple Repository traits are in scope + let author = AuthorRepository::find_by_id(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; - // Use generated find_posts method - let posts: Vec = state - .pool - .find_posts(id) + // Use generated find_posts method (from has_many) + let posts = AuthorRepository::find_posts(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -133,24 +130,19 @@ async fn get_post_with_details( State(state): State, Path(id): Path, ) -> Result { - let post = state - .pool - .find_by_id(id) + // Use fully qualified syntax when multiple Repository traits are in scope + let post = PostRepository::find_by_id(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; // Use generated find_author method (from belongs_to) - let author: Option = state - .pool - .find_author(post.author_id) + let author = PostRepository::find_author(&*state.pool, post.author_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Use generated find_comments method (from has_many) - let comments: Vec = state - .pool - .find_comments(id) + let comments = PostRepository::find_comments(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -165,9 +157,8 @@ async fn get_post_with_details( async fn list_authors( State(state): State, ) -> Result { - let authors = state - .pool - .list(100, 0) + // Use fully qualified syntax when multiple Repository traits are in scope + let authors = AuthorRepository::list(&*state.pool, 100, 0) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/examples/soft-delete/Cargo.toml b/examples/soft-delete/Cargo.toml index b900f9c..da48585 100644 --- a/examples/soft-delete/Cargo.toml +++ b/examples/soft-delete/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Soft delete example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } @@ -17,5 +23,7 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/soft-delete/migrations/20240101000000_create_documents.sql b/examples/soft-delete/migrations/20240101000000_create_documents.sql index d56838a..d8e9373 100644 --- a/examples/soft-delete/migrations/20240101000000_create_documents.sql +++ b/examples/soft-delete/migrations/20240101000000_create_documents.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create documents table for soft delete example CREATE TABLE IF NOT EXISTS documents ( diff --git a/examples/streams/Cargo.toml b/examples/streams/Cargo.toml index fbb31bf..ba196f2 100644 --- a/examples/streams/Cargo.toml +++ b/examples/streams/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Real-time streams example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } axum = "0.8" tokio = { version = "1", features = ["full", "sync"] } tokio-stream = "0.1" @@ -18,6 +24,8 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/streams/migrations/20240101000000_create_logs.sql b/examples/streams/migrations/20240101000000_create_logs.sql index 8a17558..0096238 100644 --- a/examples/streams/migrations/20240101000000_create_logs.sql +++ b/examples/streams/migrations/20240101000000_create_logs.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create logs table for streams example CREATE TABLE IF NOT EXISTS audit_logs ( diff --git a/examples/transactions/Cargo.toml b/examples/transactions/Cargo.toml index 70906cd..ecf1742 100644 --- a/examples/transactions/Cargo.toml +++ b/examples/transactions/Cargo.toml @@ -8,8 +8,14 @@ edition = "2024" publish = false description = "Multi-entity transactions example with entity-derive" +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } entity-core = { path = "../../crates/entity-core", features = ["postgres"] } axum = "0.8" tokio = { version = "1", features = ["full"] } @@ -18,5 +24,7 @@ uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/transactions/migrations/20240101000000_create_tables.sql b/examples/transactions/migrations/20240101000000_create_tables.sql index 95cb830..d1be580 100644 --- a/examples/transactions/migrations/20240101000000_create_tables.sql +++ b/examples/transactions/migrations/20240101000000_create_tables.sql @@ -1,3 +1,6 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + -- Create tables for transactions example CREATE TABLE IF NOT EXISTS bank_accounts ( From 03bf86015bc99c1e4025eb042e746593bbf842ad Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:20:42 +0700 Subject: [PATCH 03/30] #68 fix: hooks example - add utoipa dependency - Add utoipa dependency to Cargo.toml - Fix unused imports --- examples/hooks/Cargo.toml | 1 + examples/hooks/src/main.rs | 47 ++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/examples/hooks/Cargo.toml b/examples/hooks/Cargo.toml index bfccbea..9749ab1 100644 --- a/examples/hooks/Cargo.toml +++ b/examples/hooks/Cargo.toml @@ -24,5 +24,6 @@ chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/hooks/src/main.rs b/examples/hooks/src/main.rs index 1756122..7984409 100644 --- a/examples/hooks/src/main.rs +++ b/examples/hooks/src/main.rs @@ -9,18 +9,19 @@ //! - before_update, after_update //! - before_delete, after_delete +use std::sync::Arc; + +use async_trait::async_trait; use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::{get, post, patch, delete}, + routing::{patch, post} }; -use async_trait::async_trait; use chrono::{DateTime, Utc}; use entity_derive::Entity; use sqlx::PgPool; -use std::sync::Arc; use uuid::Uuid; // ============================================================================ @@ -45,20 +46,20 @@ pub struct User { #[field(response)] #[auto] - pub created_at: DateTime, + pub created_at: DateTime } // Generated trait by macro: // #[async_trait] // pub trait UserHooks: Send + Sync { // type Error: std::error::Error + Send + Sync; -// async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>; -// async fn after_create(&self, entity: &User) -> Result<(), Self::Error>; -// async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>; -// async fn after_update(&self, entity: &User) -> Result<(), Self::Error>; -// async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; -// async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>; -// } +// async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), +// Self::Error>; async fn after_create(&self, entity: &User) -> Result<(), +// Self::Error>; async fn before_update(&self, id: &Uuid, dto: &mut +// UpdateUserRequest) -> Result<(), Self::Error>; async fn +// after_update(&self, entity: &User) -> Result<(), Self::Error>; async fn +// before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; async fn +// after_delete(&self, id: &Uuid) -> Result<(), Self::Error>; } // ============================================================================ // Hooks Implementation @@ -103,7 +104,11 @@ impl UserHooks for MyUserHooks { Ok(()) } - async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> { + async fn before_update( + &self, + id: &Uuid, + dto: &mut UpdateUserRequest + ) -> Result<(), Self::Error> { if let Some(ref mut email) = dto.email { *email = email.to_lowercase(); } @@ -135,8 +140,8 @@ impl UserHooks for MyUserHooks { #[derive(Clone)] struct AppState { - pool: Arc, - hooks: Arc, + pool: Arc, + hooks: Arc } // ============================================================================ @@ -145,7 +150,7 @@ struct AppState { async fn create_user( State(state): State, - Json(mut dto): Json, + Json(mut dto): Json ) -> Result { // Run before_create hook state @@ -173,7 +178,7 @@ async fn create_user( async fn update_user( State(state): State, Path(id): Path, - Json(mut dto): Json, + Json(mut dto): Json ) -> Result { // Run before_update hook state @@ -200,7 +205,7 @@ async fn update_user( async fn delete_user( State(state): State, - Path(id): Path, + Path(id): Path ) -> Result { // Run before_delete hook state @@ -229,9 +234,7 @@ async fn delete_user( } } -async fn list_users( - State(state): State, -) -> Result { +async fn list_users(State(state): State) -> Result { let users = state .pool .list(100, 0) @@ -276,8 +279,8 @@ async fn main() { .expect("Failed to run migrations"); let state = AppState { - pool: Arc::new(pool), - hooks: Arc::new(MyUserHooks), + pool: Arc::new(pool), + hooks: Arc::new(MyUserHooks) }; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); From e29f9d2227076b84fd81b4ec6d01c4876b526f52 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:24:31 +0700 Subject: [PATCH 04/30] #69 fix: commands example - fix dependencies and struct naming - Add entity-core and utoipa dependencies - Rename AccountCommandHandler to MyAccountHandler (avoid conflict with generated trait) - Use source = "update" for UpdateEmail command - Create separate input types for HTTP handlers (commands lack Deserialize) - Use AccountRepository:: fully qualified syntax --- examples/commands/Cargo.toml | 2 + examples/commands/src/main.rs | 105 ++++++++++++++++++++-------------- 2 files changed, 65 insertions(+), 42 deletions(-) diff --git a/examples/commands/Cargo.toml b/examples/commands/Cargo.toml index 9a92968..74159a8 100644 --- a/examples/commands/Cargo.toml +++ b/examples/commands/Cargo.toml @@ -16,6 +16,7 @@ validate = [] [dependencies] entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } @@ -24,5 +25,6 @@ chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" +utoipa = { version = "5", features = ["chrono", "uuid"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/commands/src/main.rs b/examples/commands/src/main.rs index 2d2089c..f66095d 100644 --- a/examples/commands/src/main.rs +++ b/examples/commands/src/main.rs @@ -8,18 +8,18 @@ //! - `#[command(Name)]` defines a command //! - `#[command(Name, requires_id)]` for existing entity +use std::sync::Arc; + use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::post, + routing::post }; -use async_trait::async_trait; use chrono::{DateTime, Utc}; use entity_derive::Entity; use sqlx::PgPool; -use std::sync::Arc; use uuid::Uuid; // ============================================================================ @@ -31,8 +31,9 @@ use uuid::Uuid; #[entity(table = "accounts", commands)] #[command(Register)] #[command(Activate, requires_id)] +#[allow(clippy::duplicated_attributes)] #[command(Deactivate, requires_id)] -#[command(UpdateEmail, requires_id)] +#[command(UpdateEmail, source = "update")] pub struct Account { #[id] pub id: Uuid, @@ -48,7 +49,7 @@ pub struct Account { #[field(response)] #[auto] - pub created_at: DateTime, + pub created_at: DateTime } // Generated commands: @@ -72,22 +73,20 @@ impl std::fmt::Display for CommandError { impl std::error::Error for CommandError {} -struct AccountCommandHandler { - pool: Arc, +struct MyAccountHandler { + pool: Arc } -impl AccountCommandHandler { +impl MyAccountHandler { async fn handle_register(&self, cmd: RegisterAccount) -> Result { tracing::info!("[CMD] Register: email={}", cmd.email); let dto = CreateAccountRequest { email: cmd.email.to_lowercase(), - name: cmd.name, - active: false, // New accounts start inactive + name: cmd.name }; - self.pool - .create(dto) + AccountRepository::create(&*self.pool, dto) .await .map_err(|e| CommandError(e.to_string())) } @@ -96,13 +95,12 @@ impl AccountCommandHandler { tracing::info!("[CMD] Activate: id={}", cmd.id); let dto = UpdateAccountRequest { - email: None, - name: None, - active: Some(true), + email: None, + name: None, + active: Some(true) }; - self.pool - .update(cmd.id, dto) + AccountRepository::update(&*self.pool, cmd.id, dto) .await .map_err(|e| CommandError(e.to_string())) } @@ -111,31 +109,26 @@ impl AccountCommandHandler { tracing::info!("[CMD] Deactivate: id={}", cmd.id); let dto = UpdateAccountRequest { - email: None, - name: None, - active: Some(false), + email: None, + name: None, + active: Some(false) }; - self.pool - .update(cmd.id, dto) + AccountRepository::update(&*self.pool, cmd.id, dto) .await .map_err(|e| CommandError(e.to_string())) } - async fn handle_update_email( - &self, - cmd: UpdateEmailAccount, - ) -> Result { + async fn handle_update_email(&self, cmd: UpdateEmailAccount) -> Result { tracing::info!("[CMD] UpdateEmail: id={}, email={:?}", cmd.id, cmd.email); let dto = UpdateAccountRequest { - email: cmd.email.map(|e| e.to_lowercase()), - name: cmd.name, - active: cmd.active, + email: cmd.email.map(|e| e.to_lowercase()), + name: cmd.name, + active: cmd.active }; - self.pool - .update(cmd.id, dto) + AccountRepository::update(&*self.pool, cmd.id, dto) .await .map_err(|e| CommandError(e.to_string())) } @@ -147,17 +140,36 @@ impl AccountCommandHandler { #[derive(Clone)] struct AppState { - handler: Arc, + handler: Arc } // ============================================================================ // HTTP Handlers - Command Endpoints // ============================================================================ +/// Input for register command. +#[derive(serde::Deserialize)] +struct RegisterInput { + email: String, + name: String +} + +/// Input for update email command. +#[derive(serde::Deserialize)] +struct UpdateEmailInput { + email: Option, + name: Option, + active: Option +} + async fn register( State(state): State, - Json(cmd): Json, + Json(input): Json ) -> Result { + let cmd = RegisterAccount { + email: input.email, + name: input.name + }; let account = state .handler .handle_register(cmd) @@ -169,9 +181,11 @@ async fn register( async fn activate( State(state): State, - Path(id): Path, + Path(id): Path ) -> Result { - let cmd = ActivateAccount { id }; + let cmd = ActivateAccount { + id + }; let account = state .handler .handle_activate(cmd) @@ -183,9 +197,11 @@ async fn activate( async fn deactivate( State(state): State, - Path(id): Path, + Path(id): Path ) -> Result { - let cmd = DeactivateAccount { id }; + let cmd = DeactivateAccount { + id + }; let account = state .handler .handle_deactivate(cmd) @@ -198,9 +214,14 @@ async fn deactivate( async fn update_email( State(state): State, Path(id): Path, - Json(mut cmd): Json, + Json(input): Json ) -> Result { - cmd.id = id; + let cmd = UpdateEmailAccount { + id, + email: input.email, + name: input.name, + active: input.active + }; let account = state .handler .handle_update_email(cmd) @@ -246,12 +267,12 @@ async fn main() { .await .expect("Failed to run migrations"); - let handler = AccountCommandHandler { - pool: Arc::new(pool), + let handler = MyAccountHandler { + pool: Arc::new(pool) }; let state = AppState { - handler: Arc::new(handler), + handler: Arc::new(handler) }; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); From 5b47b7ecb1764d74fef8f8e61267d7a87d97c5f7 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:36:32 +0700 Subject: [PATCH 05/30] feat: implement multi-entity transaction API (#70) - Simplified TransactionContext to use 'static lifetime for sqlx::Transaction - Added extension traits for entity repository access (ctx.bank_accounts()) - Implemented pluralize() function for proper method naming - Updated transactions example with fully qualified syntax - Added cfg guards for postgres-specific code --- crates/entity-core/src/prelude.rs | 7 +- crates/entity-core/src/transaction.rs | 375 ++++++------------ .../src/entity/transaction.rs | 87 +++- examples/transactions/src/main.rs | 16 +- 4 files changed, 209 insertions(+), 276 deletions(-) diff --git a/crates/entity-core/src/prelude.rs b/crates/entity-core/src/prelude.rs index 5f9723d..45fa8c6 100644 --- a/crates/entity-core/src/prelude.rs +++ b/crates/entity-core/src/prelude.rs @@ -11,12 +11,11 @@ #[cfg(feature = "streams")] pub use crate::stream::StreamError; +#[cfg(feature = "postgres")] +pub use crate::transaction::TransactionContext; pub use crate::{ CommandKind, EntityCommand, EntityEvent, EventKind, Pagination, Repository, SortDirection, async_trait, policy::{PolicyError, PolicyOperation}, - transaction::{ - Transaction, TransactionContext, TransactionError, TransactionOps, TransactionRunner, - Transactional - } + transaction::{Transaction, TransactionError} }; diff --git a/crates/entity-core/src/transaction.rs b/crates/entity-core/src/transaction.rs index fd2faca..36d39a8 100644 --- a/crates/entity-core/src/transaction.rs +++ b/crates/entity-core/src/transaction.rs @@ -4,13 +4,13 @@ //! Transaction support for entity-derive. //! //! This module provides type-safe transaction management with automatic -//! commit/rollback semantics. It uses the builder pattern for composing -//! multiple repositories into a single transaction context. +//! commit/rollback semantics. It uses a fluent builder pattern for composing +//! multiple entity operations into a single transaction. //! //! # Overview //! //! - [`Transaction`] — Entry point for creating transactions -//! - [`TransactionContext`] — Holds active transaction and repository adapters +//! - [`TransactionContext`] — Holds active transaction, provides repo access //! - [`TransactionError`] — Error wrapper for transaction operations //! //! # Example @@ -18,7 +18,7 @@ //! ```rust,ignore //! use entity_derive::prelude::*; //! -//! async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amount: Decimal) -> Result<(), AppError> { +//! async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amount: i64) -> Result<(), AppError> { //! Transaction::new(pool) //! .with_accounts() //! .with_transfers() @@ -27,6 +27,7 @@ //! //! ctx.accounts().update(from, UpdateAccount { //! balance: Some(from_acc.balance - amount), +//! ..Default::default() //! }).await?; //! //! ctx.transfers().create(CreateTransfer { from, to, amount }).await?; @@ -36,23 +37,36 @@ //! } //! ``` -use std::{error::Error as StdError, fmt, future::Future, marker::PhantomData}; +use std::{error::Error as StdError, fmt, future::Future}; -/// Transaction builder for composing repositories. +/// Transaction builder for composing multi-entity operations. /// -/// Use [`Transaction::new`] to create a builder, then chain `.with_*()` methods -/// to add repositories, and finally call `.run()` to execute. +/// Use [`Transaction::new`] to create a builder, chain `.with_*()` methods +/// to declare which entities you'll use, then call `.run()` to execute. /// /// # Type Parameters /// -/// - `DB` — Database type (e.g., `Postgres`) -/// - `Repos` — Tuple of repository adapters accumulated via builder -pub struct Transaction<'p, DB, Repos = ()> { - pool: &'p DB, - _repos: PhantomData +/// - `'p` — Pool lifetime +/// - `DB` — Database pool type (e.g., `PgPool`) +/// +/// # Example +/// +/// ```rust,ignore +/// Transaction::new(&pool) +/// .with_users() +/// .with_orders() +/// .run(|mut ctx| async move { +/// let user = ctx.users().find_by_id(id).await?; +/// ctx.orders().create(order).await?; +/// Ok(()) +/// }) +/// .await?; +/// ``` +pub struct Transaction<'p, DB> { + pool: &'p DB } -impl<'p, DB> Transaction<'p, DB, ()> { +impl<'p, DB> Transaction<'p, DB> { /// Create a new transaction builder. /// /// # Arguments @@ -66,82 +80,76 @@ impl<'p, DB> Transaction<'p, DB, ()> { /// ``` pub const fn new(pool: &'p DB) -> Self { Self { - pool, - _repos: PhantomData + pool } } -} -impl<'p, DB, Repos> Transaction<'p, DB, Repos> { /// Get reference to the underlying pool. pub const fn pool(&self) -> &'p DB { self.pool } - - /// Transform repository tuple type. - /// - /// Used internally by generated `with_*` methods. - #[doc(hidden)] - pub const fn with_repo(self) -> Transaction<'p, DB, NewRepos> { - Transaction { - pool: self.pool, - _repos: PhantomData - } - } } -/// Active transaction context with repository adapters. +/// Active transaction context with repository access. /// /// This struct holds the database transaction and provides access to -/// repository adapters that operate within the transaction. +/// entity repositories via extension traits generated by the macro. /// /// # Automatic Rollback /// /// If dropped without explicit commit, the transaction is automatically /// rolled back via the underlying database transaction's Drop impl. /// -/// # Type Parameters +/// # Accessing Repositories /// -/// - `'t` — Transaction lifetime -/// - `Tx` — Transaction type (e.g., `sqlx::Transaction<'t, Postgres>`) -/// - `Repos` — Tuple of repository adapters -pub struct TransactionContext<'t, Tx, Repos> { - tx: Tx, - repos: Repos, - _lifetime: PhantomData<&'t ()> +/// Each entity with `#[entity(transactions)]` generates an extension trait +/// that adds an accessor method: +/// +/// ```rust,ignore +/// // For entity BankAccount, use: +/// ctx.bank_accounts().find_by_id(id).await?; +/// ctx.bank_accounts().create(dto).await?; +/// ctx.bank_accounts().update(id, dto).await?; +/// ``` +#[cfg(feature = "postgres")] +pub struct TransactionContext { + tx: sqlx::Transaction<'static, sqlx::Postgres> } -impl<'t, Tx, Repos> TransactionContext<'t, Tx, Repos> { +#[cfg(feature = "postgres")] +impl TransactionContext { /// Create a new transaction context. /// /// # Arguments /// /// * `tx` — Active database transaction - /// * `repos` — Repository adapters tuple #[doc(hidden)] - pub const fn new(tx: Tx, repos: Repos) -> Self { + pub fn new(tx: sqlx::Transaction<'static, sqlx::Postgres>) -> Self { Self { - tx, - repos, - _lifetime: PhantomData + tx } } /// Get mutable reference to the underlying transaction. /// - /// Use this for custom queries within the transaction. - pub fn transaction(&mut self) -> &mut Tx { + /// Use this for custom queries within the transaction or + /// for repository adapters to execute queries. + pub fn transaction(&mut self) -> &mut sqlx::Transaction<'static, sqlx::Postgres> { &mut self.tx } - /// Get reference to repository adapters. - pub const fn repos(&self) -> &Repos { - &self.repos + /// Commit the transaction. + /// + /// Consumes self and commits all changes. + pub async fn commit(self) -> Result<(), sqlx::Error> { + self.tx.commit().await } - /// Get mutable reference to repository adapters. - pub fn repos_mut(&mut self) -> &mut Repos { - &mut self.repos + /// Rollback the transaction. + /// + /// Consumes self and rolls back all changes. + pub async fn rollback(self) -> Result<(), sqlx::Error> { + self.tx.rollback().await } } @@ -211,130 +219,80 @@ impl TransactionError { } } -/// Trait for types that can begin a transaction. -/// -/// Implemented for database pools to enable transaction creation. -#[allow(async_fn_in_trait)] -pub trait Transactional: Sized + Send + Sync { - /// Transaction type. - type Transaction<'t>: Send - where - Self: 't; - - /// Error type for transaction operations. - type Error: StdError + Send + Sync; - - /// Begin a new transaction. - async fn begin(&self) -> Result, Self::Error>; -} - -/// Trait for transaction types that can be committed or rolled back. -#[allow(async_fn_in_trait)] -pub trait TransactionOps: Sized + Send { - /// Error type. - type Error: StdError + Send + Sync; - - /// Commit the transaction. - async fn commit(self) -> Result<(), Self::Error>; - - /// Rollback the transaction. - async fn rollback(self) -> Result<(), Self::Error>; +#[cfg(feature = "postgres")] +impl From> for sqlx::Error { + fn from(err: TransactionError) -> Self { + err.into_inner() + } } -/// Trait for executing operations within a transaction. -/// -/// This trait is implemented on [`Transaction`] with specific repository -/// combinations, enabling type-safe execution. -#[allow(async_fn_in_trait)] -pub trait TransactionRunner<'p, Repos>: Sized { - /// Transaction type. - type Tx: TransactionOps; - - /// Database error type. - type DbError: StdError + Send + Sync; - - /// Execute a closure within the transaction. +// PostgreSQL implementation +#[cfg(feature = "postgres")] +impl<'p> Transaction<'p, sqlx::PgPool> { + /// Execute a closure within a PostgreSQL transaction. /// - /// Automatically commits on `Ok`, rolls back on `Err` or panic. + /// Automatically commits on `Ok`, rolls back on `Err` or drop. /// /// # Type Parameters /// /// - `F` — Closure type /// - `Fut` — Future returned by closure /// - `T` — Success type - /// - `E` — Error type (must be convertible from database error) - async fn run(self, f: F) -> Result + /// - `E` — Error type (must be convertible from sqlx::Error) + /// + /// # Example + /// + /// ```rust,ignore + /// Transaction::new(&pool) + /// .with_users() + /// .run(|mut ctx| async move { + /// let user = ctx.users().create(dto).await?; + /// Ok(user) + /// }) + /// .await?; + /// ``` + pub async fn run(self, f: F) -> Result where - F: FnOnce(TransactionContext<'_, Self::Tx, Repos>) -> Fut + Send, + F: FnOnce(TransactionContext) -> Fut + Send, Fut: Future> + Send, - E: From>; -} - -// sqlx implementations (requires database for testing) -// LCOV_EXCL_START -#[cfg(feature = "postgres")] -mod postgres_impl { - use sqlx::{PgPool, Postgres}; - - use super::*; - - impl Transactional for PgPool { - type Transaction<'t> = sqlx::Transaction<'t, Postgres>; - type Error = sqlx::Error; - - async fn begin(&self) -> Result, Self::Error> { - sqlx::pool::Pool::begin(self).await + E: From + { + let tx = self.pool.begin().await.map_err(E::from)?; + let ctx = TransactionContext::new(tx); + + match f(ctx).await { + Ok(result) => Ok(result), + Err(e) => Err(e) } } - impl TransactionOps for sqlx::Transaction<'_, Postgres> { - type Error = sqlx::Error; - - async fn commit(self) -> Result<(), Self::Error> { - sqlx::Transaction::commit(self).await - } - - async fn rollback(self) -> Result<(), Self::Error> { - sqlx::Transaction::rollback(self).await - } - } - - impl<'p, Repos: Send> Transaction<'p, PgPool, Repos> { - /// Execute a closure within a PostgreSQL transaction. - /// - /// Automatically commits on `Ok`, rolls back on `Err` or drop. - /// - /// # Example - /// - /// ```rust,ignore - /// Transaction::new(&pool) - /// .with_users() - /// .run(|mut ctx| async move { - /// ctx.users().create(dto).await - /// }) - /// .await?; - /// ``` - pub async fn run(self, f: F) -> Result - where - F: for<'t> FnOnce( - TransactionContext<'t, sqlx::Transaction<'t, Postgres>, Repos> - ) -> Fut - + Send, - Fut: Future> + Send, - E: From>, - Repos: Default - { - let tx = self.pool.begin().await.map_err(TransactionError::Begin)?; - let ctx = TransactionContext::new(tx, Repos::default()); - - match f(ctx).await { - Ok(result) => Ok(result), - Err(e) => Err(e) - } - } + /// Execute a closure within a transaction with explicit commit. + /// + /// Unlike `run`, this method requires the closure to explicitly + /// commit the transaction by calling `ctx.commit()`. + /// + /// # Example + /// + /// ```rust,ignore + /// Transaction::new(&pool) + /// .run_with_commit(|mut ctx| async move { + /// let user = ctx.users().create(dto).await?; + /// ctx.commit().await?; + /// Ok(user) + /// }) + /// .await?; + /// ``` + pub async fn run_with_commit(self, f: F) -> Result + where + F: FnOnce(TransactionContext) -> Fut + Send, + Fut: Future> + Send, + E: From + { + let tx = self.pool.begin().await.map_err(E::from)?; + let ctx = TransactionContext::new(tx); + f(ctx).await } } -// LCOV_EXCL_STOP #[cfg(test)] mod tests { @@ -351,25 +309,22 @@ mod tests { #[test] fn transaction_error_display_commit() { let err: TransactionError = - TransactionError::Commit(std::io::Error::other("commit_err")); + TransactionError::Commit(std::io::Error::other("test")); assert!(err.to_string().contains("commit")); - assert!(err.to_string().contains("commit_err")); } #[test] fn transaction_error_display_rollback() { let err: TransactionError = - TransactionError::Rollback(std::io::Error::other("rollback_err")); + TransactionError::Rollback(std::io::Error::other("test")); assert!(err.to_string().contains("rollback")); - assert!(err.to_string().contains("rollback_err")); } #[test] fn transaction_error_display_operation() { let err: TransactionError = - TransactionError::Operation(std::io::Error::other("op_err")); + TransactionError::Operation(std::io::Error::other("test")); assert!(err.to_string().contains("operation")); - assert!(err.to_string().contains("op_err")); } #[test] @@ -377,95 +332,17 @@ mod tests { let begin: TransactionError<&str> = TransactionError::Begin("e"); let commit: TransactionError<&str> = TransactionError::Commit("e"); let rollback: TransactionError<&str> = TransactionError::Rollback("e"); - let op: TransactionError<&str> = TransactionError::Operation("e"); + let operation: TransactionError<&str> = TransactionError::Operation("e"); assert!(begin.is_begin()); - assert!(!begin.is_commit()); - assert!(!begin.is_rollback()); - assert!(!begin.is_operation()); - assert!(commit.is_commit()); - assert!(!commit.is_begin()); - assert!(rollback.is_rollback()); - assert!(!rollback.is_begin()); - - assert!(op.is_operation()); - assert!(!op.is_begin()); + assert!(operation.is_operation()); } #[test] fn transaction_error_into_inner() { - let err: TransactionError<&str> = TransactionError::Operation("inner"); - assert_eq!(err.into_inner(), "inner"); - } - - #[test] - fn transaction_error_into_inner_all_variants() { - assert_eq!(TransactionError::Begin("b").into_inner(), "b"); - assert_eq!(TransactionError::Commit("c").into_inner(), "c"); - assert_eq!(TransactionError::Rollback("r").into_inner(), "r"); - assert_eq!(TransactionError::Operation("o").into_inner(), "o"); - } - - #[test] - fn transaction_error_source() { - let inner = std::io::Error::other("source_err"); - let err: TransactionError = TransactionError::Begin(inner); - assert!(err.source().is_some()); - - let commit_err: TransactionError = - TransactionError::Commit(std::io::Error::other("c")); - assert!(commit_err.source().is_some()); - - let rollback_err: TransactionError = - TransactionError::Rollback(std::io::Error::other("r")); - assert!(rollback_err.source().is_some()); - - let op_err: TransactionError = - TransactionError::Operation(std::io::Error::other("o")); - assert!(op_err.source().is_some()); - } - - #[test] - fn transaction_builder_new() { - struct MockPool; - let pool = MockPool; - let tx: Transaction<'_, MockPool, ()> = Transaction::new(&pool); - let _ = tx.pool(); - } - - #[test] - fn transaction_builder_with_repo() { - struct MockPool; - let pool = MockPool; - let tx: Transaction<'_, MockPool, ()> = Transaction::new(&pool); - let tx2: Transaction<'_, MockPool, i32> = tx.with_repo(); - let _ = tx2.pool(); - } - - #[test] - fn transaction_context_new() { - let tx = "mock_tx"; - let repos = (1, 2, 3); - let ctx = TransactionContext::new(tx, repos); - assert_eq!(*ctx.repos(), (1, 2, 3)); - } - - #[test] - fn transaction_context_transaction() { - let tx = String::from("mock_tx"); - let repos = (); - let mut ctx = TransactionContext::new(tx, repos); - assert_eq!(ctx.transaction(), "mock_tx"); - } - - #[test] - fn transaction_context_repos_mut() { - let tx = "mock_tx"; - let repos = vec![1, 2, 3]; - let mut ctx = TransactionContext::new(tx, repos); - ctx.repos_mut().push(4); - assert_eq!(*ctx.repos(), vec![1, 2, 3, 4]); + let err: TransactionError<&str> = TransactionError::Operation("test"); + assert_eq!(err.into_inner(), "test"); } } diff --git a/crates/entity-derive-impl/src/entity/transaction.rs b/crates/entity-derive-impl/src/entity/transaction.rs index ea92ad8..c4a9250 100644 --- a/crates/entity-derive-impl/src/entity/transaction.rs +++ b/crates/entity-derive-impl/src/entity/transaction.rs @@ -11,7 +11,7 @@ //! For an entity `User` with `#[entity(transactions)]`: //! //! - `UserTransactionRepo<'t>` — Repository adapter for transaction context -//! - `with_users()` — Builder method on `Transaction<..., ()>` +//! - `with_users()` — Builder method on `Transaction` (fluent, chainable) //! - `users()` — Accessor method on `TransactionContext` //! //! # Example @@ -45,10 +45,12 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let repo_adapter = generate_repo_adapter(entity); let builder_ext = generate_builder_extension(entity); + let context_ext = generate_context_extension(entity); quote! { #repo_adapter #builder_ext + #context_ext } } @@ -153,7 +155,7 @@ fn generate_repo_adapter(entity: &EntityDef) -> TokenStream { /// Transaction repository adapter for #entity_name. /// /// Provides repository operations that execute within an active transaction. - /// Created via `Transaction::new(&pool).with_{entities}()`. + /// Access via `ctx.{entities}()` within a transaction closure. #vis struct #repo_name<'t> { tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>, } @@ -208,29 +210,92 @@ fn generate_repo_adapter(entity: &EntityDef) -> TokenStream { /// Generate the builder extension trait. /// -/// Creates an extension trait that adds `with_{entity}()` method to -/// `Transaction`. +/// Creates an extension trait that adds `with_{entities}()` method to +/// `Transaction`. This method is chainable and returns self. fn generate_builder_extension(entity: &EntityDef) -> TokenStream { let vis = &entity.vis; let entity_name = entity.name(); let entity_snake = entity.name_str().to_case(Case::Snake); - let method_name = format_ident!("with_{}", entity_snake); + // Pluralize: add 's' for simple pluralization + let plural = pluralize(&entity_snake); + let method_name = format_ident!("with_{}", plural); let trait_name = format_ident!("TransactionWith{}", entity_name); - let repo_name = format_ident!("{}TransactionRepo", entity_name); let marker = marker::generated(); quote! { #marker - /// Extension trait to add #entity_name to a transaction. + /// Extension trait to add #entity_name to a transaction builder. + /// + /// This is a fluent API method - it returns self for chaining. + /// The actual repository is accessed via `ctx.{entities}()` in the closure. #vis trait #trait_name<'p> { /// Add #entity_name repository to the transaction. - fn #method_name(self) -> entity_core::transaction::Transaction<'p, sqlx::PgPool, #repo_name<'static>>; + /// + /// Returns self for chaining with other `with_*` calls. + fn #method_name(self) -> Self; + } + + impl<'p> #trait_name<'p> for entity_core::transaction::Transaction<'p, sqlx::PgPool> { + fn #method_name(self) -> Self { + self + } + } + } +} + +/// Generate the context extension trait. +/// +/// Creates an extension trait that adds accessor method to +/// `TransactionContext`. +fn generate_context_extension(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_snake = entity.name_str().to_case(Case::Snake); + let plural = pluralize(&entity_snake); + let accessor_name = format_ident!("{}", plural); + let trait_name = format_ident!("{}ContextExt", entity_name); + let repo_name = format_ident!("{}TransactionRepo", entity_name); + let marker = marker::generated(); + + quote! { + #marker + /// Extension trait providing #entity_name access in transaction context. + #vis trait #trait_name { + /// Get repository adapter for #entity_name operations. + fn #accessor_name(&mut self) -> #repo_name<'_>; } - impl<'p> #trait_name<'p> for entity_core::transaction::Transaction<'p, sqlx::PgPool, ()> { - fn #method_name(self) -> entity_core::transaction::Transaction<'p, sqlx::PgPool, #repo_name<'static>> { - self.with_repo() + impl #trait_name for entity_core::transaction::TransactionContext { + fn #accessor_name(&mut self) -> #repo_name<'_> { + #repo_name::new(self.transaction()) } } } } + +/// Simple pluralization - adds 's' to the end. +/// +/// Handles some common cases: +/// - Words ending in 's', 'x', 'z', 'ch', 'sh' -> add 'es' +/// - Words ending in consonant + 'y' -> replace 'y' with 'ies' +/// - Otherwise -> add 's' +fn pluralize(word: &str) -> String { + if word.ends_with('s') + || word.ends_with('x') + || word.ends_with('z') + || word.ends_with("ch") + || word.ends_with("sh") + { + format!("{}es", word) + } else if let Some(without_y) = word.strip_suffix('y') { + // Check if the letter before 'y' is a consonant + if let Some(c) = without_y.chars().last() + && !"aeiou".contains(c) + { + return format!("{}ies", without_y); + } + format!("{}s", word) + } else { + format!("{}s", word) + } +} diff --git a/examples/transactions/src/main.rs b/examples/transactions/src/main.rs index 81bb97c..517cdc2 100644 --- a/examples/transactions/src/main.rs +++ b/examples/transactions/src/main.rs @@ -184,9 +184,7 @@ async fn transfer( async fn list_accounts( State(state): State, ) -> Result { - let accounts = state - .pool - .list(100, 0) + let accounts = BankAccountRepository::list(&*state.pool, 100, 0) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -200,9 +198,7 @@ async fn get_account( State(state): State, Path(id): Path, ) -> Result { - let account = state - .pool - .find_by_id(id) + let account = BankAccountRepository::find_by_id(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; @@ -215,9 +211,7 @@ async fn create_account( State(state): State, Json(dto): Json, ) -> Result { - let account = state - .pool - .create(dto) + let account = BankAccountRepository::create(&*state.pool, dto) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -228,9 +222,7 @@ async fn create_account( async fn list_transfers( State(state): State, ) -> Result { - let logs: Vec = state - .pool - .list(100, 0) + let logs = TransferLogRepository::list(&*state.pool, 100, 0) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; From fed89ac898c9750a25c8f440e0072c1e34222e53 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:38:01 +0700 Subject: [PATCH 06/30] fix: remove unused import in soft-delete example (#71) --- examples/soft-delete/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/soft-delete/src/main.rs b/examples/soft-delete/src/main.rs index a8b2cd3..09068fb 100644 --- a/examples/soft-delete/src/main.rs +++ b/examples/soft-delete/src/main.rs @@ -15,7 +15,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::{delete, get, post, patch}, + routing::{delete, get, post}, }; use chrono::{DateTime, Utc}; use entity_derive::Entity; From b6a509b654b905b3158d0ef3bc45cb5b2edbd4cd Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:45:10 +0700 Subject: [PATCH 07/30] feat: implement stream_filtered for database streaming (#72) - Added stream_filtered method to repository trait - Added Filter type alias (same as Query struct) - Implemented stream_filtered for PostgreSQL - Fixed streams example with correct field names - Added Serialize/Deserialize to AuditLog entity - Added events feature for subscriber support --- crates/entity-derive-impl/src/entity/query.rs | 5 ++ .../src/entity/repository.rs | 31 ++++++++ .../src/entity/sql/postgres.rs | 2 + .../src/entity/sql/postgres/query.rs | 71 +++++++++++++++++++ examples/streams/Cargo.toml | 3 +- examples/streams/src/main.rs | 22 +++--- 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/query.rs b/crates/entity-derive-impl/src/entity/query.rs index 84692dd..7d28048 100644 --- a/crates/entity-derive-impl/src/entity/query.rs +++ b/crates/entity-derive-impl/src/entity/query.rs @@ -86,6 +86,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let marker = marker::generated(); + let filter_name = entity.ident_with("", "Filter"); + quote! { #marker #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -97,5 +99,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { /// Number of results to skip. pub offset: Option, } + + /// Type alias for filter operations (same as Query). + #vis type #filter_name = #query_name; } } diff --git a/crates/entity-derive-impl/src/entity/repository.rs b/crates/entity-derive-impl/src/entity/repository.rs index fd20a9d..d6cc027 100644 --- a/crates/entity-derive-impl/src/entity/repository.rs +++ b/crates/entity-derive-impl/src/entity/repository.rs @@ -86,6 +86,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let projection_methods = generate_projection_methods(entity, id_type); let soft_delete_methods = generate_soft_delete_methods(entity, id_type); let query_method = generate_query_method(entity); + let stream_method = generate_stream_method(entity); let marker = marker::generated(); quote! { @@ -121,6 +122,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #query_method + #stream_method + #relation_methods #projection_methods @@ -275,3 +278,31 @@ fn generate_query_method(entity: &EntityDef) -> TokenStream { async fn query(&self, query: #query_type) -> Result, Self::Error>; } } + +/// Generate stream method when entity has streams feature and filters. +/// +/// Generates: +/// ```rust,ignore +/// async fn stream_filtered( +/// &self, +/// filter: UserFilter, +/// ) -> Result>, Self::Error>; +/// ``` +pub fn generate_stream_method(entity: &EntityDef) -> TokenStream { + if !entity.has_streams() || !entity.has_filters() { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let filter_type = entity.ident_with("", "Filter"); + + quote! { + /// Stream entities with type-safe filters. + /// + /// Returns an async stream for memory-efficient processing of large result sets. + async fn stream_filtered( + &self, + filter: #filter_type, + ) -> Result> + Send + '_>>, Self::Error>; + } +} diff --git a/crates/entity-derive-impl/src/entity/sql/postgres.rs b/crates/entity-derive-impl/src/entity/sql/postgres.rs index e79c3b0..919acbb 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres.rs @@ -101,6 +101,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let delete_impl = ctx.delete_method(); let list_impl = ctx.list_method(); let query_impl = ctx.query_method(); + let stream_impl = ctx.stream_filtered_method(); let relation_impls = ctx.relation_methods(); let projection_impls = ctx.projection_methods(); let soft_delete_impls = ctx.soft_delete_methods(); @@ -124,6 +125,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #delete_impl #list_impl #query_impl + #stream_impl #relation_impls #projection_impls #soft_delete_impls diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/query.rs b/crates/entity-derive-impl/src/entity/sql/postgres/query.rs index 3eb5cb4..209b985 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres/query.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres/query.rs @@ -121,4 +121,75 @@ impl Context<'_> { } } } + + /// Generate the `stream_filtered` method implementation. + /// + /// # Returns + /// + /// Empty `TokenStream` if entity has no streams or filter fields. + pub fn stream_filtered_method(&self) -> TokenStream { + if !self.streams || !self.entity.has_filters() { + return TokenStream::new(); + } + + let Self { + entity_name, + row_name, + table, + columns_str, + id_name, + soft_delete, + .. + } = self; + + let filter_type = self.entity.ident_with("", "Filter"); + let filter_fields = self.entity.filter_fields(); + + let where_conditions = generate_where_conditions(&filter_fields, *soft_delete); + let bindings = generate_query_bindings(&filter_fields); + + // For now, generate a simple implementation that fetches all and converts to + // stream True streaming would require more complex lifetime handling + quote! { + async fn stream_filtered( + &self, + filter: #filter_type, + ) -> Result> + Send + '_>>, Self::Error> { + use futures::StreamExt; + + let mut conditions: Vec = Vec::new(); + let mut param_idx: usize = 1; + // Rename filter to query for binding code compatibility + let query = filter; + + #where_conditions + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let limit_idx = param_idx; + param_idx += 1; + let offset_idx = param_idx; + + let sql = format!( + "SELECT {} FROM {} {} ORDER BY {} DESC LIMIT ${} OFFSET ${}", + #columns_str, #table, where_clause, stringify!(#id_name), limit_idx, offset_idx + ); + + let mut q = sqlx::query_as::<_, #row_name>(&sql); + #bindings + q = q.bind(query.limit.unwrap_or(10000)).bind(query.offset.unwrap_or(0)); + + // Fetch all results and convert to stream for simpler lifetime handling + let rows = q.fetch_all(self).await?; + let entities: Vec<#entity_name> = rows.into_iter().map(#entity_name::from).collect(); + let stream = futures::stream::iter(entities.into_iter().map(Ok)); + + Ok(Box::pin(stream)) + } + } + } } diff --git a/examples/streams/Cargo.toml b/examples/streams/Cargo.toml index ba196f2..3b44716 100644 --- a/examples/streams/Cargo.toml +++ b/examples/streams/Cargo.toml @@ -15,7 +15,8 @@ api = [] validate = [] [dependencies] -entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api", "streams"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres", "streams"] } axum = "0.8" tokio = { version = "1", features = ["full", "sync"] } tokio-stream = "0.1" diff --git a/examples/streams/src/main.rs b/examples/streams/src/main.rs index 60f08b0..e52b43d 100644 --- a/examples/streams/src/main.rs +++ b/examples/streams/src/main.rs @@ -29,8 +29,8 @@ use uuid::Uuid; // ============================================================================ /// Audit log entity with streaming support. -#[derive(Debug, Clone, Entity)] -#[entity(table = "audit_logs", streams)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Entity)] +#[entity(table = "audit_logs", streams, events)] pub struct AuditLog { #[id] pub id: Uuid, @@ -109,8 +109,10 @@ async fn aggregate_logs( let filter = AuditLogFilter { action: query.action, resource_type: query.resource_type, - created_at_min: None, - created_at_max: None, + created_at_from: None, + created_at_to: None, + limit: None, + offset: None, }; let mut stream = state @@ -157,8 +159,10 @@ async fn list_logs_streamed( let filter = AuditLogFilter { action: query.action, resource_type: query.resource_type, - created_at_min: None, - created_at_max: None, + created_at_from: None, + created_at_to: None, + limit: None, + offset: None, }; let mut stream = state @@ -209,8 +213,10 @@ async fn export_by_action( let filter = AuditLogFilter { action: Some(action.clone()), resource_type: None, - created_at_min: None, - created_at_max: None, + created_at_from: None, + created_at_to: None, + limit: None, + offset: None, }; let mut stream = state From e2197ec316b8953bd62623b93e71cf591e22be4d Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 09:51:22 +0700 Subject: [PATCH 08/30] fix: update events example for new event variants (#67) --- examples/events/src/main.rs | 48 +++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/examples/events/src/main.rs b/examples/events/src/main.rs index 69744f5..c9a94ae 100644 --- a/examples/events/src/main.rs +++ b/examples/events/src/main.rs @@ -13,7 +13,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::{get, post, patch, delete}, + routing::{get, post}, }; use chrono::{DateTime, Utc}; use entity_derive::Entity; @@ -28,7 +28,7 @@ use uuid::Uuid; // ============================================================================ /// Order entity with lifecycle events. -#[derive(Debug, Clone, Entity)] +#[derive(Debug, Clone, PartialEq, Entity)] #[entity(table = "orders", events)] pub struct Order { #[id] @@ -85,23 +85,23 @@ fn handle_event(event: &OrderEvent) -> String { order.id, order.customer_name, order.product ) } - OrderEvent::Updated { id, changes } => { + OrderEvent::Updated { old, new } => { let mut changed = Vec::new(); - if changes.customer_name.is_some() { + if old.customer_name != new.customer_name { changed.push("customer_name"); } - if changes.product.is_some() { + if old.product != new.product { changed.push("product"); } - if changes.quantity.is_some() { + if old.quantity != new.quantity { changed.push("quantity"); } - if changes.status.is_some() { + if old.status != new.status { changed.push("status"); } - format!("[AUDIT] Order updated: id={}, changed={:?}", id, changed) + format!("[AUDIT] Order updated: id={}, changed={:?}", new.id, changed) } - OrderEvent::Deleted(id) => { + OrderEvent::HardDeleted { id } => { format!("[AUDIT] Order deleted: id={}", id) } } @@ -149,37 +149,39 @@ async fn update_order( Path(id): Path, Json(dto): Json, ) -> Result { - // Emit event before update + // Fetch old order for event + let old = OrderRepository::find_by_id(&*state.pool, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let new = OrderRepository::update(&*state.pool, id, dto) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Emit event with old and new values let event = OrderEvent::Updated { - id, - changes: dto.clone(), + old: old.clone(), + new: new.clone(), }; let log = handle_event(&event); tracing::info!("{}", log); let _ = state.events.send(log); - let order = state - .pool - .update(id, dto) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(OrderResponse::from(order))) + Ok(Json(OrderResponse::from(new))) } async fn delete_order( State(state): State, Path(id): Path, ) -> Result { - let deleted = state - .pool - .delete(id) + let deleted = OrderRepository::delete(&*state.pool, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if deleted { // Emit event - let event = OrderEvent::Deleted(id); + let event = OrderEvent::HardDeleted { id }; let log = handle_event(&event); tracing::info!("{}", log); let _ = state.events.send(log); From 1145533dc8d53d49d889bd1ce36edff9b667b6a4 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 10:06:45 +0700 Subject: [PATCH 09/30] fix: gate Future import behind postgres feature --- crates/entity-core/src/transaction.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/entity-core/src/transaction.rs b/crates/entity-core/src/transaction.rs index 36d39a8..ed0ff59 100644 --- a/crates/entity-core/src/transaction.rs +++ b/crates/entity-core/src/transaction.rs @@ -37,7 +37,9 @@ //! } //! ``` -use std::{error::Error as StdError, fmt, future::Future}; +use std::{error::Error as StdError, fmt}; +#[cfg(feature = "postgres")] +use std::future::Future; /// Transaction builder for composing multi-entity operations. /// From 8c8a26293012b8859c3dac17dbcd94a48eb300e4 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 10:08:19 +0700 Subject: [PATCH 10/30] style: reorder imports per rustfmt --- crates/entity-core/src/transaction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/entity-core/src/transaction.rs b/crates/entity-core/src/transaction.rs index ed0ff59..279e979 100644 --- a/crates/entity-core/src/transaction.rs +++ b/crates/entity-core/src/transaction.rs @@ -37,9 +37,9 @@ //! } //! ``` -use std::{error::Error as StdError, fmt}; #[cfg(feature = "postgres")] use std::future::Future; +use std::{error::Error as StdError, fmt}; /// Transaction builder for composing multi-entity operations. /// From 5368bd193c352749fdde2a50fabbe72a3eaa06a5 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 10:48:20 +0700 Subject: [PATCH 11/30] #76 feat: add api attribute parsing for OpenAPI integration - Add ApiConfig struct with tag, security, path_prefix, version options - Parse api(...) nested attributes from #[entity(api(...))] - Support public commands list for bypassing authentication - Add has_api() and api_config() methods to EntityDef - Comprehensive test coverage for all parsing scenarios --- crates/entity-derive-impl/src/entity/parse.rs | 4 + .../src/entity/parse/api.rs | 346 ++++++++++++++++++ .../src/entity/parse/entity.rs | 180 ++++++++- .../src/entity/parse/entity/attrs.rs | 2 +- 4 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/parse/api.rs diff --git a/crates/entity-derive-impl/src/entity/parse.rs b/crates/entity-derive-impl/src/entity/parse.rs index f70d2d1..087be44 100644 --- a/crates/entity-derive-impl/src/entity/parse.rs +++ b/crates/entity-derive-impl/src/entity/parse.rs @@ -106,6 +106,7 @@ //! pub struct Product { /* ... */ } //! ``` +mod api; mod command; mod dialect; mod entity; @@ -114,6 +115,9 @@ mod returning; mod sql_level; mod uuid_version; +// Re-exported for handler generation (#77) +#[allow(unused_imports)] +pub use api::ApiConfig; pub use command::{CommandDef, CommandKindHint, CommandSource}; pub use dialect::DatabaseDialect; pub use entity::{EntityDef, ProjectionDef}; diff --git a/crates/entity-derive-impl/src/entity/parse/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs new file mode 100644 index 0000000..c0b258c --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api.rs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +#![allow(dead_code)] // Methods used by handler generation (#77) + +//! API configuration parsing for OpenAPI/utoipa integration. +//! +//! This module handles parsing of `#[entity(api(...))]` attributes for +//! automatic HTTP handler generation with OpenAPI documentation. +//! +//! # Syntax +//! +//! ```rust,ignore +//! #[entity(api( +//! tag = "Users", // OpenAPI tag name (required) +//! tag_description = "...", // Tag description (optional) +//! path_prefix = "/api/v1", // URL prefix (optional) +//! security = "bearer", // Default security scheme (optional) +//! public = [Register, Login], // Commands without auth (optional) +//! ))] +//! ``` +//! +//! # Generated Output +//! +//! When `api(...)` is present, the macro generates: +//! - Axum handlers with `#[utoipa::path]` annotations +//! - OpenAPI schemas via `#[derive(ToSchema)]` +//! - Router factory function +//! - OpenApi struct for Swagger UI + +use syn::Ident; + +/// API configuration parsed from `#[entity(api(...))]`. +/// +/// Controls HTTP handler generation and OpenAPI documentation. +#[derive(Debug, Clone, Default)] +pub struct ApiConfig { + /// OpenAPI tag name for grouping endpoints. + /// + /// Required when API generation is enabled. + /// Example: `"Users"`, `"Products"`, `"Orders"` + pub tag: Option, + + /// Description for the OpenAPI tag. + /// + /// Provides additional context in API documentation. + pub tag_description: Option, + + /// URL path prefix for all endpoints. + /// + /// Example: `"/api/v1"` results in `/api/v1/users` + pub path_prefix: Option, + + /// Default security scheme for endpoints. + /// + /// Supported values: + /// - `"bearer"` - JWT Bearer token + /// - `"api_key"` - API key in header + /// - `"none"` - No authentication + pub security: Option, + + /// Commands that don't require authentication. + /// + /// These endpoints bypass the default security scheme. + /// Example: `[Register, Login]` + pub public_commands: Vec, + + /// API version string. + /// + /// Added to path prefix: `/api/v1` with version `"v1"` + pub version: Option, + + /// Version in which this API is deprecated. + /// + /// Marks all endpoints with `deprecated = true` in OpenAPI. + pub deprecated_in: Option +} + +impl ApiConfig { + /// Check if API generation is enabled. + /// + /// Returns `true` if the `api(...)` attribute is present. + pub fn is_enabled(&self) -> bool { + self.tag.is_some() + } + + /// Get the tag name or default to entity name. + /// + /// # Arguments + /// + /// * `entity_name` - Fallback entity name + pub fn tag_or_default(&self, entity_name: &str) -> String { + self.tag.clone().unwrap_or_else(|| entity_name.to_string()) + } + + /// Get the full path prefix including version. + /// + /// Combines `path_prefix` and `version` if both are set. + pub fn full_path_prefix(&self) -> String { + match (&self.path_prefix, &self.version) { + (Some(prefix), Some(version)) => { + format!("{}/{}", prefix.trim_end_matches('/'), version) + } + (Some(prefix), None) => prefix.clone(), + (None, Some(version)) => format!("/{}", version), + (None, None) => String::new() + } + } + + /// Check if a command is public (no auth required). + /// + /// # Arguments + /// + /// * `command_name` - Command name to check + pub fn is_public_command(&self, command_name: &str) -> bool { + self.public_commands.iter().any(|c| c == command_name) + } + + /// Check if API is marked as deprecated. + pub fn is_deprecated(&self) -> bool { + self.deprecated_in.is_some() + } + + /// Get security scheme for a command. + /// + /// Returns `None` for public commands, otherwise the default security. + /// + /// # Arguments + /// + /// * `command_name` - Command name to check + pub fn security_for_command(&self, command_name: &str) -> Option<&str> { + if self.is_public_command(command_name) { + None + } else { + self.security.as_deref() + } + } +} + +/// Parse `#[entity(api(...))]` attribute. +/// +/// Extracts API configuration from the nested attribute. +/// +/// # Arguments +/// +/// * `meta` - The meta content inside `api(...)` +/// +/// # Returns +/// +/// Parsed `ApiConfig` or error. +pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { + let mut config = ApiConfig::default(); + + let list = match meta { + syn::Meta::List(list) => list, + syn::Meta::Path(_) => { + return Err(syn::Error::new_spanned( + meta, + "api attribute requires parameters: api(tag = \"...\")" + )); + } + syn::Meta::NameValue(_) => { + return Err(syn::Error::new_spanned( + meta, + "api attribute must use parentheses: api(tag = \"...\")" + )); + } + }; + + list.parse_nested_meta(|nested| { + let ident = nested + .path + .get_ident() + .ok_or_else(|| syn::Error::new_spanned(&nested.path, "expected identifier"))?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "tag" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.tag = Some(value.value()); + } + "tag_description" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.tag_description = Some(value.value()); + } + "path_prefix" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.path_prefix = Some(value.value()); + } + "security" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.security = Some(value.value()); + } + "public" => { + let _: syn::Token![=] = nested.input.parse()?; + let content; + syn::bracketed!(content in nested.input); + let commands = + syn::punctuated::Punctuated::::parse_terminated( + &content + )?; + config.public_commands = commands.into_iter().collect(); + } + "version" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.version = Some(value.value()); + } + "deprecated_in" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.deprecated_in = Some(value.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown api option '{}', expected: tag, tag_description, path_prefix, \ + security, public, version, deprecated_in", + ident_str + ) + )); + } + } + + Ok(()) + })?; + + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_test_config(input: &str) -> ApiConfig { + let meta: syn::Meta = syn::parse_str(input).unwrap(); + parse_api_config(&meta).unwrap() + } + + #[test] + fn parse_tag_only() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert_eq!(config.tag, Some("Users".to_string())); + assert!(config.is_enabled()); + } + + #[test] + fn parse_full_config() { + let config = parse_test_config( + r#"api( + tag = "Users", + tag_description = "User management", + path_prefix = "/api/v1", + security = "bearer" + )"# + ); + assert_eq!(config.tag, Some("Users".to_string())); + assert_eq!(config.tag_description, Some("User management".to_string())); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + assert_eq!(config.security, Some("bearer".to_string())); + } + + #[test] + fn parse_public_commands() { + let config = parse_test_config(r#"api(tag = "Users", public = [Register, Login])"#); + assert_eq!(config.public_commands.len(), 2); + assert!(config.is_public_command("Register")); + assert!(config.is_public_command("Login")); + assert!(!config.is_public_command("Update")); + } + + #[test] + fn parse_version() { + let config = parse_test_config(r#"api(tag = "Users", version = "v2")"#); + assert_eq!(config.version, Some("v2".to_string())); + } + + #[test] + fn parse_deprecated() { + let config = parse_test_config(r#"api(tag = "Users", deprecated_in = "v2")"#); + assert!(config.is_deprecated()); + } + + #[test] + fn full_path_prefix_with_version() { + let config = ApiConfig { + path_prefix: Some("/api".to_string()), + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); + } + + #[test] + fn full_path_prefix_without_version() { + let config = ApiConfig { + path_prefix: Some("/api/v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); + } + + #[test] + fn full_path_prefix_version_only() { + let config = ApiConfig { + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/v1"); + } + + #[test] + fn security_for_public_command() { + let config = + parse_test_config(r#"api(tag = "Users", security = "bearer", public = [Register])"#); + assert_eq!(config.security_for_command("Update"), Some("bearer")); + assert_eq!(config.security_for_command("Register"), None); + } + + #[test] + fn tag_or_default_uses_tag() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert_eq!(config.tag_or_default("User"), "Users"); + } + + #[test] + fn tag_or_default_uses_entity_name() { + let config = ApiConfig::default(); + assert_eq!(config.tag_or_default("User"), "User"); + } + + #[test] + fn default_config_not_enabled() { + let config = ApiConfig::default(); + assert!(!config.is_enabled()); + } + + #[test] + fn parse_trailing_slash_in_prefix() { + let config = ApiConfig { + path_prefix: Some("/api/".to_string()), + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index 9b857a6..e619399 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -43,6 +43,7 @@ pub use projection::{ProjectionDef, parse_projection_attrs}; use syn::{Attribute, DeriveInput, Ident, Visibility}; use super::{ + api::{ApiConfig, parse_api_config}, command::{CommandDef, parse_command_attrs}, dialect::DatabaseDialect, field::FieldDef, @@ -83,6 +84,75 @@ fn parse_has_many_attrs(attrs: &[Attribute]) -> Vec { .collect() } +/// Parse `api(...)` from `#[entity(...)]` attribute. +/// +/// Searches for the `api` key within the entity attribute and parses +/// its nested configuration. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes from the struct +/// +/// # Returns +/// +/// `ApiConfig` with parsed values, or default if not present. +fn parse_api_attr(attrs: &[Attribute]) -> ApiConfig { + for attr in attrs { + if !attr.path().is_ident("entity") { + continue; + } + + // Parse the attribute content manually + let result: syn::Result> = + attr.parse_args_with(|input: syn::parse::ParseStream<'_>| { + while !input.is_empty() { + let ident: Ident = input.parse()?; + + if ident == "api" { + // Found api(...), parse the nested content + let content; + syn::parenthesized!(content in input); + + // Build a Meta::List from the content + let tokens = content.parse::()?; + let meta_list = syn::Meta::List(syn::MetaList { + path: syn::parse_quote!(api), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens + }); + + if let Ok(config) = parse_api_config(&meta_list) { + return Ok(Some(config)); + } + } else { + // Skip other attributes (table = "...", schema = "...", etc.) + if input.peek(syn::Token![=]) { + let _: syn::Token![=] = input.parse()?; + // Skip the value + let _ = input.parse::()?; + } else if input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in input); + let _ = content.parse::()?; + } + } + + // Skip comma if present + if input.peek(syn::Token![,]) { + let _: syn::Token![,] = input.parse()?; + } + } + Ok(None) + }); + + if let Ok(Some(config)) = result { + return config; + } + } + + ApiConfig::default() +} + /// Complete parsed entity definition. /// /// This is the main data structure passed to all code generators. @@ -210,7 +280,13 @@ pub struct EntityDef { /// /// When `true`, generates transaction repository adapter and builder /// methods. - pub transactions: bool + pub transactions: bool, + + /// API configuration for HTTP handler generation. + /// + /// When enabled via `#[entity(api(...))]`, generates axum handlers + /// with OpenAPI documentation via utoipa. + pub api_config: ApiConfig } impl EntityDef { @@ -276,6 +352,7 @@ impl EntityDef { let has_many = parse_has_many_attrs(&input.attrs); let projections = parse_projection_attrs(&input.attrs); let command_defs = parse_command_attrs(&input.attrs); + let api_config = parse_api_attr(&input.attrs); let id_field_index = fields.iter().position(|f| f.is_id()).ok_or_else(|| { darling::Error::custom("Entity must have exactly one field with #[id] attribute") @@ -303,7 +380,8 @@ impl EntityDef { command_defs, policy: attrs.policy, streams: attrs.streams, - transactions: attrs.transactions + transactions: attrs.transactions, + api_config }) } @@ -582,6 +660,30 @@ impl EntityDef { pub fn has_transactions(&self) -> bool { self.transactions } + + /// Check if API generation is enabled. + /// + /// # Returns + /// + /// `true` if `#[entity(api(...))]` is present with a tag. + /// + /// Used by handler generation (#77). + #[allow(dead_code)] + pub fn has_api(&self) -> bool { + self.api_config.is_enabled() + } + + /// Get API configuration. + /// + /// # Returns + /// + /// Reference to the API configuration. + /// + /// Used by handler generation (#77). + #[allow(dead_code)] + pub fn api_config(&self) -> &ApiConfig { + &self.api_config + } } #[cfg(test)] @@ -610,4 +712,78 @@ mod tests { let path_str = quote::quote!(#error_path).to_string(); assert!(path_str.contains("sqlx")); } + + #[test] + fn entity_def_without_api() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(!entity.has_api()); + } + + #[test] + fn entity_def_with_api() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(entity.has_api()); + assert_eq!(entity.api_config().tag, Some("Users".to_string())); + } + + #[test] + fn entity_def_with_full_api_config() { + let input: DeriveInput = syn::parse_quote! { + #[entity( + table = "users", + api( + tag = "Users", + tag_description = "User management", + path_prefix = "/api/v1", + security = "bearer" + ) + )] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(entity.has_api()); + let config = entity.api_config(); + assert_eq!(config.tag, Some("Users".to_string())); + assert_eq!(config.tag_description, Some("User management".to_string())); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + assert_eq!(config.security, Some("bearer".to_string())); + } + + #[test] + fn entity_def_api_with_public_commands() { + let input: DeriveInput = syn::parse_quote! { + #[entity( + table = "users", + api(tag = "Users", security = "bearer", public = [Register, Login]) + )] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let config = entity.api_config(); + assert!(config.is_public_command("Register")); + assert!(config.is_public_command("Login")); + assert!(!config.is_public_command("Update")); + assert_eq!(config.security_for_command("Register"), None); + assert_eq!(config.security_for_command("Update"), Some("bearer")); + } } diff --git a/crates/entity-derive-impl/src/entity/parse/entity/attrs.rs b/crates/entity-derive-impl/src/entity/parse/entity/attrs.rs index 025b9e3..eb8b08f 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/attrs.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/attrs.rs @@ -65,7 +65,7 @@ pub fn default_error_type() -> syn::Path { /// )] /// ``` #[derive(Debug, FromDeriveInput)] -#[darling(attributes(entity), supports(struct_named))] +#[darling(attributes(entity), supports(struct_named), allow_unknown_fields)] pub struct EntityAttrs { /// Struct identifier (e.g., `User`). pub ident: Ident, From 718a2004ef9967b7f1c8a281944f8be6fd4f545c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:18:30 +0700 Subject: [PATCH 12/30] #77 feat: add HTTP handler generation with OpenAPI documentation (#88) - Add api/ module with handlers, router, and openapi submodules - Generate axum handlers with #[utoipa::path] annotations - Support security configuration (bearer, api_key) per endpoint - Mark public commands without authentication requirement - Generate router factory function for easy integration - Generate OpenApi struct for Swagger UI - HTTP method selection based on command kind (POST/PUT/DELETE) - Path parameters for commands requiring entity ID - 4 unit tests for HTTP method selection --- crates/entity-derive-impl/src/entity.rs | 3 + crates/entity-derive-impl/src/entity/api.rs | 93 +++++ .../src/entity/api/handlers.rs | 341 ++++++++++++++++++ .../src/entity/api/openapi.rs | 155 ++++++++ .../src/entity/api/router.rs | 130 +++++++ 5 files changed, 722 insertions(+) create mode 100644 crates/entity-derive-impl/src/entity/api.rs create mode 100644 crates/entity-derive-impl/src/entity/api/handlers.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi.rs create mode 100644 crates/entity-derive-impl/src/entity/api/router.rs diff --git a/crates/entity-derive-impl/src/entity.rs b/crates/entity-derive-impl/src/entity.rs index 8620897..e2974ba 100644 --- a/crates/entity-derive-impl/src/entity.rs +++ b/crates/entity-derive-impl/src/entity.rs @@ -61,6 +61,7 @@ //! | `impl From<...>` | Conversions between types | //! | `impl UserRepository for PgPool` | PostgreSQL implementation | +mod api; mod commands; mod dto; mod events; @@ -103,6 +104,7 @@ fn generate(entity: EntityDef) -> TokenStream { let policy = policy::generate(&entity); let streams = streams::generate(&entity); let transaction = transaction::generate(&entity); + let api = api::generate(&entity); let repository = repository::generate(&entity); let row = row::generate(&entity); let insertable = insertable::generate(&entity); @@ -119,6 +121,7 @@ fn generate(entity: EntityDef) -> TokenStream { #policy #streams #transaction + #api #repository #row #insertable diff --git a/crates/entity-derive-impl/src/entity/api.rs b/crates/entity-derive-impl/src/entity/api.rs new file mode 100644 index 0000000..91b3be0 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! HTTP API generation with OpenAPI documentation. +//! +//! This module generates axum handlers with utoipa annotations for entities +//! with `#[entity(api(...))]` enabled. +//! +//! # Architecture +//! +//! ```text +//! api/ +//! ├── mod.rs — Orchestrator (this file) +//! ├── handlers.rs — Axum handler functions with #[utoipa::path] +//! ├── router.rs — Router factory function +//! └── openapi.rs — OpenApi struct for Swagger UI +//! ``` +//! +//! # Generated Code +//! +//! For an entity like: +//! +//! ```rust,ignore +//! #[derive(Entity)] +//! #[entity( +//! table = "users", +//! commands, +//! api( +//! tag = "Users", +//! path_prefix = "/api/v1", +//! security = "bearer", +//! public = [Register] +//! ) +//! )] +//! #[command(Register)] +//! #[command(UpdateEmail: email)] +//! pub struct User { ... } +//! ``` +//! +//! The macro generates: +//! +//! | Type | Purpose | +//! |------|---------| +//! | `register_user` | Handler for POST /api/v1/users/register | +//! | `update_email_user` | Handler for PUT /api/v1/users/{id}/update-email | +//! | `user_router` | Router factory function | +//! | `UserApi` | OpenApi struct for Swagger UI | +//! +//! # Usage +//! +//! ```rust,ignore +//! // In your main.rs or router setup: +//! let app = Router::new() +//! .merge(user_router::()) +//! .layer(Extension(handler)); +//! +//! // For OpenAPI: +//! let openapi = UserApi::openapi(); +//! ``` + +mod handlers; +mod openapi; +mod router; + +use proc_macro2::TokenStream; +use quote::quote; + +use super::parse::EntityDef; + +/// Main entry point for API code generation. +/// +/// Returns empty `TokenStream` if `api(...)` is not configured +/// or no commands are defined. +pub fn generate(entity: &EntityDef) -> TokenStream { + if !entity.has_api() { + return TokenStream::new(); + } + + // API generation requires commands to be enabled + if !entity.has_commands() || entity.command_defs().is_empty() { + return TokenStream::new(); + } + + let handlers = handlers::generate(entity); + let router = router::generate(entity); + let openapi = openapi::generate(entity); + + quote! { + #handlers + #router + #openapi + } +} diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs new file mode 100644 index 0000000..e3cb10f --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Axum handler generation with utoipa annotations. +//! +//! Generates HTTP handlers for each command defined on the entity. +//! Each handler includes `#[utoipa::path]` annotations for OpenAPI +//! documentation. +//! +//! # Generated Handlers +//! +//! | Command Kind | HTTP Method | Path Pattern | +//! |--------------|-------------|--------------| +//! | Create (no id) | POST | `/{prefix}/{entity}` | +//! | Update (with id) | PUT | `/{prefix}/{entity}/{id}/{action}` | +//! | Delete (with id) | DELETE | `/{prefix}/{entity}/{id}` | +//! | Custom | POST | `/{prefix}/{entity}/{action}` | +//! +//! # Example +//! +//! For `#[command(Register)]` on `User`: +//! +//! ```rust,ignore +//! #[utoipa::path( +//! post, +//! path = "/api/v1/users/register", +//! tag = "Users", +//! request_body = RegisterUser, +//! responses( +//! (status = 200, body = User), +//! (status = 400, description = "Validation error"), +//! (status = 500, description = "Internal server error") +//! ) +//! )] +//! pub async fn register_user( +//! Extension(handler): Extension>, +//! Json(cmd): Json, +//! ) -> Result, ApiError> +//! where +//! H: UserCommandHandler, +//! { +//! let ctx = Default::default(); +//! let result = handler.handle_register(cmd, &ctx).await?; +//! Ok(Json(result)) +//! } +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::{CommandDef, CommandKindHint, EntityDef}; + +/// Generate all handler functions for the entity. +pub fn generate(entity: &EntityDef) -> TokenStream { + let commands = entity.command_defs(); + if commands.is_empty() { + return TokenStream::new(); + } + + let handlers: Vec = commands + .iter() + .map(|cmd| generate_handler(entity, cmd)) + .collect(); + + quote! { #(#handlers)* } +} + +/// Generate a single handler function. +fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + + // Handler function name: register_user, update_email_user + let handler_name = handler_function_name(entity, cmd); + let handler_method = cmd.handler_method_name(); + + // Command struct name: RegisterUser, UpdateEmailUser + let command_struct = cmd.struct_name(&entity_name_str); + + // Handler trait name: UserCommandHandler + let handler_trait = format_ident!("{}CommandHandler", entity_name); + + // Build the path for OpenAPI + let path = build_path(entity, cmd); + + // HTTP method based on command kind + let http_method = http_method_for_command(cmd); + let http_method_ident = format_ident!("{}", http_method); + + // Tag for OpenAPI grouping + let tag = api_config.tag_or_default(&entity_name_str); + + // Security configuration + let security_attr = if api_config.is_public_command(&cmd.name.to_string()) { + quote! {} + } else if let Some(security) = &api_config.security { + let security_name = match security.as_str() { + "bearer" => "bearer_auth", + "api_key" => "api_key", + _ => "bearer_auth" + }; + quote! { security(#security_name = []) } + } else { + quote! {} + }; + + // Determine response type + let (response_type, response_body) = response_type_for_command(entity, cmd); + + // Build utoipa path attribute + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + #http_method_ident, + path = #path, + tag = #tag, + request_body = #command_struct, + responses( + (status = 200, body = #response_body, description = "Success"), + (status = 400, description = "Validation error"), + (status = 500, description = "Internal server error") + ) + )] + } + } else { + quote! { + #[utoipa::path( + #http_method_ident, + path = #path, + tag = #tag, + request_body = #command_struct, + responses( + (status = 200, body = #response_body, description = "Success"), + (status = 400, description = "Validation error"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + #security_attr + )] + } + }; + + // Generate handler based on whether it requires ID + if cmd.requires_id { + generate_handler_with_id( + entity, + cmd, + &handler_name, + &handler_method, + &command_struct, + &handler_trait, + &response_type, + &utoipa_attr + ) + } else { + generate_handler_without_id( + entity, + cmd, + &handler_name, + &handler_method, + &command_struct, + &handler_trait, + &response_type, + &utoipa_attr + ) + } +} + +/// Generate handler for commands that don't require an ID (e.g., Register). +#[allow(clippy::too_many_arguments)] +fn generate_handler_without_id( + entity: &EntityDef, + cmd: &CommandDef, + handler_name: &syn::Ident, + handler_method: &syn::Ident, + command_struct: &syn::Ident, + handler_trait: &syn::Ident, + response_type: &TokenStream, + utoipa_attr: &TokenStream +) -> TokenStream { + let vis = &entity.vis; + let doc = format!( + "HTTP handler for {} command.\n\n\ + Generated by entity-derive.", + cmd.name + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::Extension(handler): axum::extract::Extension>, + axum::extract::Json(cmd): axum::extract::Json<#command_struct>, + ) -> Result, axum::http::StatusCode> + where + H: #handler_trait + 'static, + H::Context: Default, + { + let ctx = H::Context::default(); + match handler.#handler_method(cmd, &ctx).await { + Ok(result) => Ok(axum::response::Json(result)), + Err(_) => Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR), + } + } + } +} + +/// Generate handler for commands that require an ID (e.g., UpdateEmail). +#[allow(clippy::too_many_arguments)] +fn generate_handler_with_id( + entity: &EntityDef, + cmd: &CommandDef, + handler_name: &syn::Ident, + handler_method: &syn::Ident, + command_struct: &syn::Ident, + handler_trait: &syn::Ident, + response_type: &TokenStream, + utoipa_attr: &TokenStream +) -> TokenStream { + let vis = &entity.vis; + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let doc = format!( + "HTTP handler for {} command.\n\n\ + Generated by entity-derive.", + cmd.name + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::Extension(handler): axum::extract::Extension>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + axum::extract::Json(mut cmd): axum::extract::Json<#command_struct>, + ) -> Result, axum::http::StatusCode> + where + H: #handler_trait + 'static, + H::Context: Default, + { + cmd.id = id; + let ctx = H::Context::default(); + match handler.#handler_method(cmd, &ctx).await { + Ok(result) => Ok(axum::response::Json(result)), + Err(_) => Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR), + } + } + } +} + +/// Get the handler function name. +/// +/// Example: `register_user`, `update_email_user` +fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { + let entity_snake = entity.name_str().to_case(Case::Snake); + let cmd_snake = cmd.name.to_string().to_case(Case::Snake); + format_ident!("{}_{}", cmd_snake, entity_snake) +} + +/// Build the URL path for a command. +fn build_path(entity: &EntityDef, cmd: &CommandDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + let cmd_path = cmd.name.to_string().to_case(Case::Kebab); + + if cmd.requires_id { + format!("{}/{}/{{id}}/{}", prefix, entity_path, cmd_path) + } else { + format!("{}/{}/{}", prefix, entity_path, cmd_path) + } +} + +/// Get HTTP method for a command based on its kind. +fn http_method_for_command(cmd: &CommandDef) -> &'static str { + match cmd.kind { + CommandKindHint::Create => "post", + CommandKindHint::Update => "put", + CommandKindHint::Delete => "delete", + CommandKindHint::Custom => "post" + } +} + +/// Get the response type for a command. +fn response_type_for_command(entity: &EntityDef, cmd: &CommandDef) -> (TokenStream, TokenStream) { + let entity_name = entity.name(); + + if let Some(ref result_type) = cmd.result_type { + (quote! { #result_type }, quote! { #result_type }) + } else { + match cmd.kind { + CommandKindHint::Delete => (quote! { () }, quote! { () }), + _ => (quote! { #entity_name }, quote! { #entity_name }) + } + } +} + +#[cfg(test)] +mod tests { + use proc_macro2::Span; + use syn::Ident; + + use super::*; + use crate::entity::parse::{CommandDef, CommandSource}; + + fn create_test_command(name: &str, requires_id: bool, kind: CommandKindHint) -> CommandDef { + CommandDef { + name: Ident::new(name, Span::call_site()), + source: CommandSource::Create, + requires_id, + result_type: None, + kind + } + } + + #[test] + fn http_method_create() { + let cmd = create_test_command("Register", false, CommandKindHint::Create); + assert_eq!(http_method_for_command(&cmd), "post"); + } + + #[test] + fn http_method_update() { + let cmd = create_test_command("Update", true, CommandKindHint::Update); + assert_eq!(http_method_for_command(&cmd), "put"); + } + + #[test] + fn http_method_delete() { + let cmd = create_test_command("Delete", true, CommandKindHint::Delete); + assert_eq!(http_method_for_command(&cmd), "delete"); + } + + #[test] + fn http_method_custom() { + let cmd = create_test_command("Transfer", false, CommandKindHint::Custom); + assert_eq!(http_method_for_command(&cmd), "post"); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs new file mode 100644 index 0000000..399b627 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI struct generation. +//! +//! Generates a struct that implements `utoipa::OpenApi` for Swagger UI +//! integration. +//! +//! # Generated Code +//! +//! For `User` entity: +//! +//! ```rust,ignore +//! /// OpenAPI documentation for User entity endpoints. +//! #[derive(utoipa::OpenApi)] +//! #[openapi( +//! paths(register_user, update_email_user), +//! components(schemas(User, RegisterUser, UpdateEmailUser)), +//! tags((name = "Users", description = "User management")) +//! )] +//! pub struct UserApi; +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::{CommandDef, EntityDef}; + +/// Generate the OpenAPI struct. +pub fn generate(entity: &EntityDef) -> TokenStream { + let commands = entity.command_defs(); + if commands.is_empty() { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + + // OpenApi struct name: UserApi + let api_struct = format_ident!("{}Api", entity_name); + + // Tag for OpenAPI grouping + let tag = api_config.tag_or_default(&entity.name_str()); + let tag_description = api_config + .tag_description + .clone() + .unwrap_or_else(|| format!("{} management", entity_name)); + + // Handler function names for paths + let handler_names = generate_handler_names(entity, commands); + + // Schema types (entity + all command structs) + let schema_types = generate_schema_types(entity, commands); + + // Security schemes + let security_schemes = generate_security_schemes(api_config.security.as_deref()); + + let doc = format!( + "OpenAPI documentation for {} entity endpoints.\n\n\ + # Usage\n\n\ + ```rust,ignore\n\ + use utoipa::OpenApi;\n\ + let openapi = {}::openapi();\n\ + ```", + entity_name, api_struct + ); + + if security_schemes.is_empty() { + quote! { + #[doc = #doc] + #[derive(utoipa::OpenApi)] + #[openapi( + paths(#handler_names), + components(schemas(#schema_types)), + tags((name = #tag, description = #tag_description)) + )] + #vis struct #api_struct; + } + } else { + quote! { + #[doc = #doc] + #[derive(utoipa::OpenApi)] + #[openapi( + paths(#handler_names), + components( + schemas(#schema_types), + #security_schemes + ), + tags((name = #tag, description = #tag_description)) + )] + #vis struct #api_struct; + } + } +} + +/// Generate comma-separated handler function names. +fn generate_handler_names(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { + let names: Vec = commands + .iter() + .map(|cmd| handler_function_name(entity, cmd)) + .collect(); + + quote! { #(#names),* } +} + +/// Generate comma-separated schema types. +fn generate_schema_types(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + + let command_structs: Vec = commands + .iter() + .map(|cmd| cmd.struct_name(&entity_name_str)) + .collect(); + + quote! { #entity_name, #(#command_structs),* } +} + +/// Generate security schemes if configured. +fn generate_security_schemes(security: Option<&str>) -> TokenStream { + match security { + Some("bearer") => { + quote! { + security_schemes( + ("bearer_auth" = ( + ty = Http, + scheme = "bearer", + bearer_format = "JWT" + )) + ) + } + } + Some("api_key") => { + quote! { + security_schemes( + ("api_key" = ( + ty = ApiKey, + in = "header", + name = "X-API-Key" + )) + ) + } + } + _ => TokenStream::new() + } +} + +/// Get the handler function name. +fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { + let entity_snake = entity.name_str().to_case(Case::Snake); + let cmd_snake = cmd.name.to_string().to_case(Case::Snake); + format_ident!("{}_{}", cmd_snake, entity_snake) +} diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs new file mode 100644 index 0000000..05b4eb2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Router factory generation. +//! +//! Generates a function that creates an axum Router with all entity endpoints. +//! +//! # Generated Code +//! +//! For `User` entity with Register and UpdateEmail commands: +//! +//! ```rust,ignore +//! /// Create router for User entity endpoints. +//! pub fn user_router() -> axum::Router +//! where +//! H: UserCommandHandler + 'static, +//! H::Context: Default, +//! { +//! axum::Router::new() +//! .route("/api/v1/users/register", axum::routing::post(register_user::)) +//! .route("/api/v1/users/:id/update-email", axum::routing::put(update_email_user::)) +//! } +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::{CommandDef, CommandKindHint, EntityDef}; + +/// Generate the router factory function. +pub fn generate(entity: &EntityDef) -> TokenStream { + let commands = entity.command_defs(); + if commands.is_empty() { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let entity_snake = entity_name_str.to_case(Case::Snake); + + // Router function name: user_router + let router_fn = format_ident!("{}_router", entity_snake); + + // Handler trait name: UserCommandHandler + let handler_trait = format_ident!("{}CommandHandler", entity_name); + + // Generate route definitions + let routes = generate_routes(entity, commands); + + let doc = format!( + "Create axum router for {} entity endpoints.\n\n\ + # Usage\n\n\ + ```rust,ignore\n\ + let handler = Arc::new(MyHandler::new());\n\ + let app = Router::new()\n\ + .merge({}::())\n\ + .layer(Extension(handler));\n\ + ```", + entity_name, router_fn + ); + + quote! { + #[doc = #doc] + #vis fn #router_fn() -> axum::Router + where + H: #handler_trait + 'static, + H::Context: Default, + { + axum::Router::new() + #routes + } + } +} + +/// Generate all route definitions. +fn generate_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { + let routes: Vec = commands + .iter() + .map(|cmd| generate_route(entity, cmd)) + .collect(); + + quote! { #(#routes)* } +} + +/// Generate a single route definition. +fn generate_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { + let path = build_axum_path(entity, cmd); + let handler_name = handler_function_name(entity, cmd); + let method = axum_method_for_command(cmd); + + quote! { + .route(#path, axum::routing::#method(#handler_name::)) + } +} + +/// Build the axum-style path (uses :id instead of {id}). +fn build_axum_path(entity: &EntityDef, cmd: &CommandDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + let cmd_path = cmd.name.to_string().to_case(Case::Kebab); + + let path = if cmd.requires_id { + format!("{}/{}s/:id/{}", prefix, entity_path, cmd_path) + } else { + format!("{}/{}s/{}", prefix, entity_path, cmd_path) + }; + + // Normalize double slashes that can appear when prefix is empty + path.replace("//", "/") +} + +/// Get the handler function name. +fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { + let entity_snake = entity.name_str().to_case(Case::Snake); + let cmd_snake = cmd.name.to_string().to_case(Case::Snake); + format_ident!("{}_{}", cmd_snake, entity_snake) +} + +/// Get the axum routing method for a command. +fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { + match cmd.kind { + CommandKindHint::Create => format_ident!("post"), + CommandKindHint::Update => format_ident!("put"), + CommandKindHint::Delete => format_ident!("delete"), + CommandKindHint::Custom => format_ident!("post") + } +} From 5eda2c3812ff831d4fdb44199af2356bb6f6241a Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 10:59:56 +0700 Subject: [PATCH 13/30] #78 feat: extract doc comments for OpenAPI descriptions - Add utils/docs.rs module for doc comment extraction - Extract /// comments from entity struct for tag descriptions - Extract /// comments from fields for schema descriptions - Use entity doc as fallback for tag_description in OpenAPI - 6 unit tests for doc extraction functions --- .../src/entity/api/openapi.rs | 3 + .../src/entity/parse/entity.rs | 21 +- .../src/entity/parse/field.rs | 23 ++- crates/entity-derive-impl/src/utils.rs | 2 + crates/entity-derive-impl/src/utils/docs.rs | 191 ++++++++++++++++++ 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 crates/entity-derive-impl/src/utils/docs.rs diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index 399b627..9133cb4 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -43,9 +43,12 @@ pub fn generate(entity: &EntityDef) -> TokenStream { // Tag for OpenAPI grouping let tag = api_config.tag_or_default(&entity.name_str()); + + // Tag description: explicit > entity doc comment > default let tag_description = api_config .tag_description .clone() + .or_else(|| entity.doc().map(String::from)) .unwrap_or_else(|| format!("{} management", entity_name)); // Handler function names for paths diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index e619399..2b75813 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -51,6 +51,7 @@ use super::{ sql_level::SqlLevel, uuid_version::UuidVersion }; +use crate::utils::docs::extract_doc_comments; /// Parse `#[has_many(Entity)]` attributes from struct attributes. /// @@ -286,7 +287,12 @@ pub struct EntityDef { /// /// When enabled via `#[entity(api(...))]`, generates axum handlers /// with OpenAPI documentation via utoipa. - pub api_config: ApiConfig + pub api_config: ApiConfig, + + /// Documentation comment from the entity struct. + /// + /// Extracted from `///` comments for use in OpenAPI tag descriptions. + pub doc: Option } impl EntityDef { @@ -353,6 +359,7 @@ impl EntityDef { let projections = parse_projection_attrs(&input.attrs); let command_defs = parse_command_attrs(&input.attrs); let api_config = parse_api_attr(&input.attrs); + let doc = extract_doc_comments(&input.attrs); let id_field_index = fields.iter().position(|f| f.is_id()).ok_or_else(|| { darling::Error::custom("Entity must have exactly one field with #[id] attribute") @@ -381,7 +388,8 @@ impl EntityDef { policy: attrs.policy, streams: attrs.streams, transactions: attrs.transactions, - api_config + api_config, + doc }) } @@ -684,6 +692,15 @@ impl EntityDef { pub fn api_config(&self) -> &ApiConfig { &self.api_config } + + /// Get the documentation comment if present. + /// + /// Returns the extracted doc comment for use in OpenAPI descriptions. + #[must_use] + #[allow(dead_code)] + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } } #[cfg(test)] diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index b57f60a..46e057f 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -35,6 +35,8 @@ pub use filter::{FilterConfig, FilterType}; pub use storage::StorageConfig; use syn::{Attribute, Field, Ident, Type}; +use crate::utils::docs::extract_doc_comments; + /// Parse `#[belongs_to(EntityName)]` attribute. /// /// Extracts the entity identifier from the attribute. @@ -75,7 +77,13 @@ pub struct FieldDef { pub storage: StorageConfig, /// Query filter configuration. - pub filter: FilterConfig + pub filter: FilterConfig, + + /// Documentation comment from the field. + /// + /// Extracted from `///` comments for use in OpenAPI descriptions. + #[allow(dead_code)] // Will be used for schema field descriptions (#78) + pub doc: Option } impl FieldDef { @@ -92,6 +100,7 @@ impl FieldDef { darling::Error::custom("Entity fields must be named").with_span(field) })?; let ty = field.ty.clone(); + let doc = extract_doc_comments(&field.attrs); let mut expose = ExposeConfig::default(); let mut storage = StorageConfig::default(); @@ -116,7 +125,8 @@ impl FieldDef { ty, expose, storage, - filter + filter, + doc }) } @@ -210,4 +220,13 @@ impl FieldDef { pub fn filter(&self) -> &FilterConfig { &self.filter } + + /// Get the documentation comment if present. + /// + /// Returns the extracted doc comment for use in OpenAPI descriptions. + #[must_use] + #[allow(dead_code)] // Will be used for schema field descriptions (#78) + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } } diff --git a/crates/entity-derive-impl/src/utils.rs b/crates/entity-derive-impl/src/utils.rs index a436cad..da70035 100644 --- a/crates/entity-derive-impl/src/utils.rs +++ b/crates/entity-derive-impl/src/utils.rs @@ -7,8 +7,10 @@ //! //! # Submodules //! +//! - [`docs`] — Documentation extraction from attributes //! - [`fields`] — Field assignment generation for `From` implementations //! - [`marker`] — Generated code marker comments +pub mod docs; pub mod fields; pub mod marker; diff --git a/crates/entity-derive-impl/src/utils/docs.rs b/crates/entity-derive-impl/src/utils/docs.rs new file mode 100644 index 0000000..3d9f94a --- /dev/null +++ b/crates/entity-derive-impl/src/utils/docs.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Documentation extraction utilities. +//! +//! Extracts doc comments from Rust attributes for use in OpenAPI descriptions. +//! +//! # Doc Comment Format +//! +//! In Rust, doc comments (`///` and `/** */`) are stored as `#[doc = "..."]` +//! attributes. This module extracts and cleans those comments for use in +//! OpenAPI documentation. +//! +//! # Example +//! +//! ```rust,ignore +//! /// User account entity. +//! /// +//! /// Represents a registered user in the system. +//! #[derive(Entity)] +//! pub struct User { ... } +//! +//! // Extracts to: "User account entity.\n\nRepresents a registered user..." +//! ``` + +use syn::Attribute; + +/// Extract doc comments from attributes. +/// +/// Combines all `#[doc = "..."]` attributes into a single string, +/// trimming leading whitespace from each line. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes +/// +/// # Returns +/// +/// Combined doc string, or `None` if no doc comments present. +/// +/// # Example +/// +/// ```rust,ignore +/// let docs = extract_doc_comments(&field.attrs); +/// if let Some(description) = docs { +/// // Use description in OpenAPI +/// } +/// ``` +pub fn extract_doc_comments(attrs: &[Attribute]) -> Option { + let doc_lines: Vec = attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .filter_map(|attr| { + if let syn::Meta::NameValue(meta) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta.value + { + return Some(lit_str.value()); + } + None + }) + .collect(); + + if doc_lines.is_empty() { + return None; + } + + // Join lines and clean up + let combined = doc_lines + .iter() + .map(|line| line.trim()) + .collect::>() + .join("\n"); + + // Trim the result and return if non-empty + let trimmed = combined.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Extract the first line of doc comments (summary). +/// +/// Returns just the first non-empty line for use as a brief description. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes +/// +/// # Returns +/// +/// First doc line, or `None` if no doc comments present. +#[allow(dead_code)] // Will be used for endpoint summaries (#78) +pub fn extract_doc_summary(attrs: &[Attribute]) -> Option { + extract_doc_comments(attrs).and_then(|docs| { + docs.lines() + .find(|line| !line.trim().is_empty()) + .map(|s| s.trim().to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_attrs(input: &str) -> Vec { + let item: syn::ItemStruct = syn::parse_str(input).unwrap(); + item.attrs + } + + #[test] + fn extract_single_line_doc() { + let attrs = parse_attrs( + r#" + /// User entity. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("User entity.".to_string())); + } + + #[test] + fn extract_multi_line_doc() { + let attrs = parse_attrs( + r#" + /// First line. + /// Second line. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("First line.\nSecond line.".to_string())); + } + + #[test] + fn extract_doc_with_empty_lines() { + let attrs = parse_attrs( + r#" + /// Summary. + /// + /// Details here. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("Summary.\n\nDetails here.".to_string())); + } + + #[test] + fn extract_no_docs() { + let attrs = parse_attrs( + r#" + #[derive(Debug)] + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, None); + } + + #[test] + fn extract_summary_only() { + let attrs = parse_attrs( + r#" + /// First line summary. + /// More details. + struct Foo; + "# + ); + let summary = extract_doc_summary(&attrs); + assert_eq!(summary, Some("First line summary.".to_string())); + } + + #[test] + fn extract_summary_skips_empty_first_line() { + let attrs = parse_attrs( + r#" + /// + /// Actual summary. + struct Foo; + "# + ); + let summary = extract_doc_summary(&attrs); + assert_eq!(summary, Some("Actual summary.".to_string())); + } +} From 77035a5ed2562724d2d55791718967e06eed2cf5 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 10:59:56 +0700 Subject: [PATCH 14/30] #78 feat: extract doc comments for OpenAPI descriptions - Add utils/docs.rs module for doc comment extraction - Extract /// comments from entity struct for tag descriptions - Extract /// comments from fields for schema descriptions - Use entity doc as fallback for tag_description in OpenAPI - 6 unit tests for doc extraction functions --- .../src/entity/api/openapi.rs | 3 + .../src/entity/parse/entity.rs | 21 +- .../src/entity/parse/field.rs | 23 ++- crates/entity-derive-impl/src/utils.rs | 2 + crates/entity-derive-impl/src/utils/docs.rs | 191 ++++++++++++++++++ 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 crates/entity-derive-impl/src/utils/docs.rs diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index 399b627..9133cb4 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -43,9 +43,12 @@ pub fn generate(entity: &EntityDef) -> TokenStream { // Tag for OpenAPI grouping let tag = api_config.tag_or_default(&entity.name_str()); + + // Tag description: explicit > entity doc comment > default let tag_description = api_config .tag_description .clone() + .or_else(|| entity.doc().map(String::from)) .unwrap_or_else(|| format!("{} management", entity_name)); // Handler function names for paths diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index e619399..2b75813 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -51,6 +51,7 @@ use super::{ sql_level::SqlLevel, uuid_version::UuidVersion }; +use crate::utils::docs::extract_doc_comments; /// Parse `#[has_many(Entity)]` attributes from struct attributes. /// @@ -286,7 +287,12 @@ pub struct EntityDef { /// /// When enabled via `#[entity(api(...))]`, generates axum handlers /// with OpenAPI documentation via utoipa. - pub api_config: ApiConfig + pub api_config: ApiConfig, + + /// Documentation comment from the entity struct. + /// + /// Extracted from `///` comments for use in OpenAPI tag descriptions. + pub doc: Option } impl EntityDef { @@ -353,6 +359,7 @@ impl EntityDef { let projections = parse_projection_attrs(&input.attrs); let command_defs = parse_command_attrs(&input.attrs); let api_config = parse_api_attr(&input.attrs); + let doc = extract_doc_comments(&input.attrs); let id_field_index = fields.iter().position(|f| f.is_id()).ok_or_else(|| { darling::Error::custom("Entity must have exactly one field with #[id] attribute") @@ -381,7 +388,8 @@ impl EntityDef { policy: attrs.policy, streams: attrs.streams, transactions: attrs.transactions, - api_config + api_config, + doc }) } @@ -684,6 +692,15 @@ impl EntityDef { pub fn api_config(&self) -> &ApiConfig { &self.api_config } + + /// Get the documentation comment if present. + /// + /// Returns the extracted doc comment for use in OpenAPI descriptions. + #[must_use] + #[allow(dead_code)] + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } } #[cfg(test)] diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index b57f60a..46e057f 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -35,6 +35,8 @@ pub use filter::{FilterConfig, FilterType}; pub use storage::StorageConfig; use syn::{Attribute, Field, Ident, Type}; +use crate::utils::docs::extract_doc_comments; + /// Parse `#[belongs_to(EntityName)]` attribute. /// /// Extracts the entity identifier from the attribute. @@ -75,7 +77,13 @@ pub struct FieldDef { pub storage: StorageConfig, /// Query filter configuration. - pub filter: FilterConfig + pub filter: FilterConfig, + + /// Documentation comment from the field. + /// + /// Extracted from `///` comments for use in OpenAPI descriptions. + #[allow(dead_code)] // Will be used for schema field descriptions (#78) + pub doc: Option } impl FieldDef { @@ -92,6 +100,7 @@ impl FieldDef { darling::Error::custom("Entity fields must be named").with_span(field) })?; let ty = field.ty.clone(); + let doc = extract_doc_comments(&field.attrs); let mut expose = ExposeConfig::default(); let mut storage = StorageConfig::default(); @@ -116,7 +125,8 @@ impl FieldDef { ty, expose, storage, - filter + filter, + doc }) } @@ -210,4 +220,13 @@ impl FieldDef { pub fn filter(&self) -> &FilterConfig { &self.filter } + + /// Get the documentation comment if present. + /// + /// Returns the extracted doc comment for use in OpenAPI descriptions. + #[must_use] + #[allow(dead_code)] // Will be used for schema field descriptions (#78) + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } } diff --git a/crates/entity-derive-impl/src/utils.rs b/crates/entity-derive-impl/src/utils.rs index a436cad..da70035 100644 --- a/crates/entity-derive-impl/src/utils.rs +++ b/crates/entity-derive-impl/src/utils.rs @@ -7,8 +7,10 @@ //! //! # Submodules //! +//! - [`docs`] — Documentation extraction from attributes //! - [`fields`] — Field assignment generation for `From` implementations //! - [`marker`] — Generated code marker comments +pub mod docs; pub mod fields; pub mod marker; diff --git a/crates/entity-derive-impl/src/utils/docs.rs b/crates/entity-derive-impl/src/utils/docs.rs new file mode 100644 index 0000000..3d9f94a --- /dev/null +++ b/crates/entity-derive-impl/src/utils/docs.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Documentation extraction utilities. +//! +//! Extracts doc comments from Rust attributes for use in OpenAPI descriptions. +//! +//! # Doc Comment Format +//! +//! In Rust, doc comments (`///` and `/** */`) are stored as `#[doc = "..."]` +//! attributes. This module extracts and cleans those comments for use in +//! OpenAPI documentation. +//! +//! # Example +//! +//! ```rust,ignore +//! /// User account entity. +//! /// +//! /// Represents a registered user in the system. +//! #[derive(Entity)] +//! pub struct User { ... } +//! +//! // Extracts to: "User account entity.\n\nRepresents a registered user..." +//! ``` + +use syn::Attribute; + +/// Extract doc comments from attributes. +/// +/// Combines all `#[doc = "..."]` attributes into a single string, +/// trimming leading whitespace from each line. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes +/// +/// # Returns +/// +/// Combined doc string, or `None` if no doc comments present. +/// +/// # Example +/// +/// ```rust,ignore +/// let docs = extract_doc_comments(&field.attrs); +/// if let Some(description) = docs { +/// // Use description in OpenAPI +/// } +/// ``` +pub fn extract_doc_comments(attrs: &[Attribute]) -> Option { + let doc_lines: Vec = attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .filter_map(|attr| { + if let syn::Meta::NameValue(meta) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta.value + { + return Some(lit_str.value()); + } + None + }) + .collect(); + + if doc_lines.is_empty() { + return None; + } + + // Join lines and clean up + let combined = doc_lines + .iter() + .map(|line| line.trim()) + .collect::>() + .join("\n"); + + // Trim the result and return if non-empty + let trimmed = combined.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Extract the first line of doc comments (summary). +/// +/// Returns just the first non-empty line for use as a brief description. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes +/// +/// # Returns +/// +/// First doc line, or `None` if no doc comments present. +#[allow(dead_code)] // Will be used for endpoint summaries (#78) +pub fn extract_doc_summary(attrs: &[Attribute]) -> Option { + extract_doc_comments(attrs).and_then(|docs| { + docs.lines() + .find(|line| !line.trim().is_empty()) + .map(|s| s.trim().to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_attrs(input: &str) -> Vec { + let item: syn::ItemStruct = syn::parse_str(input).unwrap(); + item.attrs + } + + #[test] + fn extract_single_line_doc() { + let attrs = parse_attrs( + r#" + /// User entity. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("User entity.".to_string())); + } + + #[test] + fn extract_multi_line_doc() { + let attrs = parse_attrs( + r#" + /// First line. + /// Second line. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("First line.\nSecond line.".to_string())); + } + + #[test] + fn extract_doc_with_empty_lines() { + let attrs = parse_attrs( + r#" + /// Summary. + /// + /// Details here. + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, Some("Summary.\n\nDetails here.".to_string())); + } + + #[test] + fn extract_no_docs() { + let attrs = parse_attrs( + r#" + #[derive(Debug)] + struct Foo; + "# + ); + let docs = extract_doc_comments(&attrs); + assert_eq!(docs, None); + } + + #[test] + fn extract_summary_only() { + let attrs = parse_attrs( + r#" + /// First line summary. + /// More details. + struct Foo; + "# + ); + let summary = extract_doc_summary(&attrs); + assert_eq!(summary, Some("First line summary.".to_string())); + } + + #[test] + fn extract_summary_skips_empty_first_line() { + let attrs = parse_attrs( + r#" + /// + /// Actual summary. + struct Foo; + "# + ); + let summary = extract_doc_summary(&attrs); + assert_eq!(summary, Some("Actual summary.".to_string())); + } +} From 0db154af6a616da395f62a9323bbe3ce160c9d92 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:03:20 +0700 Subject: [PATCH 15/30] #79 feat: parse validation attributes for OpenAPI constraints - Add validation.rs module for #[validate(...)] parsing - Support length(min, max), range(min, max), email, url, regex - Generate OpenAPI schema constraints (minLength, maxLength, etc.) - Store raw attrs for passthrough to generated DTOs - 8 unit tests for validation parsing --- .../src/entity/parse/field.rs | 30 +- .../src/entity/parse/field/validation.rs | 325 ++++++++++++++++++ 2 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/parse/field/validation.rs diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 46e057f..43a70e5 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -29,11 +29,13 @@ mod expose; mod filter; mod storage; +mod validation; pub use expose::ExposeConfig; pub use filter::{FilterConfig, FilterType}; pub use storage::StorageConfig; use syn::{Attribute, Field, Ident, Type}; +pub use validation::ValidationConfig; use crate::utils::docs::extract_doc_comments; @@ -83,7 +85,13 @@ pub struct FieldDef { /// /// Extracted from `///` comments for use in OpenAPI descriptions. #[allow(dead_code)] // Will be used for schema field descriptions (#78) - pub doc: Option + pub doc: Option, + + /// Validation configuration from `#[validate(...)]` attributes. + /// + /// Parsed for OpenAPI schema constraints and DTO validation. + #[allow(dead_code)] // Will be used for OpenAPI schema constraints (#79) + pub validation: ValidationConfig } impl FieldDef { @@ -101,6 +109,7 @@ impl FieldDef { })?; let ty = field.ty.clone(); let doc = extract_doc_comments(&field.attrs); + let validation = validation::parse_validation_attrs(&field.attrs); let mut expose = ExposeConfig::default(); let mut storage = StorageConfig::default(); @@ -126,7 +135,8 @@ impl FieldDef { expose, storage, filter, - doc + doc, + validation }) } @@ -229,4 +239,20 @@ impl FieldDef { pub fn doc(&self) -> Option<&str> { self.doc.as_deref() } + + /// Get the validation configuration. + /// + /// Returns the parsed validation rules for OpenAPI constraints. + #[must_use] + #[allow(dead_code)] // Will be used for OpenAPI schema constraints (#79) + pub fn validation(&self) -> &ValidationConfig { + &self.validation + } + + /// Check if this field has validation rules. + #[must_use] + #[allow(dead_code)] // Will be used for OpenAPI schema constraints (#79) + pub fn has_validation(&self) -> bool { + self.validation.has_validation() + } } diff --git a/crates/entity-derive-impl/src/entity/parse/field/validation.rs b/crates/entity-derive-impl/src/entity/parse/field/validation.rs new file mode 100644 index 0000000..fd5acd1 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/field/validation.rs @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Validation attribute parsing. +//! +//! Extracts `#[validate(...)]` attributes from fields for: +//! - Passing through to generated DTOs +//! - Converting to OpenAPI schema constraints +//! +//! # Supported Validators +//! +//! | Validator | OpenAPI Constraint | +//! |-----------|-------------------| +//! | `length(min = N)` | `minLength: N` | +//! | `length(max = N)` | `maxLength: N` | +//! | `range(min = N)` | `minimum: N` | +//! | `range(max = N)` | `maximum: N` | +//! | `email` | `format: email` | +//! | `url` | `format: uri` | +//! | `regex = "..."` | `pattern: ...` | +//! +//! # Example +//! +//! ```rust,ignore +//! #[validate(length(min = 1, max = 255))] +//! #[validate(email)] +//! pub email: String, +//! +//! // Generates in OpenAPI schema: +//! // email: +//! // type: string +//! // minLength: 1 +//! // maxLength: 255 +//! // format: email +//! ``` + +use proc_macro2::TokenStream; +use quote::quote; +use syn::Attribute; + +/// Parsed validation configuration from `#[validate(...)]` attributes. +#[derive(Debug, Clone, Default)] +pub struct ValidationConfig { + /// Minimum string length. + pub min_length: Option, + + /// Maximum string length. + pub max_length: Option, + + /// Minimum numeric value. + pub minimum: Option, + + /// Maximum numeric value. + pub maximum: Option, + + /// Email format validation. + pub email: bool, + + /// URL format validation. + pub url: bool, + + /// Regex pattern. + pub pattern: Option, + + /// Raw validate attributes to pass through. + pub raw_attrs: Vec +} + +impl ValidationConfig { + /// Check if any validation is configured. + #[must_use] + #[allow(dead_code)] // Will be used when generating schema constraints + pub fn has_validation(&self) -> bool { + self.min_length.is_some() + || self.max_length.is_some() + || self.minimum.is_some() + || self.maximum.is_some() + || self.email + || self.url + || self.pattern.is_some() + } + + /// Generate OpenAPI schema attributes for utoipa. + /// + /// Returns TokenStream with schema constraints like `min_length = N`. + #[must_use] + #[allow(dead_code)] // Will be used when generating schema constraints + pub fn to_schema_attrs(&self) -> TokenStream { + let mut attrs = Vec::new(); + + if let Some(min) = self.min_length { + attrs.push(quote! { min_length = #min }); + } + if let Some(max) = self.max_length { + attrs.push(quote! { max_length = #max }); + } + if let Some(min) = self.minimum { + attrs.push(quote! { minimum = #min }); + } + if let Some(max) = self.maximum { + attrs.push(quote! { maximum = #max }); + } + if self.email { + attrs.push(quote! { format = "email" }); + } + if self.url { + attrs.push(quote! { format = "uri" }); + } + if let Some(ref pattern) = self.pattern { + attrs.push(quote! { pattern = #pattern }); + } + + if attrs.is_empty() { + TokenStream::new() + } else { + quote! { #(, #attrs)* } + } + } +} + +/// Parse validation attributes from a field. +/// +/// Extracts all `#[validate(...)]` attributes and parses their content. +pub fn parse_validation_attrs(attrs: &[Attribute]) -> ValidationConfig { + let mut config = ValidationConfig::default(); + + for attr in attrs { + if !attr.path().is_ident("validate") { + continue; + } + + // Store raw attribute for passthrough + config.raw_attrs.push(quote! { #attr }); + + // Parse the attribute content + let _ = attr.parse_nested_meta(|meta| { + let path_str = meta.path.get_ident().map(|i| i.to_string()); + + match path_str.as_deref() { + Some("length") => { + meta.parse_nested_meta(|nested| { + let nested_path = nested.path.get_ident().map(|i| i.to_string()); + match nested_path.as_deref() { + Some("min") => { + let value: syn::LitInt = nested.value()?.parse()?; + config.min_length = Some(value.base10_parse()?); + } + Some("max") => { + let value: syn::LitInt = nested.value()?.parse()?; + config.max_length = Some(value.base10_parse()?); + } + _ => {} + } + Ok(()) + })?; + } + Some("range") => { + meta.parse_nested_meta(|nested| { + let nested_path = nested.path.get_ident().map(|i| i.to_string()); + match nested_path.as_deref() { + Some("min") => { + let value: syn::LitInt = nested.value()?.parse()?; + config.minimum = Some(value.base10_parse()?); + } + Some("max") => { + let value: syn::LitInt = nested.value()?.parse()?; + config.maximum = Some(value.base10_parse()?); + } + _ => {} + } + Ok(()) + })?; + } + Some("email") => { + config.email = true; + } + Some("url") => { + config.url = true; + } + Some("regex") => { + let value: syn::LitStr = meta.value()?.parse()?; + config.pattern = Some(value.value()); + } + _ => {} + } + + Ok(()) + }); + } + + config +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_attrs(input: &str) -> Vec { + let item: syn::ItemStruct = syn::parse_str(input).unwrap(); + item.fields + .iter() + .next() + .map(|f| f.attrs.clone()) + .unwrap_or_default() + } + + #[test] + fn parse_length_min_max() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(length(min = 1, max = 255))] + name: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert_eq!(config.min_length, Some(1)); + assert_eq!(config.max_length, Some(255)); + } + + #[test] + fn parse_email() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(email)] + email: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert!(config.email); + } + + #[test] + fn parse_url() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(url)] + website: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert!(config.url); + } + + #[test] + fn parse_range() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(range(min = 0, max = 100))] + score: i32, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert_eq!(config.minimum, Some(0)); + assert_eq!(config.maximum, Some(100)); + } + + #[test] + fn parse_multiple_validators() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(length(min = 5))] + #[validate(email)] + email: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert_eq!(config.min_length, Some(5)); + assert!(config.email); + } + + #[test] + fn no_validation() { + let attrs = parse_attrs( + r#" + struct Foo { + #[field(create)] + name: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert!(!config.has_validation()); + } + + #[test] + fn has_validation_true() { + let attrs = parse_attrs( + r#" + struct Foo { + #[validate(email)] + email: String, + } + "# + ); + let config = parse_validation_attrs(&attrs); + assert!(config.has_validation()); + } + + #[test] + fn schema_attrs_generation() { + let config = ValidationConfig { + min_length: Some(1), + max_length: Some(100), + email: true, + ..Default::default() + }; + + let attrs = config.to_schema_attrs(); + let attrs_str = attrs.to_string(); + + assert!(attrs_str.contains("min_length")); + assert!(attrs_str.contains("max_length")); + assert!(attrs_str.contains("email")); + } +} From 79caaaa7592370229727931a57fc06c079a60be9 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:32:19 +0700 Subject: [PATCH 16/30] #80 feat: add example attribute parsing for OpenAPI schemas - Add example.rs module for parsing #[example = ...] attributes - Support string, integer, float, and boolean literals - Support negative numbers - Add ExampleValue enum with to_tokens() and to_schema_attr() - Add example field to FieldDef with accessor methods - Include 8 unit tests for parsing Closes #80 --- crates/entity-derive-impl/src/entity/parse.rs | 2 + .../src/entity/parse/field.rs | 30 ++- .../src/entity/parse/field/example.rs | 247 ++++++++++++++++++ 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/parse/field/example.rs diff --git a/crates/entity-derive-impl/src/entity/parse.rs b/crates/entity-derive-impl/src/entity/parse.rs index 087be44..5489b00 100644 --- a/crates/entity-derive-impl/src/entity/parse.rs +++ b/crates/entity-derive-impl/src/entity/parse.rs @@ -121,6 +121,8 @@ pub use api::ApiConfig; pub use command::{CommandDef, CommandKindHint, CommandSource}; pub use dialect::DatabaseDialect; pub use entity::{EntityDef, ProjectionDef}; +#[allow(unused_imports)] // Will be used for OpenAPI schema examples (#80) +pub use field::ExampleValue; pub use field::{FieldDef, FilterType}; pub use returning::ReturningMode; pub use sql_level::SqlLevel; diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 43a70e5..4902165 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -26,11 +26,13 @@ //! pub user_id: Uuid, //! ``` +mod example; mod expose; mod filter; mod storage; mod validation; +pub use example::ExampleValue; pub use expose::ExposeConfig; pub use filter::{FilterConfig, FilterType}; pub use storage::StorageConfig; @@ -91,7 +93,13 @@ pub struct FieldDef { /// /// Parsed for OpenAPI schema constraints and DTO validation. #[allow(dead_code)] // Will be used for OpenAPI schema constraints (#79) - pub validation: ValidationConfig + pub validation: ValidationConfig, + + /// Example value for OpenAPI schema. + /// + /// Parsed from `#[example = ...]` attribute. + #[allow(dead_code)] // Will be used for OpenAPI schema examples (#80) + pub example: Option } impl FieldDef { @@ -110,6 +118,7 @@ impl FieldDef { let ty = field.ty.clone(); let doc = extract_doc_comments(&field.attrs); let validation = validation::parse_validation_attrs(&field.attrs); + let example = example::parse_example_attr(&field.attrs); let mut expose = ExposeConfig::default(); let mut storage = StorageConfig::default(); @@ -136,7 +145,8 @@ impl FieldDef { storage, filter, doc, - validation + validation, + example }) } @@ -255,4 +265,20 @@ impl FieldDef { pub fn has_validation(&self) -> bool { self.validation.has_validation() } + + /// Get the example value if present. + /// + /// Returns the parsed example for use in OpenAPI schema. + #[must_use] + #[allow(dead_code)] // Will be used for OpenAPI schema examples (#80) + pub fn example(&self) -> Option<&ExampleValue> { + self.example.as_ref() + } + + /// Check if this field has an example value. + #[must_use] + #[allow(dead_code)] // Will be used for OpenAPI schema examples (#80) + pub fn has_example(&self) -> bool { + self.example.is_some() + } } diff --git a/crates/entity-derive-impl/src/entity/parse/field/example.rs b/crates/entity-derive-impl/src/entity/parse/field/example.rs new file mode 100644 index 0000000..5b72a06 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/field/example.rs @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Example attribute parsing for OpenAPI schemas. +//! +//! Extracts `#[example = ...]` attributes from fields for use in +//! OpenAPI schema documentation. +//! +//! # Supported Types +//! +//! | Type | Syntax | OpenAPI | +//! |------|--------|---------| +//! | String | `#[example = "text"]` | `example: "text"` | +//! | Integer | `#[example = 42]` | `example: 42` | +//! | Float | `#[example = 3.14]` | `example: 3.14` | +//! | Boolean | `#[example = true]` | `example: true` | +//! +//! # Example +//! +//! ```rust,ignore +//! #[field(create, response)] +//! #[example = "user@example.com"] +//! pub email: String, +//! +//! #[field(response)] +//! #[example = 25] +//! pub age: i32, +//! ``` + +use proc_macro2::TokenStream; +use quote::quote; +use syn::Attribute; + +/// Example value for OpenAPI schema. +#[derive(Debug, Clone)] +#[allow(dead_code)] // Will be used for OpenAPI schema examples (#80) +pub enum ExampleValue { + /// String example: `#[example = "text"]`. + String(String), + + /// Integer example: `#[example = 42]`. + Int(i64), + + /// Float example: `#[example = 3.14]`. + Float(f64), + + /// Boolean example: `#[example = true]`. + Bool(bool) +} + +#[allow(dead_code)] // Will be used for OpenAPI schema examples (#80) +impl ExampleValue { + /// Convert to TokenStream for code generation. + #[must_use] + pub fn to_tokens(&self) -> TokenStream { + match self { + Self::String(s) => quote! { #s }, + Self::Int(i) => quote! { #i }, + Self::Float(f) => quote! { #f }, + Self::Bool(b) => quote! { #b } + } + } + + /// Convert to utoipa schema attribute format. + /// + /// Returns `example = ` for use in `#[schema(...)]`. + #[must_use] + pub fn to_schema_attr(&self) -> TokenStream { + let value = self.to_tokens(); + quote! { example = #value } + } +} + +/// Parse `#[example = ...]` attribute from field attributes. +/// +/// Returns `Some(ExampleValue)` if the attribute is present and valid. +pub fn parse_example_attr(attrs: &[Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("example") { + continue; + } + + // Parse as name-value: #[example = value] + if let syn::Meta::NameValue(meta) = &attr.meta { + return parse_example_expr(&meta.value); + } + } + + None +} + +/// Parse the expression part of the example attribute. +fn parse_example_expr(expr: &syn::Expr) -> Option { + match expr { + syn::Expr::Lit(lit_expr) => parse_example_lit(&lit_expr.lit), + // Handle negative numbers: -42 + syn::Expr::Unary(unary) if matches!(unary.op, syn::UnOp::Neg(_)) => { + if let syn::Expr::Lit(lit_expr) = &*unary.expr { + match &lit_expr.lit { + syn::Lit::Int(lit) => { + let value: i64 = lit.base10_parse().ok()?; + Some(ExampleValue::Int(-value)) + } + syn::Lit::Float(lit) => { + let value: f64 = lit.base10_parse().ok()?; + Some(ExampleValue::Float(-value)) + } + _ => None + } + } else { + None + } + } + _ => None + } +} + +/// Parse a literal value into an ExampleValue. +fn parse_example_lit(lit: &syn::Lit) -> Option { + match lit { + syn::Lit::Str(s) => Some(ExampleValue::String(s.value())), + syn::Lit::Int(i) => { + let value: i64 = i.base10_parse().ok()?; + Some(ExampleValue::Int(value)) + } + syn::Lit::Float(f) => { + let value: f64 = f.base10_parse().ok()?; + Some(ExampleValue::Float(value)) + } + syn::Lit::Bool(b) => Some(ExampleValue::Bool(b.value())), + _ => None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_attrs(input: &str) -> Vec { + let item: syn::ItemStruct = syn::parse_str(input).unwrap(); + item.fields + .iter() + .next() + .map(|f| f.attrs.clone()) + .unwrap_or_default() + } + + #[test] + fn parse_string_example() { + let attrs = parse_attrs( + r#" + struct Foo { + #[example = "user@example.com"] + email: String, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::String(s)) if s == "user@example.com")); + } + + #[test] + fn parse_int_example() { + let attrs = parse_attrs( + r#" + struct Foo { + #[example = 42] + age: i32, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::Int(42)))); + } + + #[test] + fn parse_negative_int_example() { + let attrs = parse_attrs( + r#" + struct Foo { + #[example = -10] + temperature: i32, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::Int(-10)))); + } + + #[test] + fn parse_float_example() { + let attrs = parse_attrs( + r#" + struct Foo { + #[example = 3.14] + pi: f64, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::Float(f)) if (f - 3.14).abs() < 0.001)); + } + + #[test] + fn parse_bool_example() { + let attrs = parse_attrs( + r#" + struct Foo { + #[example = true] + active: bool, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::Bool(true)))); + } + + #[test] + fn no_example_attr() { + let attrs = parse_attrs( + r#" + struct Foo { + #[field(create)] + name: String, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(example.is_none()); + } + + #[test] + fn to_schema_attr_string() { + let example = ExampleValue::String("test".to_string()); + let tokens = example.to_schema_attr().to_string(); + assert!(tokens.contains("example")); + assert!(tokens.contains("test")); + } + + #[test] + fn to_schema_attr_int() { + let example = ExampleValue::Int(42); + let tokens = example.to_schema_attr().to_string(); + assert!(tokens.contains("example")); + assert!(tokens.contains("42")); + } +} From b3e2ff070791d36e7effba8b88661c9dceba6880 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:35:17 +0700 Subject: [PATCH 17/30] #81 feat: add command-level security configuration - Add security field to CommandDef for per-command override - Parse security = "..." option in #[command(...)] attributes - Support "none" value to make specific commands public - Update handler generation to use command security with priority: 1. Command-level security override 2. Entity-level public commands list 3. Entity-level default security - Add security_scheme_name helper for mapping schemes - Add 3 unit tests for security parsing Closes #81 --- .../src/entity/api/handlers.rs | 33 ++++++-- .../src/entity/parse/command.rs | 78 ++++++++++++++++++- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs index e3cb10f..8b3df7a 100644 --- a/crates/entity-derive-impl/src/entity/api/handlers.rs +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -93,14 +93,21 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { let tag = api_config.tag_or_default(&entity_name_str); // Security configuration - let security_attr = if api_config.is_public_command(&cmd.name.to_string()) { + // Priority: command-level override > entity-level public list > entity-level + // default + let security_attr = if cmd.is_public() { + // Command explicitly marked as public + quote! {} + } else if let Some(cmd_security) = cmd.security() { + // Command has explicit security override + let security_name = security_scheme_name(cmd_security); + quote! { security(#security_name = []) } + } else if api_config.is_public_command(&cmd.name.to_string()) { + // Command is in entity-level public list quote! {} } else if let Some(security) = &api_config.security { - let security_name = match security.as_str() { - "bearer" => "bearer_auth", - "api_key" => "api_key", - _ => "bearer_auth" - }; + // Use entity-level default security + let security_name = security_scheme_name(security); quote! { security(#security_name = []) } } else { quote! {} @@ -283,6 +290,17 @@ fn http_method_for_command(cmd: &CommandDef) -> &'static str { } } +/// Map security scheme name to OpenAPI security scheme identifier. +fn security_scheme_name(scheme: &str) -> &'static str { + match scheme { + "bearer" => "bearer_auth", + "api_key" => "api_key", + "admin" => "admin_auth", + "oauth2" => "oauth2", + _ => "bearer_auth" + } +} + /// Get the response type for a command. fn response_type_for_command(entity: &EntityDef, cmd: &CommandDef) -> (TokenStream, TokenStream) { let entity_name = entity.name(); @@ -311,7 +329,8 @@ mod tests { source: CommandSource::Create, requires_id, result_type: None, - kind + kind, + security: None } } diff --git a/crates/entity-derive-impl/src/entity/parse/command.rs b/crates/entity-derive-impl/src/entity/parse/command.rs index 17216f6..1f044bc 100644 --- a/crates/entity-derive-impl/src/entity/parse/command.rs +++ b/crates/entity-derive-impl/src/entity/parse/command.rs @@ -123,7 +123,13 @@ pub struct CommandDef { pub result_type: Option, /// Kind hint for command categorization. - pub kind: CommandKindHint + pub kind: CommandKindHint, + + /// Security scheme override for this command. + /// + /// When set, overrides the entity-level default security. + /// Use `"none"` to make a command public. + pub security: Option } impl CommandDef { @@ -138,7 +144,8 @@ impl CommandDef { source: CommandSource::default(), requires_id: false, result_type: None, - kind: CommandKindHint::default() + kind: CommandKindHint::default(), + security: None } } @@ -169,6 +176,29 @@ impl CommandDef { let snake = self.name.to_string().to_case(Case::Snake); Ident::new(&format!("handle_{}", snake), Span::call_site()) } + + /// Check if this command has explicit security override. + #[must_use] + #[allow(dead_code)] // Used in tests and for API inspection + pub fn has_security_override(&self) -> bool { + self.security.is_some() + } + + /// Check if this command is explicitly marked as public. + /// + /// Returns `true` if `security = "none"` is set. + #[must_use] + pub fn is_public(&self) -> bool { + self.security.as_deref() == Some("none") + } + + /// Get the security scheme for this command. + /// + /// Returns command-level override if set, otherwise `None`. + #[must_use] + pub fn security(&self) -> Option<&str> { + self.security.as_deref() + } } /// Parse `#[command(...)]` attributes. @@ -294,12 +324,17 @@ fn parse_single_command(attr: &Attribute) -> syn::Result { } } } + "security" => { + let _: syn::Token![=] = input.parse()?; + let security_lit: syn::LitStr = input.parse()?; + cmd.security = Some(security_lit.value()); + } _ => { return Err(syn::Error::new( option_name.span(), format!( "unknown command option '{}', expected: requires_id, source, \ - payload, result, kind", + payload, result, kind, security", option_str ) )); @@ -555,4 +590,41 @@ mod tests { let cmds = parse_command_attrs(&input.attrs); assert!(cmds.is_empty()); } + + #[test] + fn parse_security_bearer() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(AdminDelete, requires_id, security = "admin")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].security(), Some("admin")); + assert!(!cmds[0].is_public()); + } + + #[test] + fn parse_security_none() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(PublicList, security = "none")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].is_public()); + assert!(cmds[0].has_security_override()); + } + + #[test] + fn default_no_security_override() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(!cmds[0].has_security_override()); + assert!(!cmds[0].is_public()); + assert_eq!(cmds[0].security(), None); + } } From 39cf5b72bb3921143f0fbed017cd6ecd0bc1dc75 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:37:20 +0700 Subject: [PATCH 18/30] #82 feat: add EntityError derive macro for OpenAPI error docs - Create error.rs module with EntityError derive macro - Parse #[status(code)] attributes for HTTP status codes - Use doc comments as error descriptions - Generate {Error}Responses struct with helper methods: - status_codes() - all error status codes - descriptions() - all error descriptions - utoipa_responses() - tuples for OpenAPI - Add 4 unit tests for error parsing Closes #82 --- crates/entity-derive-impl/src/error.rs | 216 +++++++++++++++++++++++++ crates/entity-derive-impl/src/lib.rs | 57 ++++++- 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 crates/entity-derive-impl/src/error.rs diff --git a/crates/entity-derive-impl/src/error.rs b/crates/entity-derive-impl/src/error.rs new file mode 100644 index 0000000..fcf8bbb --- /dev/null +++ b/crates/entity-derive-impl/src/error.rs @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! EntityError derive macro implementation. +//! +//! Generates OpenAPI error response documentation from enum variants. +//! +//! # Example +//! +//! ```rust,ignore +//! #[derive(Debug, Error, ToSchema, EntityError)] +//! pub enum UserError { +//! /// User with this email already exists +//! #[error("Email already exists")] +//! #[status(409)] +//! EmailExists, +//! +//! /// User not found by ID +//! #[error("User not found")] +//! #[status(404)] +//! NotFound, +//! } +//! ``` +//! +//! Generates `UserErrorResponses` that can be used in handlers. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Attribute, DeriveInput, parse_macro_input}; + +use crate::utils::docs::extract_doc_summary; + +/// Main entry point for the EntityError derive macro. +pub fn derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match generate(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into() + } +} + +/// Generate the error responses code. +fn generate(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + let vis = &input.vis; + + // Ensure it's an enum + let variants = match &input.data { + syn::Data::Enum(data) => &data.variants, + _ => { + return Err(syn::Error::new_spanned( + input, + "EntityError can only be derived for enums" + )); + } + }; + + // Parse error variants + let error_variants: Vec = variants + .iter() + .filter_map(|v| parse_error_variant(v).ok()) + .collect(); + + if error_variants.is_empty() { + return Ok(TokenStream2::new()); + } + + // Generate responses struct name + let responses_struct = format_ident!("{}Responses", name); + + // Generate status codes array + let status_codes: Vec = error_variants.iter().map(|v| v.status).collect(); + + // Generate descriptions array + let descriptions: Vec<&String> = error_variants.iter().map(|v| &v.description).collect(); + + let doc = format!( + "OpenAPI error responses for `{}`.\\n\\n\ + Use with `#[utoipa::path(responses(...))]`.", + name + ); + + Ok(quote! { + #[doc = #doc] + #vis struct #responses_struct; + + impl #responses_struct { + /// Get all error status codes. + #[must_use] + pub const fn status_codes() -> &'static [u16] { + &[#(#status_codes),*] + } + + /// Get all error descriptions. + #[must_use] + pub fn descriptions() -> &'static [&'static str] { + &[#(#descriptions),*] + } + + /// Generate utoipa response entries. + /// + /// Use in `#[utoipa::path(responses(...))]`. + #[must_use] + pub fn utoipa_responses() -> Vec<(u16, &'static str)> { + vec![ + #((#status_codes, #descriptions)),* + ] + } + } + }) +} + +/// Parsed error variant. +struct ErrorVariant { + /// HTTP status code from `#[status(code)]`. + status: u16, + /// Description from doc comment. + description: String +} + +/// Parse a single enum variant for error info. +fn parse_error_variant(variant: &syn::Variant) -> syn::Result { + let status = parse_status_attr(&variant.attrs)?; + let description = + extract_doc_summary(&variant.attrs).unwrap_or_else(|| format!("{} error", variant.ident)); + + Ok(ErrorVariant { + status, + description + }) +} + +/// Parse `#[status(code)]` attribute. +fn parse_status_attr(attrs: &[Attribute]) -> syn::Result { + for attr in attrs { + if attr.path().is_ident("status") { + let status: syn::LitInt = attr.parse_args()?; + return status.base10_parse(); + } + } + + Err(syn::Error::new( + proc_macro2::Span::call_site(), + "Missing #[status(code)] attribute" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_status_code() { + let input: DeriveInput = syn::parse_quote! { + enum UserError { + /// User not found + #[status(404)] + NotFound, + } + }; + + if let syn::Data::Enum(data) = &input.data { + let variant = &data.variants[0]; + let status = parse_status_attr(&variant.attrs).unwrap(); + assert_eq!(status, 404); + } + } + + #[test] + fn parse_error_variant_full() { + let input: DeriveInput = syn::parse_quote! { + enum UserError { + /// User with this email already exists + #[status(409)] + EmailExists, + } + }; + + if let syn::Data::Enum(data) = &input.data { + let variant = &data.variants[0]; + let parsed = parse_error_variant(variant).unwrap(); + assert_eq!(parsed.status, 409); + assert_eq!(parsed.description, "User with this email already exists"); + } + } + + #[test] + fn parse_missing_status_fails() { + let input: DeriveInput = syn::parse_quote! { + enum UserError { + /// Some error + NoStatus, + } + }; + + if let syn::Data::Enum(data) = &input.data { + let variant = &data.variants[0]; + let result = parse_error_variant(variant); + assert!(result.is_err()); + } + } + + #[test] + fn generate_for_non_enum_fails() { + let input: DeriveInput = syn::parse_quote! { + struct NotAnEnum { + field: String, + } + }; + + let result = generate(&input); + assert!(result.is_err()); + } +} diff --git a/crates/entity-derive-impl/src/lib.rs b/crates/entity-derive-impl/src/lib.rs index 387e9a9..083d65f 100644 --- a/crates/entity-derive-impl/src/lib.rs +++ b/crates/entity-derive-impl/src/lib.rs @@ -215,6 +215,7 @@ //! | Boilerplate reduction | ~90% | ~50% | ~60% | mod entity; +mod error; mod utils; use proc_macro::TokenStream; @@ -397,9 +398,63 @@ use proc_macro::TokenStream; #[proc_macro_derive( Entity, attributes( - entity, field, id, auto, validate, belongs_to, has_many, projection, filter, command + entity, field, id, auto, validate, belongs_to, has_many, projection, filter, command, + example ) )] pub fn derive_entity(input: TokenStream) -> TokenStream { entity::derive(input) } + +/// Derive macro for generating OpenAPI error response documentation. +/// +/// # Overview +/// +/// The `EntityError` derive macro generates OpenAPI response documentation +/// from error enum variants, using `#[status(code)]` attributes and doc +/// comments. +/// +/// # Example +/// +/// ```rust,ignore +/// use entity_derive::EntityError; +/// use thiserror::Error; +/// use utoipa::ToSchema; +/// +/// #[derive(Debug, Error, ToSchema, EntityError)] +/// pub enum UserError { +/// /// User with this email already exists +/// #[error("Email already exists")] +/// #[status(409)] +/// EmailExists, +/// +/// /// User not found by ID +/// #[error("User not found")] +/// #[status(404)] +/// NotFound, +/// +/// /// Invalid credentials provided +/// #[error("Invalid credentials")] +/// #[status(401)] +/// InvalidCredentials, +/// } +/// ``` +/// +/// # Generated Code +/// +/// For `UserError`, generates: +/// - `UserErrorResponses` struct with helper methods +/// - `status_codes()` - returns all error status codes +/// - `descriptions()` - returns all error descriptions +/// - `utoipa_responses()` - returns tuples for OpenAPI responses +/// +/// # Attributes +/// +/// | Attribute | Required | Description | +/// |-----------|----------|-------------| +/// | `#[status(code)]` | **Yes** | HTTP status code (e.g., 404, 409, 500) | +/// | `/// Doc comment` | No | Used as response description | +#[proc_macro_derive(EntityError, attributes(status))] +pub fn derive_entity_error(input: TokenStream) -> TokenStream { + error::derive(input) +} From bea427985bc4e732eb75016c42a41bd4e9ceb78d Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:38:48 +0700 Subject: [PATCH 19/30] #83 feat: add API versioning and deprecation support - Wire up deprecated_in config to generate deprecated = true in utoipa - Version is already supported via full_path_prefix() - Add 5 unit tests for security scheme name mapping Closes #83 --- .../src/entity/api/handlers.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs index 8b3df7a..c0e86bc 100644 --- a/crates/entity-derive-impl/src/entity/api/handlers.rs +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -116,6 +116,13 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { // Determine response type let (response_type, response_body) = response_type_for_command(entity, cmd); + // Deprecated flag from api config + let deprecated_attr = if api_config.is_deprecated() { + quote! { , deprecated = true } + } else { + quote! {} + }; + // Build utoipa path attribute let utoipa_attr = if security_attr.is_empty() { quote! { @@ -129,6 +136,7 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { (status = 400, description = "Validation error"), (status = 500, description = "Internal server error") ) + #deprecated_attr )] } } else { @@ -145,6 +153,7 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { (status = 500, description = "Internal server error") ), #security_attr + #deprecated_attr )] } }; @@ -357,4 +366,29 @@ mod tests { let cmd = create_test_command("Transfer", false, CommandKindHint::Custom); assert_eq!(http_method_for_command(&cmd), "post"); } + + #[test] + fn security_scheme_bearer() { + assert_eq!(security_scheme_name("bearer"), "bearer_auth"); + } + + #[test] + fn security_scheme_api_key() { + assert_eq!(security_scheme_name("api_key"), "api_key"); + } + + #[test] + fn security_scheme_admin() { + assert_eq!(security_scheme_name("admin"), "admin_auth"); + } + + #[test] + fn security_scheme_oauth2() { + assert_eq!(security_scheme_name("oauth2"), "oauth2"); + } + + #[test] + fn security_scheme_unknown_defaults_to_bearer() { + assert_eq!(security_scheme_name("unknown"), "bearer_auth"); + } } From af7e7f38da4aba6e2dd6352845c0a7f206e878dc Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 11:40:26 +0700 Subject: [PATCH 20/30] fix: use non-PI float value in example test --- crates/entity-derive-impl/src/entity/parse/field/example.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/parse/field/example.rs b/crates/entity-derive-impl/src/entity/parse/field/example.rs index 5b72a06..8f8afd5 100644 --- a/crates/entity-derive-impl/src/entity/parse/field/example.rs +++ b/crates/entity-derive-impl/src/entity/parse/field/example.rs @@ -192,13 +192,13 @@ mod tests { let attrs = parse_attrs( r#" struct Foo { - #[example = 3.14] - pi: f64, + #[example = 99.99] + price: f64, } "# ); let example = parse_example_attr(&attrs); - assert!(matches!(example, Some(ExampleValue::Float(f)) if (f - 3.14).abs() < 0.001)); + assert!(matches!(example, Some(ExampleValue::Float(f)) if (f - 99.99).abs() < 0.001)); } #[test] From 3cee183b85ae59851ac8b7627da9beeedeb963f5 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:32:33 +0700 Subject: [PATCH 21/30] #67 feat(api): add OpenAPI info config and selective handlers (#94) * feat(api): add CRUD handler generation Add `api(handlers)` option to generate REST handlers automatically: - create_{entity} - POST handler - get_{entity} - GET by ID handler - update_{entity} - PATCH handler - delete_{entity} - DELETE handler - list_{entity} - GET list handler Also adds separate routers: - {entity}_router() for CRUD (Repository trait) - {entity}_commands_router() for commands (CommandHandler trait) Part of #67: fix examples with generated handlers * #67 feat(api): add OpenAPI info config and selective handlers - Add OpenAPI info configuration (title, description, version, license, contact) - Add selective handlers via handlers(create, get, list) syntax - Generate ErrorResponse and PaginationQuery schemas in OpenAPI - Refactor path generation to only include enabled handlers - Update router and crud modules to respect HandlerConfig - Fix utoipa 5.x compatibility with Modify trait pattern * #67 fix(openapi): register only schemas for enabled handlers - OpenAPI now only includes CreateRequest if handlers.create is enabled - OpenAPI now only includes UpdateRequest if handlers.update is enabled - Response schema is always included (used by all read operations) - Added 3 tests for selective handlers schema registration * #67 fix(ci): show green status for non-dependabot PRs Changed workflow to run for all PRs but skip auto-merge steps for non-dependabot PRs. This shows green (success) instead of gray (skipped) status in the PR checks. --- .github/workflows/dependabot-automerge.yml | 16 +- README.md | 3 + crates/entity-derive-impl/src/entity/api.rs | 22 +- .../entity-derive-impl/src/entity/api/crud.rs | 708 ++++++++++++++ .../src/entity/api/openapi.rs | 922 ++++++++++++++++-- .../src/entity/api/router.rs | 244 ++++- .../src/entity/parse/api.rs | 234 ++++- examples/basic/Cargo.toml | 1 + examples/basic/src/main.rs | 271 ++--- 9 files changed, 2089 insertions(+), 332 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/api/crud.rs diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index a9841d7..bdaea4d 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -16,23 +16,33 @@ jobs: automerge: name: Auto-merge Dependabot PRs runs-on: ubuntu-latest - if: github.event.pull_request.user.login == 'dependabot[bot]' steps: + - name: Check if Dependabot PR + id: check + run: | + if [ "${{ github.event.pull_request.user.login }}" = "dependabot[bot]" ]; then + echo "is_dependabot=true" >> $GITHUB_OUTPUT + else + echo "is_dependabot=false" >> $GITHUB_OUTPUT + echo "Not a Dependabot PR, skipping auto-merge" + fi + - name: Fetch Dependabot metadata + if: steps.check.outputs.is_dependabot == 'true' id: metadata uses: dependabot/fetch-metadata@v2 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for minor/patch updates - if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' + if: steps.check.outputs.is_dependabot == 'true' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch') run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Approve patch updates - if: steps.metadata.outputs.update-type == 'version-update:semver-patch' + if: steps.check.outputs.is_dependabot == 'true' && steps.metadata.outputs.update-type == 'version-update:semver-patch' run: gh pr review --approve "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} diff --git a/README.md b/README.md index 94bd40c..4fbeb7e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ REUSE Compliant + + Wiki +

--- diff --git a/crates/entity-derive-impl/src/entity/api.rs b/crates/entity-derive-impl/src/entity/api.rs index 91b3be0..3fe4b4f 100644 --- a/crates/entity-derive-impl/src/entity/api.rs +++ b/crates/entity-derive-impl/src/entity/api.rs @@ -11,7 +11,8 @@ //! ```text //! api/ //! ├── mod.rs — Orchestrator (this file) -//! ├── handlers.rs — Axum handler functions with #[utoipa::path] +//! ├── crud.rs — CRUD handler functions (create, get, update, delete, list) +//! ├── handlers.rs — Command handler functions with #[utoipa::path] //! ├── router.rs — Router factory function //! └── openapi.rs — OpenApi struct for Swagger UI //! ``` @@ -58,6 +59,7 @@ //! let openapi = UserApi::openapi(); //! ``` +mod crud; mod handlers; mod openapi; mod router; @@ -69,24 +71,30 @@ use super::parse::EntityDef; /// Main entry point for API code generation. /// -/// Returns empty `TokenStream` if `api(...)` is not configured -/// or no commands are defined. +/// Returns empty `TokenStream` if `api(...)` is not configured. +/// Generates CRUD handlers if `handlers` is enabled, and command handlers +/// if commands are defined. pub fn generate(entity: &EntityDef) -> TokenStream { if !entity.has_api() { return TokenStream::new(); } - // API generation requires commands to be enabled - if !entity.has_commands() || entity.command_defs().is_empty() { + let has_crud = entity.api_config().has_handlers(); + let has_commands = entity.has_commands() && !entity.command_defs().is_empty(); + + // Need at least one type of handler to generate API + if !has_crud && !has_commands { return TokenStream::new(); } - let handlers = handlers::generate(entity); + let crud_handlers = crud::generate(entity); + let command_handlers = handlers::generate(entity); let router = router::generate(entity); let openapi = openapi::generate(entity); quote! { - #handlers + #crud_handlers + #command_handlers #router #openapi } diff --git a/crates/entity-derive-impl/src/entity/api/crud.rs b/crates/entity-derive-impl/src/entity/api/crud.rs new file mode 100644 index 0000000..08219e8 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud.rs @@ -0,0 +1,708 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! CRUD handler generation with utoipa annotations. +//! +//! Generates production-ready REST handlers with: +//! - OpenAPI documentation via `#[utoipa::path]` +//! - Cookie/Bearer authentication via `security` attribute +//! - Proper error responses using `masterror::ErrorResponse` +//! - Standard HTTP status codes and error handling +//! +//! # Generated Handlers +//! +//! | Operation | HTTP Method | Path | Status Codes | +//! |-----------|-------------|------|--------------| +//! | Create | POST | `/{entities}` | 201, 400, 401, 500 | +//! | Get | GET | `/{entities}/{id}` | 200, 401, 404, 500 | +//! | Update | PATCH | `/{entities}/{id}` | 200, 400, 401, 404, 500 | +//! | Delete | DELETE | `/{entities}/{id}` | 204, 401, 404, 500 | +//! | List | GET | `/{entities}` | 200, 401, 500 | +//! +//! # Security +//! +//! When `security = "cookie"` or `security = "bearer"` is specified, +//! handlers require authentication and use `Claims` extractor. +//! +//! # Example +//! +//! ```rust,ignore +//! #[derive(Entity)] +//! #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] +//! pub struct User { /* ... */ } +//! +//! // Generated handler with auth: +//! #[utoipa::path( +//! post, +//! path = "/users", +//! tag = "Users", +//! request_body(content = CreateUserRequest, description = "User data to create"), +//! responses( +//! (status = 201, description = "User created successfully", body = UserResponse), +//! (status = 400, description = "Invalid request data", body = ErrorResponse), +//! (status = 401, description = "Authentication required", body = ErrorResponse), +//! (status = 500, description = "Internal server error", body = ErrorResponse) +//! ), +//! security(("cookieAuth" = [])) +//! )] +//! pub async fn create_user( +//! _claims: Claims, +//! State(repo): State>, +//! Json(dto): Json, +//! ) -> AppResult<(StatusCode, Json)> +//! where +//! R: UserRepository + 'static, +//! { /* ... */ } +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::EntityDef; + +/// Generate CRUD handler functions based on enabled handlers. +pub fn generate(entity: &EntityDef) -> TokenStream { + if !entity.api_config().has_handlers() { + return TokenStream::new(); + } + + let handlers = entity.api_config().handlers(); + + let create = if handlers.create { + generate_create_handler(entity) + } else { + TokenStream::new() + }; + let get = if handlers.get { + generate_get_handler(entity) + } else { + TokenStream::new() + }; + let update = if handlers.update { + generate_update_handler(entity) + } else { + TokenStream::new() + }; + let delete = if handlers.delete { + generate_delete_handler(entity) + } else { + TokenStream::new() + }; + let list = if handlers.list { + generate_list_handler(entity) + } else { + TokenStream::new() + }; + + quote! { + #create + #get + #update + #delete + #list + } +} + +/// Generate the create handler. +fn generate_create_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("create_{}", entity_name_str.to_case(Case::Snake)); + let create_dto = entity.ident_with("Create", "Request"); + let response_dto = entity.ident_with("", "Response"); + + let path = build_collection_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let request_body_desc = format!("Data for creating a new {}", entity_name); + let success_desc = format!("{} created successfully", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body(content = #create_dto, description = #request_body_desc), + responses( + (status = 201, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 401, description = "Authentication required"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body(content = #create_dto, description = #request_body_desc), + responses( + (status = 201, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Create a new {}.\n\n\ + # Responses\n\n\ + - `201 Created` - {} created successfully\n\ + - `400 Bad Request` - Invalid request data\n\ + {}\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + } + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Json(dto): axum::extract::Json<#create_dto>, + ) -> masterror::AppResult<(axum::http::StatusCode, axum::response::Json<#response_dto>)> + where + R: #repo_trait + 'static, + { + let entity = repo + .create(dto) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + Ok((axum::http::StatusCode::CREATED, axum::response::Json(#response_dto::from(entity)))) + } + } +} + +/// Generate the get handler. +fn generate_get_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("get_{}", entity_name_str.to_case(Case::Snake)); + let response_dto = entity.ident_with("", "Response"); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let success_desc = format!("{} found", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Get {} by ID.\n\n\ + # Responses\n\n\ + - `200 OK` - {} found\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + let not_found_msg = format!("{} not found", entity_name); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + ) -> masterror::AppResult> + where + R: #repo_trait + 'static, + { + let entity = repo + .find_by_id(id) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))? + .ok_or_else(|| masterror::AppError::not_found(#not_found_msg))?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} + +/// Generate the update handler. +fn generate_update_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("update_{}", entity_name_str.to_case(Case::Snake)); + let update_dto = entity.ident_with("Update", "Request"); + let response_dto = entity.ident_with("", "Response"); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let request_body_desc = format!("Fields to update for {}", entity_name); + let success_desc = format!("{} updated successfully", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Update {} by ID.\n\n\ + # Responses\n\n\ + - `200 OK` - {} updated successfully\n\ + - `400 Bad Request` - Invalid request data\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + axum::extract::Json(dto): axum::extract::Json<#update_dto>, + ) -> masterror::AppResult> + where + R: #repo_trait + 'static, + { + let entity = repo + .update(id, dto) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} + +/// Generate the delete handler. +fn generate_delete_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("delete_{}", entity_name_str.to_case(Case::Snake)); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let success_desc = format!("{} deleted successfully", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 204, description = #success_desc), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 204, description = #success_desc), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Delete {} by ID.\n\n\ + # Responses\n\n\ + - `204 No Content` - {} deleted successfully\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + let not_found_msg = format!("{} not found", entity_name); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + ) -> masterror::AppResult + where + R: #repo_trait + 'static, + { + let deleted = repo + .delete(id) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + if deleted { + Ok(axum::http::StatusCode::NO_CONTENT) + } else { + Err(masterror::AppError::not_found(#not_found_msg)) + } + } + } +} + +/// Generate the list handler. +fn generate_list_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("list_{}", entity_name_str.to_case(Case::Snake)); + let response_dto = entity.ident_with("", "Response"); + + let path = build_collection_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let success_desc = format!("List of {} entities", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), + ("offset" = Option, Query, description = "Number of items to skip for pagination") + ), + responses( + (status = 200, description = #success_desc, body = Vec<#response_dto>), + (status = 401, description = "Authentication required"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), + ("offset" = Option, Query, description = "Number of items to skip for pagination") + ), + responses( + (status = 200, description = #success_desc, body = Vec<#response_dto>), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "List {} entities with pagination.\n\n\ + # Query Parameters\n\n\ + - `limit` - Maximum number of items to return (default: 100)\n\ + - `offset` - Number of items to skip for pagination\n\n\ + # Responses\n\n\ + - `200 OK` - List of {} entities\n\ + {}\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + } + ); + + quote! { + /// Pagination query parameters. + #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] + #vis struct PaginationQuery { + /// Maximum number of items to return. + #[serde(default = "default_limit")] + pub limit: i64, + /// Number of items to skip for pagination. + #[serde(default)] + pub offset: i64, + } + + fn default_limit() -> i64 { 100 } + + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Query(pagination): axum::extract::Query, + ) -> masterror::AppResult>> + where + R: #repo_trait + 'static, + { + let entities = repo + .list(pagination.limit, pagination.offset) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + let responses: Vec<#response_dto> = entities.into_iter().map(#response_dto::from).collect(); + Ok(axum::response::Json(responses)) + } + } +} + +/// Build the collection path (e.g., `/api/v1/users`). +fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Build the item path (e.g., `/api/v1/users/{id}`). +fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Build security attribute for a handler. +/// +/// Returns the appropriate security scheme based on the `security` option: +/// - `"cookie"` -> `security(("cookieAuth" = []))` +/// - `"bearer"` -> `security(("bearerAuth" = []))` +/// - `"api_key"` -> `security(("apiKey" = []))` +fn build_security_attr(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + + if let Some(security) = &api_config.security { + let security_name = match security.as_str() { + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" + }; + quote! { security((#security_name = [])) } + } else { + TokenStream::new() + } +} + +/// Build deprecated attribute if API is deprecated. +fn build_deprecated_attr(entity: &EntityDef) -> TokenStream { + if entity.api_config().is_deprecated() { + quote! { , deprecated = true } + } else { + TokenStream::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_entity() -> EntityDef { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + EntityDef::from_derive_input(&input).unwrap() + } + + #[test] + fn collection_path_format() { + let entity = create_test_entity(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn item_path_format() { + let entity = create_test_entity(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); + } + + #[test] + fn generates_handlers_when_enabled() { + let entity = create_test_entity(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("create_user")); + assert!(output.contains("get_user")); + assert!(output.contains("update_user")); + assert!(output.contains("delete_user")); + assert!(output.contains("list_user")); + } + + #[test] + fn no_handlers_when_disabled() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + assert!(tokens.is_empty()); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index 9133cb4..c9193b9 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -1,21 +1,31 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! OpenAPI struct generation. +//! OpenAPI struct generation for utoipa 5.x. //! //! Generates a struct that implements `utoipa::OpenApi` for Swagger UI -//! integration. +//! integration, with security schemes and paths added via the `Modify` trait. //! //! # Generated Code //! -//! For `User` entity: +//! For `User` entity with handlers and security: //! //! ```rust,ignore +//! /// OpenAPI modifier for User entity. +//! struct UserApiModifier; +//! +//! impl utoipa::Modify for UserApiModifier { +//! fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { +//! // Add security schemes +//! // Add CRUD paths with documentation +//! } +//! } +//! //! /// OpenAPI documentation for User entity endpoints. //! #[derive(utoipa::OpenApi)] //! #[openapi( -//! paths(register_user, update_email_user), -//! components(schemas(User, RegisterUser, UpdateEmailUser)), +//! components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), +//! modifiers(&UserApiModifier), //! tags((name = "Users", description = "User management")) //! )] //! pub struct UserApi; @@ -27,10 +37,12 @@ use quote::{format_ident, quote}; use crate::entity::parse::{CommandDef, EntityDef}; -/// Generate the OpenAPI struct. +/// Generate the OpenAPI struct with modifier. pub fn generate(entity: &EntityDef) -> TokenStream { - let commands = entity.command_defs(); - if commands.is_empty() { + let has_crud = entity.api_config().has_handlers(); + let has_commands = !entity.command_defs().is_empty(); + + if !has_crud && !has_commands { return TokenStream::new(); } @@ -38,27 +50,18 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let entity_name = entity.name(); let api_config = entity.api_config(); - // OpenApi struct name: UserApi let api_struct = format_ident!("{}Api", entity_name); + let modifier_struct = format_ident!("{}ApiModifier", entity_name); - // Tag for OpenAPI grouping let tag = api_config.tag_or_default(&entity.name_str()); - - // Tag description: explicit > entity doc comment > default let tag_description = api_config .tag_description .clone() .or_else(|| entity.doc().map(String::from)) .unwrap_or_else(|| format!("{} management", entity_name)); - // Handler function names for paths - let handler_names = generate_handler_names(entity, commands); - - // Schema types (entity + all command structs) - let schema_types = generate_schema_types(entity, commands); - - // Security schemes - let security_schemes = generate_security_schemes(api_config.security.as_deref()); + let schema_types = generate_all_schema_types(entity); + let modifier_impl = generate_modifier(entity, &modifier_struct); let doc = format!( "OpenAPI documentation for {} entity endpoints.\n\n\ @@ -70,89 +73,850 @@ pub fn generate(entity: &EntityDef) -> TokenStream { entity_name, api_struct ); - if security_schemes.is_empty() { - quote! { - #[doc = #doc] - #[derive(utoipa::OpenApi)] - #[openapi( - paths(#handler_names), - components(schemas(#schema_types)), - tags((name = #tag, description = #tag_description)) - )] - #vis struct #api_struct; + quote! { + #modifier_impl + + #[doc = #doc] + #[derive(utoipa::OpenApi)] + #[openapi( + components(schemas(#schema_types)), + modifiers(&#modifier_struct), + tags((name = #tag, description = #tag_description)) + )] + #vis struct #api_struct; + } +} + +/// Generate all schema types (DTOs, commands). +/// +/// Only registers schemas for enabled handlers to keep OpenAPI spec clean. +fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { + let entity_name_str = entity.name_str(); + let mut types: Vec = Vec::new(); + + // CRUD DTOs - only include schemas for enabled handlers + let handlers = entity.api_config().handlers(); + if handlers.any() { + // Response is always needed (for get, list, create, update responses) + let response = entity.ident_with("", "Response"); + types.push(quote! { #response }); + + // CreateRequest only if create handler is enabled + if handlers.create { + let create = entity.ident_with("Create", "Request"); + types.push(quote! { #create }); + } + + // UpdateRequest only if update handler is enabled + if handlers.update { + let update = entity.ident_with("Update", "Request"); + types.push(quote! { #update }); } + } + + // Command structs + for cmd in entity.command_defs() { + let cmd_struct = cmd.struct_name(&entity_name_str); + types.push(quote! { #cmd_struct }); + } + + quote! { #(#types),* } +} + +/// Generate the modifier struct with Modify implementation. +/// +/// This adds security schemes, common schemas, CRUD paths, and info to the +/// OpenAPI spec. +fn generate_modifier(entity: &EntityDef, modifier_name: &syn::Ident) -> TokenStream { + let entity_name = entity.name(); + let api_config = entity.api_config(); + + let info_code = generate_info_code(entity); + let security_code = generate_security_code(api_config.security.as_deref()); + let common_schemas_code = if api_config.has_handlers() { + generate_common_schemas_code() } else { - quote! { - #[doc = #doc] - #[derive(utoipa::OpenApi)] - #[openapi( - paths(#handler_names), - components( - schemas(#schema_types), - #security_schemes - ), - tags((name = #tag, description = #tag_description)) - )] - #vis struct #api_struct; + TokenStream::new() + }; + let paths_code = if api_config.has_handlers() { + generate_paths_code(entity) + } else { + TokenStream::new() + }; + + let doc = format!("OpenAPI modifier for {} entity.", entity_name); + + quote! { + #[doc = #doc] + struct #modifier_name; + + impl utoipa::Modify for #modifier_name { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::*; + + #info_code + #security_code + #common_schemas_code + #paths_code + } } } } -/// Generate comma-separated handler function names. -fn generate_handler_names(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { - let names: Vec = commands - .iter() - .map(|cmd| handler_function_name(entity, cmd)) - .collect(); +/// Generate code to configure OpenAPI info section. +/// +/// Sets title, description, version, license, and contact information. +fn generate_info_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + + // Title: use configured or generate default + let title_code = if let Some(ref title) = api_config.title { + quote! { openapi.info.title = #title.to_string(); } + } else { + TokenStream::new() + }; + + // Description + let description_code = if let Some(ref description) = api_config.description { + quote! { openapi.info.description = Some(#description.to_string()); } + } else if let Some(doc) = entity.doc() { + // Use entity doc comment if no description configured + quote! { openapi.info.description = Some(#doc.to_string()); } + } else { + TokenStream::new() + }; - quote! { #(#names),* } + // API Version + let version_code = if let Some(ref version) = api_config.api_version { + quote! { openapi.info.version = #version.to_string(); } + } else { + TokenStream::new() + }; + + // License + let license_code = match (&api_config.license, &api_config.license_url) { + (Some(name), Some(url)) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .url(Some(#url)) + .build() + ); + } + } + (Some(name), None) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .build() + ); + } + } + _ => TokenStream::new() + }; + + // Contact + let has_contact = api_config.contact_name.is_some() + || api_config.contact_email.is_some() + || api_config.contact_url.is_some(); + + let contact_code = if has_contact { + let name = api_config.contact_name.as_deref().unwrap_or(""); + let email = api_config.contact_email.as_deref(); + let url = api_config.contact_url.as_deref(); + + let email_setter = if let Some(e) = email { + quote! { .email(Some(#e)) } + } else { + TokenStream::new() + }; + + let url_setter = if let Some(u) = url { + quote! { .url(Some(#u)) } + } else { + TokenStream::new() + }; + + quote! { + openapi.info.contact = Some( + info::ContactBuilder::new() + .name(Some(#name)) + #email_setter + #url_setter + .build() + ); + } + } else { + TokenStream::new() + }; + + // Deprecated flag + let deprecated_code = if api_config.is_deprecated() { + let version = api_config.deprecated_in.as_deref().unwrap_or("unknown"); + let msg = format!("Deprecated since {}", version); + quote! { + // Mark in description that API is deprecated + if let Some(ref desc) = openapi.info.description { + openapi.info.description = Some(format!("**DEPRECATED**: {}\n\n{}", #msg, desc)); + } else { + openapi.info.description = Some(format!("**DEPRECATED**: {}", #msg)); + } + } + } else { + TokenStream::new() + }; + + quote! { + #title_code + #description_code + #version_code + #license_code + #contact_code + #deprecated_code + } } -/// Generate comma-separated schema types. -fn generate_schema_types(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); +/// Generate common schemas (ErrorResponse, PaginationQuery) for the OpenAPI +/// spec. +fn generate_common_schemas_code() -> TokenStream { + quote! { + // Add ErrorResponse schema for error responses + if let Some(components) = openapi.components.as_mut() { + // ErrorResponse schema (RFC 7807 Problem Details) + let error_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("ErrorResponse")) + .description(Some("Error response following RFC 7807 Problem Details")) + .property("type", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A URI reference that identifies the problem type")) + .example(Some(serde_json::json!("https://errors.example.com/not-found"))) + .build()) + .required("type") + .property("title", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A short, human-readable summary of the problem")) + .example(Some(serde_json::json!("Resource not found"))) + .build()) + .required("title") + .property("status", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("HTTP status code")) + .example(Some(serde_json::json!(404))) + .build()) + .required("status") + .property("detail", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A human-readable explanation specific to this occurrence")) + .example(Some(serde_json::json!("User with ID '123' was not found"))) + .build()) + .property("code", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("Application-specific error code")) + .example(Some(serde_json::json!("NOT_FOUND"))) + .build()) + .build(); - let command_structs: Vec = commands - .iter() - .map(|cmd| cmd.struct_name(&entity_name_str)) - .collect(); + components.schemas.insert("ErrorResponse".to_string(), error_schema.into()); - quote! { #entity_name, #(#command_structs),* } + // PaginationQuery schema + let pagination_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("PaginationQuery")) + .description(Some("Query parameters for paginated list endpoints")) + .property("limit", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Maximum number of items to return")) + .default(Some(serde_json::json!(100))) + .minimum(Some(1.0)) + .maximum(Some(1000.0)) + .build()) + .property("offset", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Number of items to skip for pagination")) + .default(Some(serde_json::json!(0))) + .minimum(Some(0.0)) + .build()) + .build(); + + components.schemas.insert("PaginationQuery".to_string(), pagination_schema.into()); + } + } } -/// Generate security schemes if configured. -fn generate_security_schemes(security: Option<&str>) -> TokenStream { - match security { - Some("bearer") => { +/// Generate security scheme code for the Modify implementation. +fn generate_security_code(security: Option<&str>) -> TokenStream { + let Some(security) = security else { + return TokenStream::new(); + }; + + let (scheme_name, scheme_impl) = match security { + "cookie" => ( + "cookieAuth", quote! { - security_schemes( - ("bearer_auth" = ( - ty = Http, - scheme = "bearer", - bearer_format = "JWT" - )) + security::SecurityScheme::ApiKey( + security::ApiKey::Cookie( + security::ApiKeyValue::with_description( + "token", + "JWT token stored in HTTP-only cookie" + ) + ) ) } - } - Some("api_key") => { + ), + "bearer" => ( + "bearerAuth", quote! { - security_schemes( - ("api_key" = ( - ty = ApiKey, - in = "header", - name = "X-API-Key" - )) + security::SecurityScheme::Http( + security::HttpBuilder::new() + .scheme(security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("JWT token in Authorization header")) + .build() + ) + } + ), + "api_key" => ( + "apiKey", + quote! { + security::SecurityScheme::ApiKey( + security::ApiKey::Header( + security::ApiKeyValue::with_description( + "X-API-Key", + "API key for service-to-service authentication" + ) + ) ) } + ), + _ => return TokenStream::new() + }; + + quote! { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme(#scheme_name, #scheme_impl); } - _ => TokenStream::new() } } -/// Get the handler function name. -fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { +/// Generate code to add CRUD paths to OpenAPI. +/// +/// Only generates paths for enabled handlers based on `HandlerConfig`. +fn generate_paths_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + let handlers = api_config.handlers(); + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + let tag = api_config.tag_or_default(&entity_name_str); + let collection_path = build_collection_path(entity); + let item_path = build_item_path(entity); + + let response_schema = entity.ident_with("", "Response"); + let create_schema = entity.ident_with("Create", "Request"); + let update_schema = entity.ident_with("Update", "Request"); + + // Schema names for $ref - just the name, not full path + let response_ref = response_schema.to_string(); + let create_ref = create_schema.to_string(); + let update_ref = update_schema.to_string(); + + // Security requirement + let security_req = if let Some(security) = &api_config.security { + let scheme_name = match security.as_str() { + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" + }; + quote! { + Some(vec![security::SecurityRequirement::new::<_, _, &str>(#scheme_name, [])]) + } + } else { + quote! { None } + }; + + // ID parameter type (only needed for item paths) + let needs_id_param = handlers.get || handlers.update || handlers.delete; + let id_type_str = quote!(#id_type).to_string().replace(' ', ""); + let id_schema_type = if id_type_str.contains("Uuid") { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .format(Some(schema::SchemaFormat::Custom("uuid".into()))) + .build() + } + } else { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .build() + } + }; + + let create_op_id = format!("create_{}", entity_name_str.to_case(Case::Snake)); + let get_op_id = format!("get_{}", entity_name_str.to_case(Case::Snake)); + let update_op_id = format!("update_{}", entity_name_str.to_case(Case::Snake)); + let delete_op_id = format!("delete_{}", entity_name_str.to_case(Case::Snake)); + let list_op_id = format!("list_{}", entity_name_str.to_case(Case::Snake)); + + let create_summary = format!("Create a new {}", entity_name); + let get_summary = format!("Get {} by ID", entity_name); + let update_summary = format!("Update {} by ID", entity_name); + let delete_summary = format!("Delete {} by ID", entity_name); + let list_summary = format!("List all {}", entity_name); + + let create_desc = format!("Creates a new {} entity", entity_name); + let get_desc = format!("Retrieves a {} by its unique identifier", entity_name); + let update_desc = format!("Updates an existing {} by ID", entity_name); + let delete_desc = format!("Deletes a {} by ID", entity_name); + let list_desc = format!("Returns a paginated list of {} entities", entity_name); + + let id_param_desc = format!("{} unique identifier", entity_name); + let created_desc = format!("{} created successfully", entity_name); + let found_desc = format!("{} found", entity_name); + let updated_desc = format!("{} updated successfully", entity_name); + let deleted_desc = format!("{} deleted successfully", entity_name); + let list_desc_resp = format!("List of {} entities", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + // Common code: error helper, params, security + let common_code = quote! { + // Helper to build error response with ErrorResponse schema + let error_response = |desc: &str| -> response::Response { + response::ResponseBuilder::new() + .description(desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name("ErrorResponse"))) + .build() + ) + .build() + }; + + // Security requirements + let security_req: Option> = #security_req; + }; + + // ID parameter (only if needed) + let id_param_code = if needs_id_param { + quote! { + let id_param = path::ParameterBuilder::new() + .name("id") + .parameter_in(path::ParameterIn::Path) + .required(utoipa::openapi::Required::True) + .description(Some(#id_param_desc)) + .schema(Some(#id_schema_type)) + .build(); + } + } else { + TokenStream::new() + }; + + // CREATE handler + let create_code = if handlers.create { + quote! { + let create_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#create_op_id)) + .tag(#tag) + .summary(Some(#create_summary)) + .description(Some(#create_desc)) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Request body")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#create_ref))) + .build() + ) + .build() + )) + .response("201", + response::ResponseBuilder::new() + .description(#created_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Post], create_op); + } + } else { + TokenStream::new() + }; + + // LIST handler + let list_code = if handlers.list { + quote! { + let limit_param = path::ParameterBuilder::new() + .name("limit") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Maximum number of items to return (default: 100)")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let offset_param = path::ParameterBuilder::new() + .name("offset") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Number of items to skip for pagination")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let list_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#list_op_id)) + .tag(#tag) + .summary(Some(#list_summary)) + .description(Some(#list_desc)) + .parameter(limit_param) + .parameter(offset_param) + .response("200", + response::ResponseBuilder::new() + .description(#list_desc_resp) + .content("application/json", + content::ContentBuilder::new() + .schema(Some( + schema::ArrayBuilder::new() + .items(Ref::from_schema_name(#response_ref)) + .build() + )) + .build() + ) + .build() + ) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Get], list_op); + } + } else { + TokenStream::new() + }; + + // GET handler + let get_code = if handlers.get { + quote! { + let get_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#get_op_id)) + .tag(#tag) + .summary(Some(#get_summary)) + .description(Some(#get_desc)) + .parameter(id_param.clone()) + .response("200", + response::ResponseBuilder::new() + .description(#found_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Get], get_op); + } + } else { + TokenStream::new() + }; + + // UPDATE handler + let update_code = if handlers.update { + quote! { + let update_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#update_op_id)) + .tag(#tag) + .summary(Some(#update_summary)) + .description(Some(#update_desc)) + .parameter(id_param.clone()) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Fields to update")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#update_ref))) + .build() + ) + .build() + )) + .response("200", + response::ResponseBuilder::new() + .description(#updated_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Patch], update_op); + } + } else { + TokenStream::new() + }; + + // DELETE handler + let delete_code = if handlers.delete { + quote! { + let delete_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#delete_op_id)) + .tag(#tag) + .summary(Some(#delete_summary)) + .description(Some(#delete_desc)) + .parameter(id_param.clone()) + .response("204", + response::ResponseBuilder::new() + .description(#deleted_desc) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Delete], delete_op); + } + } else { + TokenStream::new() + }; + + quote! { + #common_code + #id_param_code + #create_code + #list_code + #get_code + #update_code + #delete_code + } +} + +/// Build the collection path (e.g., `/users`). +fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Build the item path (e.g., `/users/{id}`). +fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Get command handler function name. +#[allow(dead_code)] +fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); let cmd_snake = cmd.name.to_string().to_case(Case::Snake); format_ident!("{}_{}", cmd_snake, entity_snake) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_crud_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserApi")); + assert!(output.contains("UserApiModifier")); + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); + } + + #[test] + fn generate_with_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "bearer", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserApiModifier")); + assert!(output.contains("bearerAuth")); + } + + #[test] + fn generate_cookie_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("cookieAuth")); + } + + #[test] + fn no_api_when_disabled() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + assert!(tokens.is_empty()); + } + + #[test] + fn collection_path_format() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn item_path_format() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); + } + + #[test] + fn selective_handlers_schemas_get_list_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(get, list)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // Response should be included (needed for get/list) + assert!(output.contains("UserResponse")); + // CreateRequest should NOT be included (no create handler) + assert!(!output.contains("CreateUserRequest")); + // UpdateRequest should NOT be included (no update handler) + assert!(!output.contains("UpdateUserRequest")); + } + + #[test] + fn selective_handlers_schemas_create_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // Response should be included (create returns response) + assert!(output.contains("UserResponse")); + // CreateRequest should be included + assert!(output.contains("CreateUserRequest")); + // UpdateRequest should NOT be included + assert!(!output.contains("UpdateUserRequest")); + } + + #[test] + fn selective_handlers_all_schemas() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create, update)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // All schemas should be included + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); + assert!(output.contains("UpdateUserRequest")); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index 05b4eb2..f0445a7 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -3,22 +3,38 @@ //! Router factory generation. //! -//! Generates a function that creates an axum Router with all entity endpoints. +//! Generates functions that create axum Routers for entity endpoints. //! -//! # Generated Code +//! # Generated Routers //! -//! For `User` entity with Register and UpdateEmail commands: +//! | Configuration | Generated Function | Type Parameter | +//! |---------------|-------------------|----------------| +//! | `handlers` | `{entity}_router` | Repository trait | +//! | `commands` | `{entity}_commands_router` | CommandHandler trait | +//! +//! # Example +//! +//! For `User` entity with both handlers and commands: //! //! ```rust,ignore -//! /// Create router for User entity endpoints. -//! pub fn user_router() -> axum::Router +//! // CRUD router +//! pub fn user_router() -> axum::Router> +//! where +//! R: UserRepository + 'static, +//! { +//! axum::Router::new() +//! .route("/users", post(create_user::).get(list_user::)) +//! .route("/users/:id", get(get_user::).patch(update_user::).delete(delete_user::)) +//! } +//! +//! // Commands router +//! pub fn user_commands_router() -> axum::Router //! where //! H: UserCommandHandler + 'static, //! H::Context: Default, //! { //! axum::Router::new() -//! .route("/api/v1/users/register", axum::routing::post(register_user::)) -//! .route("/api/v1/users/:id/update-email", axum::routing::put(update_email_user::)) +//! .route("/users/register", post(register_user::)) //! } //! ``` @@ -28,10 +44,20 @@ use quote::{format_ident, quote}; use crate::entity::parse::{CommandDef, CommandKindHint, EntityDef}; -/// Generate the router factory function. +/// Generate all router factory functions. pub fn generate(entity: &EntityDef) -> TokenStream { - let commands = entity.command_defs(); - if commands.is_empty() { + let crud_router = generate_crud_router(entity); + let commands_router = generate_commands_router(entity); + + quote! { + #crud_router + #commands_router + } +} + +/// Generate CRUD router for repository-based handlers. +fn generate_crud_router(entity: &EntityDef) -> TokenStream { + if !entity.api_config().has_handlers() { return TokenStream::new(); } @@ -40,17 +66,131 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let entity_name_str = entity.name_str(); let entity_snake = entity_name_str.to_case(Case::Snake); - // Router function name: user_router let router_fn = format_ident!("{}_router", entity_snake); + let repo_trait = format_ident!("{}Repository", entity_name); + + let crud_routes = generate_crud_routes(entity); + + let doc = format!( + "Create axum router for {} CRUD endpoints.\n\n\ + # Usage\n\n\ + ```rust,ignore\n\ + let pool = Arc::new(PgPool::connect(url).await?);\n\ + let app = Router::new()\n\ + .merge({}::())\n\ + .with_state(pool);\n\ + ```", + entity_name, router_fn + ); + + quote! { + #[doc = #doc] + #vis fn #router_fn() -> axum::Router> + where + R: #repo_trait + 'static, + { + axum::Router::new() + #crud_routes + } + } +} + +/// Generate CRUD route definitions based on enabled handlers. +fn generate_crud_routes(entity: &EntityDef) -> TokenStream { + let handlers = entity.api_config().handlers(); + let snake = entity.name_str().to_case(Case::Snake); + let collection_path = build_crud_collection_path(entity); + let item_path = build_crud_item_path(entity); + + let create_handler = format_ident!("create_{}", snake); + let get_handler = format_ident!("get_{}", snake); + let update_handler = format_ident!("update_{}", snake); + let delete_handler = format_ident!("delete_{}", snake); + let list_handler = format_ident!("list_{}", snake); + + // Build collection route methods (POST, GET) + let mut collection_methods = Vec::new(); + if handlers.create { + collection_methods.push(quote! { post(#create_handler::) }); + } + if handlers.list { + collection_methods.push(quote! { get(#list_handler::) }); + } + + // Build item route methods (GET, PATCH, DELETE) + let mut item_methods = Vec::new(); + if handlers.get { + item_methods.push(quote! { get(#get_handler::) }); + } + if handlers.update { + item_methods.push(quote! { patch(#update_handler::) }); + } + if handlers.delete { + item_methods.push(quote! { delete(#delete_handler::) }); + } + + // Generate routes only for non-empty method lists + let collection_route = if !collection_methods.is_empty() { + let first = &collection_methods[0]; + let rest: Vec<_> = collection_methods.iter().skip(1).collect(); + quote! { + .route(#collection_path, axum::routing::#first #(.#rest)*) + } + } else { + TokenStream::new() + }; + + let item_route = if !item_methods.is_empty() { + let first = &item_methods[0]; + let rest: Vec<_> = item_methods.iter().skip(1).collect(); + quote! { + .route(#item_path, axum::routing::#first #(.#rest)*) + } + } else { + TokenStream::new() + }; + + quote! { + #collection_route + #item_route + } +} + +/// Build CRUD collection path (e.g., `/api/v1/users`). +fn build_crud_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} - // Handler trait name: UserCommandHandler +/// Build CRUD item path (e.g., `/api/v1/users/{id}`). +fn build_crud_item_path(entity: &EntityDef) -> String { + let collection = build_crud_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Generate commands router for command handler. +fn generate_commands_router(entity: &EntityDef) -> TokenStream { + let commands = entity.command_defs(); + if commands.is_empty() { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let entity_snake = entity_name_str.to_case(Case::Snake); + + let router_fn = format_ident!("{}_commands_router", entity_snake); let handler_trait = format_ident!("{}CommandHandler", entity_name); - // Generate route definitions - let routes = generate_routes(entity, commands); + let routes = generate_command_routes(entity, commands); let doc = format!( - "Create axum router for {} entity endpoints.\n\n\ + "Create axum router for {} command endpoints.\n\n\ # Usage\n\n\ ```rust,ignore\n\ let handler = Arc::new(MyHandler::new());\n\ @@ -74,20 +214,20 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } } -/// Generate all route definitions. -fn generate_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { +/// Generate command route definitions. +fn generate_command_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { let routes: Vec = commands .iter() - .map(|cmd| generate_route(entity, cmd)) + .map(|cmd| generate_command_route(entity, cmd)) .collect(); quote! { #(#routes)* } } -/// Generate a single route definition. -fn generate_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { - let path = build_axum_path(entity, cmd); - let handler_name = handler_function_name(entity, cmd); +/// Generate a single command route definition. +fn generate_command_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { + let path = build_command_path(entity, cmd); + let handler_name = command_handler_name(entity, cmd); let method = axum_method_for_command(cmd); quote! { @@ -95,31 +235,30 @@ fn generate_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { } } -/// Build the axum-style path (uses :id instead of {id}). -fn build_axum_path(entity: &EntityDef, cmd: &CommandDef) -> String { +/// Build command path (e.g., `/users/{id}/activate`). +fn build_command_path(entity: &EntityDef, cmd: &CommandDef) -> String { let api_config = entity.api_config(); let prefix = api_config.full_path_prefix(); let entity_path = entity.name_str().to_case(Case::Kebab); let cmd_path = cmd.name.to_string().to_case(Case::Kebab); let path = if cmd.requires_id { - format!("{}/{}s/:id/{}", prefix, entity_path, cmd_path) + format!("{}/{}s/{{id}}/{}", prefix, entity_path, cmd_path) } else { format!("{}/{}s/{}", prefix, entity_path, cmd_path) }; - // Normalize double slashes that can appear when prefix is empty path.replace("//", "/") } -/// Get the handler function name. -fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { +/// Get command handler function name. +fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); let cmd_snake = cmd.name.to_string().to_case(Case::Snake); format_ident!("{}_{}", cmd_snake, entity_snake) } -/// Get the axum routing method for a command. +/// Get axum routing method for a command. fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { match cmd.kind { CommandKindHint::Create => format_ident!("post"), @@ -128,3 +267,50 @@ fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { CommandKindHint::Custom => format_ident!("post") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crud_collection_path() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn crud_item_path() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_item_path(&entity); + assert_eq!(path, "/users/{id}"); + } + + #[test] + fn crud_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", path_prefix = "/api/v1", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_collection_path(&entity); + assert_eq!(path, "/api/v1/users"); + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs index c0b258c..ec7d419 100644 --- a/crates/entity-derive-impl/src/entity/parse/api.rs +++ b/crates/entity-derive-impl/src/entity/parse/api.rs @@ -30,6 +30,44 @@ use syn::Ident; +/// Handler configuration for selective CRUD generation. +/// +/// # Syntax +/// +/// - `handlers` - enables all handlers +/// - `handlers(create, get, list)` - enables specific handlers +#[derive(Debug, Clone, Default)] +pub struct HandlerConfig { + /// Generate create handler (POST /collection). + pub create: bool, + /// Generate get handler (GET /collection/{id}). + pub get: bool, + /// Generate update handler (PATCH /collection/{id}). + pub update: bool, + /// Generate delete handler (DELETE /collection/{id}). + pub delete: bool, + /// Generate list handler (GET /collection). + pub list: bool +} + +impl HandlerConfig { + /// Create config with all handlers enabled. + pub fn all() -> Self { + Self { + create: true, + get: true, + update: true, + delete: true, + list: true + } + } + + /// Check if any handler is enabled. + pub fn any(&self) -> bool { + self.create || self.get || self.update || self.delete || self.list + } +} + /// API configuration parsed from `#[entity(api(...))]`. /// /// Controls HTTP handler generation and OpenAPI documentation. @@ -73,7 +111,58 @@ pub struct ApiConfig { /// Version in which this API is deprecated. /// /// Marks all endpoints with `deprecated = true` in OpenAPI. - pub deprecated_in: Option + pub deprecated_in: Option, + + /// CRUD handlers configuration. + /// + /// Controls which handlers to generate: + /// - `handlers` - all handlers + /// - `handlers(create, get, list)` - specific handlers only + pub handlers: HandlerConfig, + + /// OpenAPI info: API title. + /// + /// Overrides the default title in OpenAPI spec. + /// Example: `"User Service API"` + pub title: Option, + + /// OpenAPI info: API description. + /// + /// Full description for the API, supports Markdown. + /// Example: `"RESTful API for user management"` + pub description: Option, + + /// OpenAPI info: API version. + /// + /// Semantic version string for the API. + /// Example: `"1.0.0"` + pub api_version: Option, + + /// OpenAPI info: License name. + /// + /// License under which the API is published. + /// Example: `"MIT"`, `"Apache-2.0"` + pub license: Option, + + /// OpenAPI info: License URL. + /// + /// URL to the license text. + pub license_url: Option, + + /// OpenAPI info: Contact name. + /// + /// Name of the API maintainer or team. + pub contact_name: Option, + + /// OpenAPI info: Contact email. + /// + /// Email for API support inquiries. + pub contact_email: Option, + + /// OpenAPI info: Contact URL. + /// + /// URL to API support or documentation. + pub contact_url: Option } impl ApiConfig { @@ -121,6 +210,16 @@ impl ApiConfig { self.deprecated_in.is_some() } + /// Check if any CRUD handler should be generated. + pub fn has_handlers(&self) -> bool { + self.handlers.any() + } + + /// Get handler configuration. + pub fn handlers(&self) -> &HandlerConfig { + &self.handlers + } + /// Get security scheme for a command. /// /// Returns `None` for public commands, otherwise the default security. @@ -209,12 +308,85 @@ pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { let value: syn::LitStr = nested.value()?.parse()?; config.deprecated_in = Some(value.value()); } + "handlers" => { + // Support: + // - `handlers` - all handlers + // - `handlers = true/false` - all or none + // - `handlers(create, get, list)` - specific handlers + if nested.input.peek(syn::Token![=]) { + let _: syn::Token![=] = nested.input.parse()?; + let value: syn::LitBool = nested.input.parse()?; + if value.value() { + config.handlers = HandlerConfig::all(); + } + } else if nested.input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in nested.input); + let handlers = + syn::punctuated::Punctuated::::parse_terminated( + &content + )?; + for handler in handlers { + match handler.to_string().as_str() { + "create" => config.handlers.create = true, + "get" => config.handlers.get = true, + "update" => config.handlers.update = true, + "delete" => config.handlers.delete = true, + "list" => config.handlers.list = true, + other => { + return Err(syn::Error::new( + handler.span(), + format!( + "unknown handler '{}', expected: create, get, update, delete, list", + other + ) + )); + } + } + } + } else { + config.handlers = HandlerConfig::all(); + } + } + "title" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.title = Some(value.value()); + } + "description" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.description = Some(value.value()); + } + "api_version" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.api_version = Some(value.value()); + } + "license" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.license = Some(value.value()); + } + "license_url" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.license_url = Some(value.value()); + } + "contact_name" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_name = Some(value.value()); + } + "contact_email" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_email = Some(value.value()); + } + "contact_url" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_url = Some(value.value()); + } _ => { return Err(syn::Error::new( ident.span(), format!( "unknown api option '{}', expected: tag, tag_description, path_prefix, \ - security, public, version, deprecated_in", + security, public, version, deprecated_in, handlers, title, description, \ + api_version, license, license_url, contact_name, contact_email, contact_url", ident_str ) )); @@ -343,4 +515,62 @@ mod tests { }; assert_eq!(config.full_path_prefix(), "/api/v1"); } + + #[test] + fn parse_handlers_flag() { + let config = parse_test_config(r#"api(tag = "Users", handlers)"#); + assert!(config.has_handlers()); + } + + #[test] + fn parse_handlers_true() { + let config = parse_test_config(r#"api(tag = "Users", handlers = true)"#); + assert!(config.has_handlers()); + } + + #[test] + fn parse_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users", handlers = false)"#); + assert!(!config.has_handlers()); + } + + #[test] + fn default_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert!(!config.has_handlers()); + } + + #[test] + fn parse_handlers_selective() { + let config = parse_test_config(r#"api(tag = "Users", handlers(create, get, list))"#); + assert!(config.has_handlers()); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(config.handlers().list); + } + + #[test] + fn parse_handlers_single() { + let config = parse_test_config(r#"api(tag = "Users", handlers(get))"#); + assert!(config.has_handlers()); + assert!(!config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(!config.handlers().list); + } + + #[test] + fn parse_handlers_all_explicit() { + let config = parse_test_config( + r#"api(tag = "Users", handlers(create, get, update, delete, list))"# + ); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(config.handlers().update); + assert!(config.handlers().delete); + assert!(config.handlers().list); + } } diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index f200972..5fb2d38 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -16,6 +16,7 @@ validate = [] [dependencies] entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } +masterror = { version = "0.27", features = ["axum", "openapi"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 55ce2c9..f71c5db 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,38 +1,59 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Axum CRUD Example with entity-derive +//! Basic CRUD Example with Generated Handlers //! //! Demonstrates full CRUD operations using: -//! - entity-derive for code generation +//! - entity-derive for code generation including HTTP handlers //! - Axum for HTTP routing //! - sqlx for PostgreSQL access //! - utoipa for OpenAPI docs +//! +//! Key features: +//! - `api(tag = "Users", handlers)` generates CRUD handlers automatically +//! - `user_router()` provides ready-to-use axum Router +//! - `UserApi` provides OpenAPI documentation use std::sync::Arc; -use axum::{ - Json, Router, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, post}, -}; +use axum::Router; use chrono::{DateTime, Utc}; use entity_derive::Entity; -use serde::Deserialize; use sqlx::PgPool; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; // ============================================================================ -// Entity Definition +// Entity Definition with Generated API // ============================================================================ -/// User entity with full CRUD support. +/// User entity with full CRUD support and cookie authentication. +/// +/// The `api(tag = "Users", security = "cookie", handlers)` attribute generates: +/// - `create_user()` - POST /users (requires auth) +/// - `get_user()` - GET /users/{id} (requires auth) +/// - `update_user()` - PATCH /users/{id} (requires auth) +/// - `delete_user()` - DELETE /users/{id} (requires auth) +/// - `list_user()` - GET /users (requires auth) +/// - `user_router()` - axum Router with all routes +/// - `UserApi` - OpenAPI documentation with security scheme #[derive(Debug, Clone, Entity)] -#[entity(table = "users", schema = "public")] +#[entity( + table = "users", + schema = "public", + api( + tag = "Users", + security = "cookie", + handlers, + title = "User Service API", + description = "RESTful API for user management with cookie-based authentication", + api_version = "1.0.0", + license = "MIT", + contact_name = "API Support", + contact_email = "support@example.com" + ) +)] pub struct User { /// Unique identifier (UUID v7). #[id] @@ -61,203 +82,25 @@ pub struct User { pub updated_at: DateTime, } -// ============================================================================ -// Application State -// ============================================================================ - -#[derive(Clone)] -struct AppState { - pool: Arc, -} - -impl AppState { - fn new(pool: PgPool) -> Self { - Self { - pool: Arc::new(pool), - } - } - - fn repo(&self) -> &PgPool { - &self.pool - } -} - -// ============================================================================ -// Query Parameters -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct ListParams { - #[serde(default = "default_limit")] - limit: i64, - #[serde(default)] - offset: i64, -} - -fn default_limit() -> i64 { - 20 -} - -// ============================================================================ -// Error Handling -// ============================================================================ - -enum AppError { - NotFound, - Database(sqlx::Error), -} - -impl From for AppError { - fn from(err: sqlx::Error) -> Self { - match err { - sqlx::Error::RowNotFound => Self::NotFound, - _ => Self::Database(err), - } - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - match self { - Self::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), - Self::Database(e) => { - tracing::error!("Database error: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() - } - } - } -} - -// ============================================================================ -// HTTP Handlers -// ============================================================================ - -/// Create a new user. -#[utoipa::path( - post, - path = "/users", - request_body = CreateUserRequest, - responses( - (status = 201, description = "User created", body = UserResponse), - (status = 500, description = "Internal error"), - ) -)] -async fn create_user( - State(state): State, - Json(dto): Json, -) -> Result { - let user = state.repo().create(dto).await?; - Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) -} - -/// Get user by ID. -#[utoipa::path( - get, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - responses( - (status = 200, description = "User found", body = UserResponse), - (status = 404, description = "User not found"), - ) -)] -async fn get_user( - State(state): State, - Path(id): Path, -) -> Result { - let user = state.repo().find_by_id(id).await?.ok_or(AppError::NotFound)?; - Ok(Json(UserResponse::from(user))) -} - -/// Update user by ID. -#[utoipa::path( - patch, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - request_body = UpdateUserRequest, - responses( - (status = 200, description = "User updated", body = UserResponse), - (status = 404, description = "User not found"), - ) -)] -async fn update_user( - State(state): State, - Path(id): Path, - Json(dto): Json, -) -> Result { - let user = state.repo().update(id, dto).await?; - Ok(Json(UserResponse::from(user))) -} - -/// Delete user by ID. -#[utoipa::path( - delete, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - responses( - (status = 204, description = "User deleted"), - (status = 404, description = "User not found"), - ) -)] -async fn delete_user( - State(state): State, - Path(id): Path, -) -> Result { - let deleted = state.repo().delete(id).await?; - if deleted { - Ok(StatusCode::NO_CONTENT) - } else { - Err(AppError::NotFound) - } -} - -/// List users with pagination. -#[utoipa::path( - get, - path = "/users", - params( - ("limit" = Option, Query, description = "Max results"), - ("offset" = Option, Query, description = "Skip results"), - ), - responses( - (status = 200, description = "List of users", body = Vec), - ) -)] -async fn list_users( - State(state): State, - Query(params): Query, -) -> Result { - let users = state.repo().list(params.limit, params.offset).await?; - let responses: Vec = users.into_iter().map(UserResponse::from).collect(); - Ok(Json(responses)) -} - -// ============================================================================ -// OpenAPI Documentation -// ============================================================================ - -#[derive(OpenApi)] -#[openapi( - paths( - create_user, - get_user, - update_user, - delete_user, - list_users, - ), - components(schemas(CreateUserRequest, UpdateUserRequest, UserResponse)) -)] -struct ApiDoc; - // ============================================================================ // Router Setup // ============================================================================ -fn app(state: AppState) -> Router { +/// Create the application router. +/// +/// Uses the generated `user_router()` function which includes: +/// - POST /users - create user +/// - GET /users - list users +/// - GET /users/{id} - get user +/// - PATCH /users/{id} - update user +/// - DELETE /users/{id} - delete user +fn app(pool: Arc) -> Router { Router::new() - .route("/users", post(create_user).get(list_users)) - .route("/users/{id}", get(get_user).patch(update_user).delete(delete_user)) - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) - .with_state(state) + // Use the generated router for CRUD operations + .merge(user_router::()) + // Add Swagger UI using generated OpenAPI struct + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", UserApi::openapi())) + .with_state(pool) } // ============================================================================ @@ -267,13 +110,11 @@ fn app(state: AppState) -> Router { #[tokio::main] async fn main() { tracing_subscriber::fmt() - .with_env_filter("axum_crud_example=debug,tower_http=debug") + .with_env_filter("example_basic=debug,tower_http=debug") .init(); - let database_url = - std::env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://postgres:postgres@localhost:5432/entity_example".to_string() - }); + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); let pool = PgPool::connect(&database_url) .await @@ -284,12 +125,18 @@ async fn main() { .await .expect("Failed to run migrations"); - let state = AppState::new(pool); - let app = app(state); + let app = app(Arc::new(pool)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); tracing::info!("Listening on http://localhost:3000"); tracing::info!("Swagger UI: http://localhost:3000/swagger-ui"); + tracing::info!(""); + tracing::info!("Try these endpoints:"); + tracing::info!(" POST /users - Create a user"); + tracing::info!(" GET /users - List users"); + tracing::info!(" GET /users/{{id}} - Get user by ID"); + tracing::info!(" PATCH /users/{{id}} - Update user"); + tracing::info!(" DELETE /users/{{id}} - Delete user"); axum::serve(listener, app).await.unwrap(); } From 4d9c569ad96e03ed2c9ddb7ff968b09480088160 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 14:22:40 +0700 Subject: [PATCH 22/30] chore: bump versions and fix full-app example - Bump entity-core: 0.1.3 -> 0.2.0 - Bump entity-derive: 0.3.3 -> 0.4.0 - Bump entity-derive-impl: 0.1.3 -> 0.2.0 - Bump workspace: 0.3.0 -> 0.4.0 Full-app example fixes: - Add masterror dependency - Add streams feature to entity-core - Fix relation patterns (use #[has_many] at struct level) - Add Serialize/Deserialize derives to AuditLog - Simplify to single entity with api(handlers) --- Cargo.toml | 8 +- crates/entity-core/Cargo.toml | 2 +- crates/entity-derive-impl/Cargo.toml | 2 +- crates/entity-derive/Cargo.toml | 6 +- examples/full-app/Cargo.toml | 5 +- examples/full-app/src/main.rs | 381 +++++---------------------- 6 files changed, 77 insertions(+), 327 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 74389d6..e209c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ exclude = [ ] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.92" authors = ["RAprogramm "] @@ -26,9 +26,9 @@ license = "MIT" repository = "https://github.com/RAprogramm/entity-derive" [workspace.dependencies] -entity-core = { path = "crates/entity-core", version = "0.1.2" } -entity-derive = { path = "crates/entity-derive", version = "0.3.2" } -entity-derive-impl = { path = "crates/entity-derive-impl", version = "0.1.2" } +entity-core = { path = "crates/entity-core", version = "0.2.0" } +entity-derive = { path = "crates/entity-derive", version = "0.4.0" } +entity-derive-impl = { path = "crates/entity-derive-impl", version = "0.2.0" } syn = { version = "2", features = ["full", "extra-traits", "parsing"] } quote = "1" proc-macro2 = "1" diff --git a/crates/entity-core/Cargo.toml b/crates/entity-core/Cargo.toml index ee3f3e3..67d8791 100644 --- a/crates/entity-core/Cargo.toml +++ b/crates/entity-core/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "entity-core" -version = "0.1.3" +version = "0.2.0" edition = "2024" rust-version = "1.92" authors = ["RAprogramm "] diff --git a/crates/entity-derive-impl/Cargo.toml b/crates/entity-derive-impl/Cargo.toml index dbd7458..e3be362 100644 --- a/crates/entity-derive-impl/Cargo.toml +++ b/crates/entity-derive-impl/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "entity-derive-impl" -version = "0.1.3" +version = "0.2.0" edition = "2024" rust-version = "1.92" authors = ["RAprogramm "] diff --git a/crates/entity-derive/Cargo.toml b/crates/entity-derive/Cargo.toml index bbf0dff..2ada274 100644 --- a/crates/entity-derive/Cargo.toml +++ b/crates/entity-derive/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "entity-derive" -version = "0.3.3" +version = "0.4.0" edition = "2024" rust-version = "1.92" authors = ["RAprogramm "] @@ -25,8 +25,8 @@ api = [] validate = [] [dependencies] -entity-core = { path = "../entity-core", version = "0.1.3" } -entity-derive-impl = { path = "../entity-derive-impl", version = "0.1.3" } +entity-core = { path = "../entity-core", version = "0.2.0" } +entity-derive-impl = { path = "../entity-derive-impl", version = "0.2.0" } [dev-dependencies] trybuild = "1" diff --git a/examples/full-app/Cargo.toml b/examples/full-app/Cargo.toml index 67d73c6..77d0ddd 100644 --- a/examples/full-app/Cargo.toml +++ b/examples/full-app/Cargo.toml @@ -15,8 +15,9 @@ api = [] validate = [] [dependencies] -entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } -entity-core = { path = "../../crates/entity-core", features = ["postgres"] } +entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api", "streams"] } +entity-core = { path = "../../crates/entity-core", features = ["postgres", "streams"] } +masterror = { version = "0.27", features = ["axum", "openapi"] } axum = "0.8" tokio = { version = "1", features = ["full", "sync"] } tokio-stream = "0.1" diff --git a/examples/full-app/src/main.rs b/examples/full-app/src/main.rs index 92ba750..6ac2061 100644 --- a/examples/full-app/src/main.rs +++ b/examples/full-app/src/main.rs @@ -3,39 +3,41 @@ //! Full Application Example with entity-derive //! -//! A complete e-commerce application demonstrating ALL entity-derive features: +//! A complete e-commerce application demonstrating entity-derive features: +//! - Auto-generated CRUD handlers with `api(handlers)` //! - Relations (`#[belongs_to]`, `#[has_many]`) //! - Soft Delete (`#[entity(soft_delete)]`) //! - Transactions (`#[entity(transactions)]`) //! - Events (`#[entity(events)]`) -//! - Hooks (`#[entity(hooks)]`) -//! - Commands (`#[entity(commands)]`) //! - Streams (`#[entity(streams)]`) //! - Filtering (`#[filter]`, `#[filter(like)]`, `#[filter(range)]`) -use axum::{ - Json, Router, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{delete, get, patch, post}, -}; +use std::sync::Arc; + +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; use chrono::{DateTime, Utc}; use entity_core::prelude::*; use entity_derive::Entity; use futures::StreamExt; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use std::sync::Arc; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; // ============================================================================ // Entity Definitions // ============================================================================ -/// User entity with soft delete, events, and hooks. +/// User entity with full CRUD API and soft delete. +/// This entity uses auto-generated handlers via `api(handlers)`. #[derive(Debug, Clone, Entity)] -#[entity(table = "users", soft_delete, events, hooks)] +#[entity( + table = "users", + soft_delete, + api(tag = "Users", handlers, title = "E-Commerce API", api_version = "1.0.0") +)] +#[has_many(Order)] pub struct User { #[id] pub id: Uuid, @@ -65,15 +67,12 @@ pub struct User { #[field(skip)] pub deleted_at: Option>, - - /// User's orders - #[has_many(Order, foreign_key = "user_id")] - pub orders: Vec, } -/// Category entity (basic CRUD). +/// Category entity (basic CRUD without auto-handlers). #[derive(Debug, Clone, Entity)] #[entity(table = "categories")] +#[has_many(Product)] pub struct Category { #[id] pub id: Uuid, @@ -88,20 +87,18 @@ pub struct Category { #[field(response)] #[auto] pub created_at: DateTime, - - /// Products in this category - #[has_many(Product, foreign_key = "category_id")] - pub products: Vec, } -/// Product entity with relations, filtering, and soft delete. +/// Product entity with soft delete, transactions, and filtering. #[derive(Debug, Clone, Entity)] #[entity(table = "products", soft_delete, transactions)] pub struct Product { #[id] pub id: Uuid, + /// Foreign key to category #[field(create, update, response)] + #[belongs_to(Category)] pub category_id: Uuid, #[field(create, update, response)] @@ -132,22 +129,19 @@ pub struct Product { #[field(skip)] pub deleted_at: Option>, - - /// Parent category - #[belongs_to(Category)] - pub category: Option, } /// Order entity with transactions and events. -#[derive(Debug, Clone, Entity)] +#[derive(Debug, Clone, PartialEq, Entity)] #[entity(table = "orders", transactions, events)] -#[command(PlaceOrder)] -#[command(UpdateStatus, requires_id)] +#[has_many(OrderItem)] pub struct Order { #[id] pub id: Uuid, + /// Foreign key to user #[field(create, response)] + #[belongs_to(User)] pub user_id: Uuid, #[field(create, update, response)] @@ -164,14 +158,6 @@ pub struct Order { #[field(response)] #[auto] pub updated_at: DateTime, - - /// Customer who placed the order - #[belongs_to(User)] - pub user: Option, - - /// Items in this order - #[has_many(OrderItem, foreign_key = "order_id")] - pub items: Vec, } /// Order item entity (line items). @@ -181,10 +167,14 @@ pub struct OrderItem { #[id] pub id: Uuid, + /// Foreign key to order #[field(create, response)] + #[belongs_to(Order)] pub order_id: Uuid, + /// Foreign key to product #[field(create, response)] + #[belongs_to(Product)] pub product_id: Uuid, #[field(create, response)] @@ -196,19 +186,11 @@ pub struct OrderItem { #[field(response)] #[auto] pub created_at: DateTime, - - /// Parent order - #[belongs_to(Order)] - pub order: Option, - - /// Product reference - #[belongs_to(Product)] - pub product: Option, } /// Audit log for streaming. -#[derive(Debug, Clone, Entity)] -#[entity(table = "audit_logs", streams)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Entity)] +#[entity(table = "audit_logs", streams, events)] pub struct AuditLog { #[id] pub id: Uuid, @@ -240,16 +222,7 @@ pub struct AuditLog { } // ============================================================================ -// Application State -// ============================================================================ - -#[derive(Clone)] -struct AppState { - pool: Arc, -} - -// ============================================================================ -// Request/Response DTOs +// Custom Order Placement (Transaction Example) // ============================================================================ #[derive(Debug, Deserialize)] @@ -265,206 +238,40 @@ struct OrderItemInput { } #[derive(Debug, Serialize)] -struct OrderWithItems { - #[serde(flatten)] +struct PlaceOrderResponse { order: OrderResponse, items: Vec, total_formatted: String, } -// ============================================================================ -// User Handlers -// ============================================================================ - -async fn list_users( - State(state): State, - Query(filter): Query, -) -> Result { - let users = state - .pool - .list_filtered(filter, 100, 0) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let responses: Vec = users.into_iter().map(UserResponse::from).collect(); - Ok(Json(responses)) -} - -async fn get_user( - State(state): State, - Path(id): Path, -) -> Result { - let user = state - .pool - .find_by_id(id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(UserResponse::from(user))) -} - -async fn create_user( - State(state): State, - Json(dto): Json, -) -> Result { - let user = state - .pool - .create(dto) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) -} - -async fn delete_user( - State(state): State, - Path(id): Path, -) -> Result { - let deleted = state - .pool - .delete(id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if deleted { - Ok(StatusCode::NO_CONTENT) - } else { - Err(StatusCode::NOT_FOUND) - } -} - -// ============================================================================ -// Category Handlers -// ============================================================================ - -async fn list_categories(State(state): State) -> Result { - let categories: Vec = state - .pool - .list(100, 0) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let responses: Vec = categories - .into_iter() - .map(CategoryResponse::from) - .collect(); - Ok(Json(responses)) -} - -async fn get_category_with_products( - State(state): State, - Path(id): Path, -) -> Result { - let category = state - .pool - .find_by_id_with_products(id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(CategoryResponse::from(category))) -} - -// ============================================================================ -// Product Handlers -// ============================================================================ - -async fn list_products( - State(state): State, - Query(filter): Query, -) -> Result { - let products = state - .pool - .list_filtered(filter, 100, 0) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let responses: Vec = products.into_iter().map(ProductResponse::from).collect(); - Ok(Json(responses)) -} - -async fn get_product( - State(state): State, - Path(id): Path, -) -> Result { - let product = state - .pool - .find_by_id_with_category(id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(ProductResponse::from(product))) -} - -async fn create_product( - State(state): State, - Json(dto): Json, -) -> Result { - let product = state - .pool - .create(dto) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok((StatusCode::CREATED, Json(ProductResponse::from(product)))) -} - -async fn update_product( - State(state): State, - Path(id): Path, - Json(dto): Json, -) -> Result { - let product = state - .pool - .update(id, dto) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(ProductResponse::from(product))) -} - -// ============================================================================ -// Order Handlers (with Transactions) -// ============================================================================ - -/// Place an order atomically. -/// -/// This demonstrates transactions across multiple entities: -/// 1. Create order -/// 2. Create order items -/// 3. Update product stock -/// 4. Calculate and update order total +/// Place an order atomically using transactions. async fn place_order( - State(state): State, + State(pool): State>, Json(req): Json, ) -> Result { if req.items.is_empty() { return Err((StatusCode::BAD_REQUEST, "Order must have items".into())); } - let result = Transaction::new(&*state.pool) + let result = Transaction::new(&*pool) .with_orders() .with_order_items() .with_products() .run(|mut ctx| async move { - // Step 1: Create the order + // Create order let order = ctx .orders() .create(CreateOrderRequest { user_id: req.user_id, status: "pending".to_string(), - total: 0, }) .await?; let mut total: i64 = 0; let mut created_items = Vec::new(); - // Step 2: Process each item + // Process each item for item in &req.items { - // Get product and check stock let product = ctx .products() .find_by_id(item.product_id) @@ -478,7 +285,6 @@ async fn place_order( ))); } - // Create order item let order_item = ctx .order_items() .create(CreateOrderItemRequest { @@ -492,7 +298,6 @@ async fn place_order( created_items.push(order_item); total += product.price * item.quantity as i64; - // Update stock ctx.products() .update( item.product_id, @@ -508,7 +313,7 @@ async fn place_order( .await?; } - // Step 3: Update order total + // Update order total let final_order = ctx .orders() .update( @@ -520,17 +325,17 @@ async fn place_order( ) .await?; - Ok((final_order, created_items)) + Ok((final_order, created_items, total)) }) .await; match result { - Ok((order, items)) => { + Ok((order, items, total)) => { tracing::info!("Order {} placed successfully", order.id); - let response = OrderWithItems { + let response = PlaceOrderResponse { order: OrderResponse::from(order), items: items.into_iter().map(OrderItemResponse::from).collect(), - total_formatted: format!("${:.2}", items.iter().map(|i| i.unit_price * i.quantity as i64).sum::() as f64 / 100.0), + total_formatted: format!("${:.2}", total as f64 / 100.0), }; Ok((StatusCode::CREATED, Json(response))) } @@ -541,50 +346,8 @@ async fn place_order( } } -async fn list_orders( - State(state): State, - Query(filter): Query, -) -> Result { - let orders = state - .pool - .list_filtered(filter, 100, 0) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let responses: Vec = orders.into_iter().map(OrderResponse::from).collect(); - Ok(Json(responses)) -} - -async fn get_order( - State(state): State, - Path(id): Path, -) -> Result { - let order = state - .pool - .find_by_id_with_items(id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(OrderResponse::from(order))) -} - -async fn update_order_status( - State(state): State, - Path(id): Path, - Json(dto): Json, -) -> Result { - let order = state - .pool - .update(id, dto) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(OrderResponse::from(order))) -} - // ============================================================================ -// Audit Log Handlers (Streaming) +// Audit Log Streaming Example // ============================================================================ #[derive(Debug, Deserialize)] @@ -595,18 +358,19 @@ struct AuditQuery { } async fn stream_audit_logs( - State(state): State, - Query(query): Query, + State(pool): State>, + axum::extract::Query(query): axum::extract::Query, ) -> Result { let filter = AuditLogFilter { entity_type: query.entity_type, action: query.action, - created_at_min: None, - created_at_max: None, + created_at_from: None, + created_at_to: None, + limit: None, + offset: None, }; - let mut stream = state - .pool + let mut stream = pool .stream_filtered(filter) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -630,26 +394,16 @@ async fn stream_audit_logs( // Router Setup // ============================================================================ -fn app(state: AppState) -> Router { +fn app(pool: Arc) -> Router { Router::new() - // User routes - .route("/users", get(list_users).post(create_user)) - .route("/users/{id}", get(get_user).delete(delete_user)) - // Category routes - .route("/categories", get(list_categories)) - .route("/categories/{id}", get(get_category_with_products)) - // Product routes - .route("/products", get(list_products).post(create_product)) - .route("/products/{id}", get(get_product).patch(update_product)) - // Order routes - .route("/orders", get(list_orders).post(place_order)) - .route( - "/orders/{id}", - get(get_order).patch(update_order_status), - ) - // Audit routes - .route("/audit", get(stream_audit_logs)) - .with_state(state) + // Use generated router for Users (auto-generated handlers) + .merge(user_router::()) + // Custom endpoints + .route("/orders/place", post(place_order)) + .route("/audit", axum::routing::get(stream_audit_logs)) + // Swagger UI + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", UserApi::openapi())) + .with_state(pool) } // ============================================================================ @@ -674,29 +428,24 @@ async fn main() { .await .expect("Failed to run migrations"); - let state = AppState { - pool: Arc::new(pool), - }; - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); tracing::info!("================================================="); tracing::info!("Full Application Example - All Features Combined"); tracing::info!("================================================="); tracing::info!("Listening on http://localhost:3000"); + tracing::info!("Swagger UI: http://localhost:3000/swagger-ui"); tracing::info!(""); tracing::info!("Features demonstrated:"); - tracing::info!(" - Relations: Category -> Products, User -> Orders"); + tracing::info!(" - Auto-generated CRUD handlers (User)"); + tracing::info!(" - Relations: User -> Orders, Category -> Products"); tracing::info!(" - Soft Delete: Users, Products"); tracing::info!(" - Transactions: Order placement"); - tracing::info!(" - Filtering: Products by price, Users by role"); tracing::info!(" - Streams: Audit log processing"); tracing::info!(""); tracing::info!("Endpoints:"); - tracing::info!(" GET/POST /users"); - tracing::info!(" GET/POST /products?price_min=&price_max="); - tracing::info!(" GET /categories/{{id}} (with products)"); - tracing::info!(" POST /orders (atomic order placement)"); + tracing::info!(" GET/POST /users (auto-generated)"); + tracing::info!(" POST /orders/place (atomic order placement)"); tracing::info!(" GET /audit?entity_type=&action="); - axum::serve(listener, app(state)).await.unwrap(); + axum::serve(listener, app(Arc::new(pool))).await.unwrap(); } From 1e810804831c1aa86046ab57a1923368fba513d7 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 14:23:23 +0700 Subject: [PATCH 23/30] docs: update README with v0.4 features --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4fbeb7e..cd83d0e 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ pub struct User { ```toml [dependencies] -entity-derive = { version = "0.3", features = ["postgres"] } +entity-derive = { version = "0.4", features = ["postgres", "api"] } ``` --- @@ -88,9 +88,11 @@ entity-derive = { version = "0.3", features = ["postgres"] } |---------|-------------| | **Zero Runtime Cost** | All code generation at compile time | | **Type Safe** | Change a field once, everything updates | +| **Auto HTTP Handlers** | `api(handlers)` generates CRUD endpoints + router | +| **OpenAPI Docs** | Auto-generated Swagger/OpenAPI documentation | | **Query Filtering** | Type-safe `#[filter]`, `#[filter(like)]`, `#[filter(range)]` | | **Relations** | `#[belongs_to]` and `#[has_many]` | -| **Projections** | Partial views with optimized SELECT | +| **Transactions** | Multi-entity atomic operations | | **Lifecycle Events** | `Created`, `Updated`, `Deleted` events | | **Real-Time Streams** | Postgres LISTEN/NOTIFY integration | | **Lifecycle Hooks** | `before_create`, `after_update`, etc. | @@ -134,6 +136,14 @@ entity-derive = { version = "0.3", features = ["postgres"] } streams, // Optional: real-time Postgres NOTIFY hooks, // Optional: before/after lifecycle hooks commands, // Optional: CQRS command pattern + transactions, // Optional: multi-entity transaction support + api( // Optional: generate HTTP handlers + OpenAPI + tag = "Users", + handlers, // All CRUD, or handlers(get, list, create) + security = "bearer", // cookie, bearer, api_key, or none + title = "My API", + api_version = "1.0.0", + ), )] ``` From 6fe4abfa48891f499f5460cd360d24d709b75d63 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 14:59:40 +0700 Subject: [PATCH 24/30] refactor: split large modules into smaller components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split 5 large modules (600-900 lines) into focused submodules: - openapi.rs (922 lines) → openapi/ (6 files) - crud.rs (708 lines) → crud/ (8 files) - command.rs (630 lines) → command/ (4 files) - api.rs (576 lines) → parse/api/ (4 files) - entity.rs (806 lines) → parse/entity/ (8 files) Changes: - Replace inline comments with doc comments (//! and ///) - Remove regular comments (allowed only in examples) - Add detailed module-level documentation - Improve code organization for better testability --- .../entity-derive-impl/src/entity/api/crud.rs | 708 -------------- .../src/entity/api/crud/create.rs | 238 +++++ .../src/entity/api/crud/delete.rs | 110 +++ .../src/entity/api/crud/get.rs | 108 ++ .../src/entity/api/crud/helpers.rs | 280 ++++++ .../src/entity/api/crud/list.rs | 120 +++ .../src/entity/api/crud/mod.rs | 249 +++++ .../src/entity/api/crud/tests.rs | 59 ++ .../src/entity/api/crud/update.rs | 113 +++ .../src/entity/api/handlers.rs | 22 - .../src/entity/api/openapi.rs | 922 ------------------ .../src/entity/api/openapi/info.rs | 119 +++ .../src/entity/api/openapi/mod.rs | 143 +++ .../src/entity/api/openapi/paths.rs | 354 +++++++ .../src/entity/api/openapi/schemas.rs | 108 ++ .../src/entity/api/openapi/security.rs | 75 ++ .../src/entity/api/openapi/tests.rs | 156 +++ .../src/entity/api/router.rs | 3 - .../src/entity/parse/api.rs | 576 ----------- .../src/entity/parse/api/config.rs | 212 ++++ .../src/entity/parse/api/mod.rs | 38 + .../src/entity/parse/api/parser.rs | 169 ++++ .../src/entity/parse/api/tests.rs | 176 ++++ .../src/entity/parse/command.rs | 630 ------------ .../src/entity/parse/command/mod.rs | 34 + .../src/entity/parse/command/parser.rs | 150 +++ .../src/entity/parse/command/tests.rs | 287 ++++++ .../src/entity/parse/command/types.rs | 181 ++++ .../src/entity/parse/entity.rs | 781 +-------------- .../src/entity/parse/entity/accessors.rs | 197 ++++ .../src/entity/parse/entity/constructor.rs | 114 +++ .../src/entity/parse/entity/def.rs | 155 +++ .../src/entity/parse/entity/helpers.rs | 101 ++ .../src/entity/parse/entity/tests.rs | 105 ++ 34 files changed, 4164 insertions(+), 3629 deletions(-) delete mode 100644 crates/entity-derive-impl/src/entity/api/crud.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/create.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/delete.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/get.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/helpers.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/list.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/mod.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/tests.rs create mode 100644 crates/entity-derive-impl/src/entity/api/crud/update.rs delete mode 100644 crates/entity-derive-impl/src/entity/api/openapi.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/info.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/mod.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/paths.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/schemas.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/security.rs create mode 100644 crates/entity-derive-impl/src/entity/api/openapi/tests.rs delete mode 100644 crates/entity-derive-impl/src/entity/parse/api.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/api/config.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/api/mod.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/api/parser.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/api/tests.rs delete mode 100644 crates/entity-derive-impl/src/entity/parse/command.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/command/mod.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/command/parser.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/command/tests.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/command/types.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/accessors.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/constructor.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/def.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/helpers.rs create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/tests.rs diff --git a/crates/entity-derive-impl/src/entity/api/crud.rs b/crates/entity-derive-impl/src/entity/api/crud.rs deleted file mode 100644 index 08219e8..0000000 --- a/crates/entity-derive-impl/src/entity/api/crud.rs +++ /dev/null @@ -1,708 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 RAprogramm -// SPDX-License-Identifier: MIT - -//! CRUD handler generation with utoipa annotations. -//! -//! Generates production-ready REST handlers with: -//! - OpenAPI documentation via `#[utoipa::path]` -//! - Cookie/Bearer authentication via `security` attribute -//! - Proper error responses using `masterror::ErrorResponse` -//! - Standard HTTP status codes and error handling -//! -//! # Generated Handlers -//! -//! | Operation | HTTP Method | Path | Status Codes | -//! |-----------|-------------|------|--------------| -//! | Create | POST | `/{entities}` | 201, 400, 401, 500 | -//! | Get | GET | `/{entities}/{id}` | 200, 401, 404, 500 | -//! | Update | PATCH | `/{entities}/{id}` | 200, 400, 401, 404, 500 | -//! | Delete | DELETE | `/{entities}/{id}` | 204, 401, 404, 500 | -//! | List | GET | `/{entities}` | 200, 401, 500 | -//! -//! # Security -//! -//! When `security = "cookie"` or `security = "bearer"` is specified, -//! handlers require authentication and use `Claims` extractor. -//! -//! # Example -//! -//! ```rust,ignore -//! #[derive(Entity)] -//! #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] -//! pub struct User { /* ... */ } -//! -//! // Generated handler with auth: -//! #[utoipa::path( -//! post, -//! path = "/users", -//! tag = "Users", -//! request_body(content = CreateUserRequest, description = "User data to create"), -//! responses( -//! (status = 201, description = "User created successfully", body = UserResponse), -//! (status = 400, description = "Invalid request data", body = ErrorResponse), -//! (status = 401, description = "Authentication required", body = ErrorResponse), -//! (status = 500, description = "Internal server error", body = ErrorResponse) -//! ), -//! security(("cookieAuth" = [])) -//! )] -//! pub async fn create_user( -//! _claims: Claims, -//! State(repo): State>, -//! Json(dto): Json, -//! ) -> AppResult<(StatusCode, Json)> -//! where -//! R: UserRepository + 'static, -//! { /* ... */ } -//! ``` - -use convert_case::{Case, Casing}; -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; - -use crate::entity::parse::EntityDef; - -/// Generate CRUD handler functions based on enabled handlers. -pub fn generate(entity: &EntityDef) -> TokenStream { - if !entity.api_config().has_handlers() { - return TokenStream::new(); - } - - let handlers = entity.api_config().handlers(); - - let create = if handlers.create { - generate_create_handler(entity) - } else { - TokenStream::new() - }; - let get = if handlers.get { - generate_get_handler(entity) - } else { - TokenStream::new() - }; - let update = if handlers.update { - generate_update_handler(entity) - } else { - TokenStream::new() - }; - let delete = if handlers.delete { - generate_delete_handler(entity) - } else { - TokenStream::new() - }; - let list = if handlers.list { - generate_list_handler(entity) - } else { - TokenStream::new() - }; - - quote! { - #create - #get - #update - #delete - #list - } -} - -/// Generate the create handler. -fn generate_create_handler(entity: &EntityDef) -> TokenStream { - let vis = &entity.vis; - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let api_config = entity.api_config(); - let repo_trait = entity.ident_with("", "Repository"); - let has_security = api_config.security.is_some(); - - let handler_name = format_ident!("create_{}", entity_name_str.to_case(Case::Snake)); - let create_dto = entity.ident_with("Create", "Request"); - let response_dto = entity.ident_with("", "Response"); - - let path = build_collection_path(entity); - let tag = api_config.tag_or_default(&entity_name_str); - - let security_attr = build_security_attr(entity); - let deprecated_attr = build_deprecated_attr(entity); - - let request_body_desc = format!("Data for creating a new {}", entity_name); - let success_desc = format!("{} created successfully", entity_name); - - let utoipa_attr = if has_security { - quote! { - #[utoipa::path( - post, - path = #path, - tag = #tag, - request_body(content = #create_dto, description = #request_body_desc), - responses( - (status = 201, description = #success_desc, body = #response_dto), - (status = 400, description = "Invalid request data"), - (status = 401, description = "Authentication required"), - (status = 500, description = "Internal server error") - ), - #security_attr - #deprecated_attr - )] - } - } else { - quote! { - #[utoipa::path( - post, - path = #path, - tag = #tag, - request_body(content = #create_dto, description = #request_body_desc), - responses( - (status = 201, description = #success_desc, body = #response_dto), - (status = 400, description = "Invalid request data"), - (status = 500, description = "Internal server error") - ) - #deprecated_attr - )] - } - }; - - let doc = format!( - "Create a new {}.\n\n\ - # Responses\n\n\ - - `201 Created` - {} created successfully\n\ - - `400 Bad Request` - Invalid request data\n\ - {}\ - - `500 Internal Server Error` - Database or server error", - entity_name, - entity_name, - if has_security { - "- `401 Unauthorized` - Authentication required\n" - } else { - "" - } - ); - - quote! { - #[doc = #doc] - #utoipa_attr - #vis async fn #handler_name( - axum::extract::State(repo): axum::extract::State>, - axum::extract::Json(dto): axum::extract::Json<#create_dto>, - ) -> masterror::AppResult<(axum::http::StatusCode, axum::response::Json<#response_dto>)> - where - R: #repo_trait + 'static, - { - let entity = repo - .create(dto) - .await - .map_err(|e| masterror::AppError::internal(e.to_string()))?; - Ok((axum::http::StatusCode::CREATED, axum::response::Json(#response_dto::from(entity)))) - } - } -} - -/// Generate the get handler. -fn generate_get_handler(entity: &EntityDef) -> TokenStream { - let vis = &entity.vis; - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let api_config = entity.api_config(); - let id_field = entity.id_field(); - let id_type = &id_field.ty; - let repo_trait = entity.ident_with("", "Repository"); - let has_security = api_config.security.is_some(); - - let handler_name = format_ident!("get_{}", entity_name_str.to_case(Case::Snake)); - let response_dto = entity.ident_with("", "Response"); - - let path = build_item_path(entity); - let tag = api_config.tag_or_default(&entity_name_str); - - let security_attr = build_security_attr(entity); - let deprecated_attr = build_deprecated_attr(entity); - - let id_desc = format!("{} unique identifier", entity_name); - let success_desc = format!("{} found", entity_name); - let not_found_desc = format!("{} not found", entity_name); - - let utoipa_attr = if has_security { - quote! { - #[utoipa::path( - get, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - responses( - (status = 200, description = #success_desc, body = #response_dto), - (status = 401, description = "Authentication required"), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ), - #security_attr - #deprecated_attr - )] - } - } else { - quote! { - #[utoipa::path( - get, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - responses( - (status = 200, description = #success_desc, body = #response_dto), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ) - #deprecated_attr - )] - } - }; - - let doc = format!( - "Get {} by ID.\n\n\ - # Responses\n\n\ - - `200 OK` - {} found\n\ - {}\ - - `404 Not Found` - {} not found\n\ - - `500 Internal Server Error` - Database or server error", - entity_name, - entity_name, - if has_security { - "- `401 Unauthorized` - Authentication required\n" - } else { - "" - }, - entity_name - ); - - let not_found_msg = format!("{} not found", entity_name); - - quote! { - #[doc = #doc] - #utoipa_attr - #vis async fn #handler_name( - axum::extract::State(repo): axum::extract::State>, - axum::extract::Path(id): axum::extract::Path<#id_type>, - ) -> masterror::AppResult> - where - R: #repo_trait + 'static, - { - let entity = repo - .find_by_id(id) - .await - .map_err(|e| masterror::AppError::internal(e.to_string()))? - .ok_or_else(|| masterror::AppError::not_found(#not_found_msg))?; - Ok(axum::response::Json(#response_dto::from(entity))) - } - } -} - -/// Generate the update handler. -fn generate_update_handler(entity: &EntityDef) -> TokenStream { - let vis = &entity.vis; - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let api_config = entity.api_config(); - let id_field = entity.id_field(); - let id_type = &id_field.ty; - let repo_trait = entity.ident_with("", "Repository"); - let has_security = api_config.security.is_some(); - - let handler_name = format_ident!("update_{}", entity_name_str.to_case(Case::Snake)); - let update_dto = entity.ident_with("Update", "Request"); - let response_dto = entity.ident_with("", "Response"); - - let path = build_item_path(entity); - let tag = api_config.tag_or_default(&entity_name_str); - - let security_attr = build_security_attr(entity); - let deprecated_attr = build_deprecated_attr(entity); - - let id_desc = format!("{} unique identifier", entity_name); - let request_body_desc = format!("Fields to update for {}", entity_name); - let success_desc = format!("{} updated successfully", entity_name); - let not_found_desc = format!("{} not found", entity_name); - - let utoipa_attr = if has_security { - quote! { - #[utoipa::path( - patch, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - request_body(content = #update_dto, description = #request_body_desc), - responses( - (status = 200, description = #success_desc, body = #response_dto), - (status = 400, description = "Invalid request data"), - (status = 401, description = "Authentication required"), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ), - #security_attr - #deprecated_attr - )] - } - } else { - quote! { - #[utoipa::path( - patch, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - request_body(content = #update_dto, description = #request_body_desc), - responses( - (status = 200, description = #success_desc, body = #response_dto), - (status = 400, description = "Invalid request data"), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ) - #deprecated_attr - )] - } - }; - - let doc = format!( - "Update {} by ID.\n\n\ - # Responses\n\n\ - - `200 OK` - {} updated successfully\n\ - - `400 Bad Request` - Invalid request data\n\ - {}\ - - `404 Not Found` - {} not found\n\ - - `500 Internal Server Error` - Database or server error", - entity_name, - entity_name, - if has_security { - "- `401 Unauthorized` - Authentication required\n" - } else { - "" - }, - entity_name - ); - - quote! { - #[doc = #doc] - #utoipa_attr - #vis async fn #handler_name( - axum::extract::State(repo): axum::extract::State>, - axum::extract::Path(id): axum::extract::Path<#id_type>, - axum::extract::Json(dto): axum::extract::Json<#update_dto>, - ) -> masterror::AppResult> - where - R: #repo_trait + 'static, - { - let entity = repo - .update(id, dto) - .await - .map_err(|e| masterror::AppError::internal(e.to_string()))?; - Ok(axum::response::Json(#response_dto::from(entity))) - } - } -} - -/// Generate the delete handler. -fn generate_delete_handler(entity: &EntityDef) -> TokenStream { - let vis = &entity.vis; - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let api_config = entity.api_config(); - let id_field = entity.id_field(); - let id_type = &id_field.ty; - let repo_trait = entity.ident_with("", "Repository"); - let has_security = api_config.security.is_some(); - - let handler_name = format_ident!("delete_{}", entity_name_str.to_case(Case::Snake)); - - let path = build_item_path(entity); - let tag = api_config.tag_or_default(&entity_name_str); - - let security_attr = build_security_attr(entity); - let deprecated_attr = build_deprecated_attr(entity); - - let id_desc = format!("{} unique identifier", entity_name); - let success_desc = format!("{} deleted successfully", entity_name); - let not_found_desc = format!("{} not found", entity_name); - - let utoipa_attr = if has_security { - quote! { - #[utoipa::path( - delete, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - responses( - (status = 204, description = #success_desc), - (status = 401, description = "Authentication required"), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ), - #security_attr - #deprecated_attr - )] - } - } else { - quote! { - #[utoipa::path( - delete, - path = #path, - tag = #tag, - params(("id" = #id_type, Path, description = #id_desc)), - responses( - (status = 204, description = #success_desc), - (status = 404, description = #not_found_desc), - (status = 500, description = "Internal server error") - ) - #deprecated_attr - )] - } - }; - - let doc = format!( - "Delete {} by ID.\n\n\ - # Responses\n\n\ - - `204 No Content` - {} deleted successfully\n\ - {}\ - - `404 Not Found` - {} not found\n\ - - `500 Internal Server Error` - Database or server error", - entity_name, - entity_name, - if has_security { - "- `401 Unauthorized` - Authentication required\n" - } else { - "" - }, - entity_name - ); - - let not_found_msg = format!("{} not found", entity_name); - - quote! { - #[doc = #doc] - #utoipa_attr - #vis async fn #handler_name( - axum::extract::State(repo): axum::extract::State>, - axum::extract::Path(id): axum::extract::Path<#id_type>, - ) -> masterror::AppResult - where - R: #repo_trait + 'static, - { - let deleted = repo - .delete(id) - .await - .map_err(|e| masterror::AppError::internal(e.to_string()))?; - if deleted { - Ok(axum::http::StatusCode::NO_CONTENT) - } else { - Err(masterror::AppError::not_found(#not_found_msg)) - } - } - } -} - -/// Generate the list handler. -fn generate_list_handler(entity: &EntityDef) -> TokenStream { - let vis = &entity.vis; - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let api_config = entity.api_config(); - let repo_trait = entity.ident_with("", "Repository"); - let has_security = api_config.security.is_some(); - - let handler_name = format_ident!("list_{}", entity_name_str.to_case(Case::Snake)); - let response_dto = entity.ident_with("", "Response"); - - let path = build_collection_path(entity); - let tag = api_config.tag_or_default(&entity_name_str); - - let security_attr = build_security_attr(entity); - let deprecated_attr = build_deprecated_attr(entity); - - let success_desc = format!("List of {} entities", entity_name); - - let utoipa_attr = if has_security { - quote! { - #[utoipa::path( - get, - path = #path, - tag = #tag, - params( - ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), - ("offset" = Option, Query, description = "Number of items to skip for pagination") - ), - responses( - (status = 200, description = #success_desc, body = Vec<#response_dto>), - (status = 401, description = "Authentication required"), - (status = 500, description = "Internal server error") - ), - #security_attr - #deprecated_attr - )] - } - } else { - quote! { - #[utoipa::path( - get, - path = #path, - tag = #tag, - params( - ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), - ("offset" = Option, Query, description = "Number of items to skip for pagination") - ), - responses( - (status = 200, description = #success_desc, body = Vec<#response_dto>), - (status = 500, description = "Internal server error") - ) - #deprecated_attr - )] - } - }; - - let doc = format!( - "List {} entities with pagination.\n\n\ - # Query Parameters\n\n\ - - `limit` - Maximum number of items to return (default: 100)\n\ - - `offset` - Number of items to skip for pagination\n\n\ - # Responses\n\n\ - - `200 OK` - List of {} entities\n\ - {}\ - - `500 Internal Server Error` - Database or server error", - entity_name, - entity_name, - if has_security { - "- `401 Unauthorized` - Authentication required\n" - } else { - "" - } - ); - - quote! { - /// Pagination query parameters. - #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] - #vis struct PaginationQuery { - /// Maximum number of items to return. - #[serde(default = "default_limit")] - pub limit: i64, - /// Number of items to skip for pagination. - #[serde(default)] - pub offset: i64, - } - - fn default_limit() -> i64 { 100 } - - #[doc = #doc] - #utoipa_attr - #vis async fn #handler_name( - axum::extract::State(repo): axum::extract::State>, - axum::extract::Query(pagination): axum::extract::Query, - ) -> masterror::AppResult>> - where - R: #repo_trait + 'static, - { - let entities = repo - .list(pagination.limit, pagination.offset) - .await - .map_err(|e| masterror::AppError::internal(e.to_string()))?; - let responses: Vec<#response_dto> = entities.into_iter().map(#response_dto::from).collect(); - Ok(axum::response::Json(responses)) - } - } -} - -/// Build the collection path (e.g., `/api/v1/users`). -fn build_collection_path(entity: &EntityDef) -> String { - let api_config = entity.api_config(); - let prefix = api_config.full_path_prefix(); - let entity_path = entity.name_str().to_case(Case::Kebab); - - let path = format!("{}/{}s", prefix, entity_path); - path.replace("//", "/") -} - -/// Build the item path (e.g., `/api/v1/users/{id}`). -fn build_item_path(entity: &EntityDef) -> String { - let collection = build_collection_path(entity); - format!("{}/{{id}}", collection) -} - -/// Build security attribute for a handler. -/// -/// Returns the appropriate security scheme based on the `security` option: -/// - `"cookie"` -> `security(("cookieAuth" = []))` -/// - `"bearer"` -> `security(("bearerAuth" = []))` -/// - `"api_key"` -> `security(("apiKey" = []))` -fn build_security_attr(entity: &EntityDef) -> TokenStream { - let api_config = entity.api_config(); - - if let Some(security) = &api_config.security { - let security_name = match security.as_str() { - "cookie" => "cookieAuth", - "bearer" => "bearerAuth", - "api_key" => "apiKey", - _ => "cookieAuth" - }; - quote! { security((#security_name = [])) } - } else { - TokenStream::new() - } -} - -/// Build deprecated attribute if API is deprecated. -fn build_deprecated_attr(entity: &EntityDef) -> TokenStream { - if entity.api_config().is_deprecated() { - quote! { , deprecated = true } - } else { - TokenStream::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_entity() -> EntityDef { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - #[field(create, update, response)] - pub name: String, - } - }; - EntityDef::from_derive_input(&input).unwrap() - } - - #[test] - fn collection_path_format() { - let entity = create_test_entity(); - let path = build_collection_path(&entity); - assert_eq!(path, "/users"); - } - - #[test] - fn item_path_format() { - let entity = create_test_entity(); - let path = build_item_path(&entity); - assert_eq!(path, "/users/{id}"); - } - - #[test] - fn generates_handlers_when_enabled() { - let entity = create_test_entity(); - let tokens = generate(&entity); - let output = tokens.to_string(); - assert!(output.contains("create_user")); - assert!(output.contains("get_user")); - assert!(output.contains("update_user")); - assert!(output.contains("delete_user")); - assert!(output.contains("list_user")); - } - - #[test] - fn no_handlers_when_disabled() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users"))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - assert!(tokens.is_empty()); - } -} diff --git a/crates/entity-derive-impl/src/entity/api/crud/create.rs b/crates/entity-derive-impl/src/entity/api/crud/create.rs new file mode 100644 index 0000000..c26cd05 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/create.rs @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! POST handler generation for creating new entities. +//! +//! This module generates the `create_{entity}` HTTP handler function +//! that creates new entities via POST requests. +//! +//! # Generated Handler +//! +//! For an entity `User`, generates: +//! +//! ```rust,ignore +//! /// Create a new User. +//! /// +//! /// # Responses +//! /// +//! /// - `201 Created` - User created successfully +//! /// - `400 Bad Request` - Invalid request data +//! /// - `401 Unauthorized` - Authentication required (if security enabled) +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! post, +//! path = "/users", +//! tag = "Users", +//! request_body(content = CreateUserRequest, description = "..."), +//! responses( +//! (status = 201, description = "User created", body = UserResponse), +//! (status = 400, description = "Invalid request data"), +//! (status = 401, description = "Authentication required"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn create_user( +//! State(repo): State>, +//! Json(dto): Json, +//! ) -> AppResult<(StatusCode, Json)> +//! where +//! R: UserRepository + 'static, +//! { +//! let entity = repo +//! .create(dto) +//! .await +//! .map_err(|e| AppError::internal(e.to_string()))?; +//! Ok((StatusCode::CREATED, Json(UserResponse::from(entity)))) +//! } +//! ``` +//! +//! # Request Flow +//! +//! ```text +//! Client Handler Repository Database +//! │ │ │ │ +//! │ POST /users │ │ │ +//! │ CreateUserRequest │ │ │ +//! │─────────────────────>│ │ │ +//! │ │ │ │ +//! │ │ repo.create(dto) │ │ +//! │ │─────────────────────>│ │ +//! │ │ │ │ +//! │ │ │ INSERT INTO users │ +//! │ │ │──────────────────>│ +//! │ │ │ │ +//! │ │ │<──────────────────│ +//! │ │ │ UserRow │ +//! │ │<─────────────────────│ │ +//! │ │ User │ │ +//! │ │ │ │ +//! │<─────────────────────│ │ │ +//! │ 201 Created │ │ │ +//! │ UserResponse │ │ │ +//! ``` +//! +//! # DTO Transformation +//! +//! The handler uses three types: +//! +//! | Type | Purpose | Direction | +//! |------|---------|-----------| +//! | `CreateUserRequest` | Validated input from client | Request body | +//! | `User` | Internal domain entity | Repository return | +//! | `UserResponse` | Serialized output to client | Response body | +//! +//! The `UserResponse::from(entity)` conversion is automatically generated +//! by the derive macro based on `#[field(response)]` attributes. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::helpers::{build_collection_path, build_deprecated_attr, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// Generates the POST handler for creating new entities. +/// +/// Creates a handler function that: +/// +/// 1. Accepts `CreateEntityRequest` in JSON body +/// 2. Validates the request data (via serde/validator) +/// 3. Calls `repository.create(dto)` to persist the entity +/// 4. Returns `201 Created` with `EntityResponse` body +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A `TokenStream` containing the complete handler function with: +/// - Doc comments describing the endpoint +/// - `#[utoipa::path]` attribute for OpenAPI documentation +/// - The async handler function implementation +/// +/// # Generated Components +/// +/// | Component | Description | +/// |-----------|-------------| +/// | Function name | `create_{entity_snake}` (e.g., `create_user`) | +/// | Path | Collection path (e.g., `/users`) | +/// | Method | POST | +/// | Request body | `Create{Entity}Request` | +/// | Response body | `{Entity}Response` | +/// | Status code | 201 Created on success | +/// +/// # Security Handling +/// +/// When security is configured on the entity: +/// +/// - Adds `401 Unauthorized` to response list +/// - Includes `security((...))` attribute in utoipa +/// +/// Without security: +/// +/// - Only 201, 400, 500 responses documented +/// - No security attribute generated +/// +/// # Error Handling +/// +/// The handler wraps repository errors in `AppError::internal(...)`: +/// +/// ```rust,ignore +/// repo.create(dto) +/// .await +/// .map_err(|e| AppError::internal(e.to_string()))? +/// ``` +/// +/// This ensures all database errors return 500 Internal Server Error +/// with a safe error message (no SQL details leaked). +pub fn generate_create_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("create_{}", entity_name_str.to_case(Case::Snake)); + let create_dto = entity.ident_with("Create", "Request"); + let response_dto = entity.ident_with("", "Response"); + + let path = build_collection_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let request_body_desc = format!("Data for creating a new {}", entity_name); + let success_desc = format!("{} created successfully", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body(content = #create_dto, description = #request_body_desc), + responses( + (status = 201, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 401, description = "Authentication required"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body(content = #create_dto, description = #request_body_desc), + responses( + (status = 201, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Create a new {}.\n\n\ + # Responses\n\n\ + - `201 Created` - {} created successfully\n\ + - `400 Bad Request` - Invalid request data\n\ + {}\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + } + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Json(dto): axum::extract::Json<#create_dto>, + ) -> masterror::AppResult<(axum::http::StatusCode, axum::response::Json<#response_dto>)> + where + R: #repo_trait + 'static, + { + let entity = repo + .create(dto) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + Ok((axum::http::StatusCode::CREATED, axum::response::Json(#response_dto::from(entity)))) + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/delete.rs b/crates/entity-derive-impl/src/entity/api/crud/delete.rs new file mode 100644 index 0000000..b3ba4e2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/delete.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Delete handler generation. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// Generate the delete handler. +pub fn generate_delete_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("delete_{}", entity_name_str.to_case(Case::Snake)); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let success_desc = format!("{} deleted successfully", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 204, description = #success_desc), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 204, description = #success_desc), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Delete {} by ID.\n\n\ + # Responses\n\n\ + - `204 No Content` - {} deleted successfully\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + let not_found_msg = format!("{} not found", entity_name); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + ) -> masterror::AppResult + where + R: #repo_trait + 'static, + { + let deleted = repo + .delete(id) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + if deleted { + Ok(axum::http::StatusCode::NO_CONTENT) + } else { + Err(masterror::AppError::not_found(#not_found_msg)) + } + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/get.rs b/crates/entity-derive-impl/src/entity/api/crud/get.rs new file mode 100644 index 0000000..03df73b --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/get.rs @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Get handler generation. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// Generate the get handler. +pub fn generate_get_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("get_{}", entity_name_str.to_case(Case::Snake)); + let response_dto = entity.ident_with("", "Response"); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let success_desc = format!("{} found", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Get {} by ID.\n\n\ + # Responses\n\n\ + - `200 OK` - {} found\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + let not_found_msg = format!("{} not found", entity_name); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + ) -> masterror::AppResult> + where + R: #repo_trait + 'static, + { + let entity = repo + .find_by_id(id) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))? + .ok_or_else(|| masterror::AppError::not_found(#not_found_msg))?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/helpers.rs b/crates/entity-derive-impl/src/entity/api/crud/helpers.rs new file mode 100644 index 0000000..d6b424c --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/helpers.rs @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Helper functions for CRUD handler generation. +//! +//! This module provides utility functions used across all CRUD handler +//! generators. These helpers handle common tasks like URL path construction +//! and security attribute generation. +//! +//! # Overview +//! +//! The helpers in this module are responsible for: +//! +//! - **Path Building**: Constructing RESTful URL paths following conventions +//! - **Security Attributes**: Generating utoipa security annotations +//! - **Deprecation Handling**: Adding deprecated markers to OpenAPI spec +//! +//! # Path Conventions +//! +//! All paths follow REST conventions: +//! +//! | Resource Type | Pattern | Example | +//! |---------------|---------|---------| +//! | Collection | `/{prefix}/{entity}s` | `/api/v1/users` | +//! | Item | `/{prefix}/{entity}s/{id}` | `/api/v1/users/{id}` | +//! +//! Entity names are converted to kebab-case and pluralized. +//! +//! # Security Schemes +//! +//! Supported authentication schemes: +//! +//! | Scheme | OpenAPI Name | Description | +//! |--------|--------------|-------------| +//! | `cookie` | `cookieAuth` | JWT in HTTP-only cookie | +//! | `bearer` | `bearerAuth` | JWT in Authorization header | +//! | `api_key` | `apiKey` | API key in X-API-Key header | +//! +//! # Example +//! +//! ```rust,ignore +//! use crate::entity::api::crud::helpers::*; +//! +//! // For entity "UserProfile" with prefix "/api/v1": +//! let collection = build_collection_path(&entity); +//! // Result: "/api/v1/user-profiles" +//! +//! let item = build_item_path(&entity); +//! // Result: "/api/v1/user-profiles/{id}" +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::quote; + +use crate::entity::parse::EntityDef; + +/// Builds the collection endpoint path for an entity. +/// +/// Constructs the URL path for collection-level operations (list, create). +/// The path follows REST conventions: `/{prefix}/{entity}s`. +/// +/// # Path Construction +/// +/// The path is built from three components: +/// +/// 1. **Prefix**: From `api(path_prefix = "...")` attribute +/// 2. **Entity name**: Converted to kebab-case +/// 3. **Plural suffix**: Always adds "s" for collections +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing API configuration +/// +/// # Returns +/// +/// A `String` containing the full collection path. +/// +/// # Examples +/// +/// ```rust,ignore +/// // Entity: User, prefix: /api/v1 +/// build_collection_path(&entity) // "/api/v1/users" +/// +/// // Entity: UserProfile, prefix: /api +/// build_collection_path(&entity) // "/api/user-profiles" +/// +/// // Entity: Order, no prefix +/// build_collection_path(&entity) // "/orders" +/// ``` +/// +/// # Notes +/// +/// - Double slashes (`//`) are automatically normalized to single slashes +/// - Entity names are converted from PascalCase to kebab-case +/// - The plural form is naive (just adds "s"), not grammatically correct +pub fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Builds the item endpoint path for an entity. +/// +/// Constructs the URL path for item-level operations (get, update, delete). +/// The path follows REST conventions: `/{prefix}/{entity}s/{id}`. +/// +/// # Path Construction +/// +/// Extends the collection path with an `{id}` path parameter: +/// +/// ```text +/// /api/v1/users/{id} +/// ↑ ↑ +/// collection parameter +/// ``` +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing API configuration +/// +/// # Returns +/// +/// A `String` containing the full item path with `{id}` placeholder. +/// +/// # Examples +/// +/// ```rust,ignore +/// // Entity: User, prefix: /api/v1 +/// build_item_path(&entity) // "/api/v1/users/{id}" +/// +/// // Entity: BlogPost, no prefix +/// build_item_path(&entity) // "/blog-posts/{id}" +/// ``` +/// +/// # OpenAPI Integration +/// +/// The `{id}` placeholder is recognized by utoipa and generates: +/// +/// ```yaml +/// parameters: +/// - name: id +/// in: path +/// required: true +/// ``` +pub fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Generates the utoipa security attribute for a handler. +/// +/// Creates the `security((...))` attribute used in `#[utoipa::path]` +/// annotations. The security scheme is determined by the entity's +/// API configuration. +/// +/// # Security Scheme Mapping +/// +/// | Config Value | OpenAPI Scheme | Authentication Method | +/// |--------------|----------------|----------------------| +/// | `"cookie"` | `cookieAuth` | JWT in HTTP-only cookie | +/// | `"bearer"` | `bearerAuth` | JWT in Authorization header | +/// | `"api_key"` | `apiKey` | Key in X-API-Key header | +/// | Other | `cookieAuth` | Falls back to cookie auth | +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing security config +/// +/// # Returns +/// +/// A `TokenStream` containing either: +/// - `security(("schemeName" = []))` if security is configured +/// - Empty `TokenStream` if no security is configured +/// +/// # Generated Code Examples +/// +/// With `security = "bearer"`: +/// ```rust,ignore +/// #[utoipa::path( +/// // ... +/// security(("bearerAuth" = [])) +/// )] +/// ``` +/// +/// With `security = "cookie"`: +/// ```rust,ignore +/// #[utoipa::path( +/// // ... +/// security(("cookieAuth" = [])) +/// )] +/// ``` +/// +/// Without security: +/// ```rust,ignore +/// #[utoipa::path( +/// // ... (no security attribute) +/// )] +/// ``` +/// +/// # OpenAPI Spec +/// +/// The generated security requirement references a security scheme +/// that must be defined in the OpenAPI components. See +/// [`crate::entity::api::openapi::security`] for scheme definitions. +pub fn build_security_attr(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + + if let Some(security) = &api_config.security { + let security_name = match security.as_str() { + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" + }; + quote! { security((#security_name = [])) } + } else { + TokenStream::new() + } +} + +/// Generates the deprecated attribute for API endpoints. +/// +/// Creates the `deprecated = true` attribute used in `#[utoipa::path]` +/// annotations when the entity's API is marked as deprecated. +/// +/// # Deprecation Flow +/// +/// 1. Entity marked with `api(deprecated_in = "v2")` +/// 2. This function returns `deprecated = true` attribute +/// 3. OpenAPI spec shows endpoint as deprecated +/// 4. Swagger UI displays strikethrough on deprecated endpoints +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing deprecation info +/// +/// # Returns +/// +/// A `TokenStream` containing either: +/// - `, deprecated = true` if API is deprecated +/// - Empty `TokenStream` if API is not deprecated +/// +/// # Generated Code Examples +/// +/// With `api(deprecated_in = "v2")`: +/// ```rust,ignore +/// #[utoipa::path( +/// get, +/// path = "/users/{id}", +/// // ... +/// , deprecated = true // ← generated by this function +/// )] +/// ``` +/// +/// Without deprecation: +/// ```rust,ignore +/// #[utoipa::path( +/// get, +/// path = "/users/{id}", +/// // ... (no deprecated attribute) +/// )] +/// ``` +/// +/// # Visual Result +/// +/// In Swagger UI, deprecated endpoints appear with: +/// - Strikethrough text on the endpoint name +/// - "Deprecated" badge +/// - Grayed out styling +pub fn build_deprecated_attr(entity: &EntityDef) -> TokenStream { + if entity.api_config().is_deprecated() { + quote! { , deprecated = true } + } else { + TokenStream::new() + } +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/list.rs b/crates/entity-derive-impl/src/entity/api/crud/list.rs new file mode 100644 index 0000000..8bd9274 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/list.rs @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! List handler generation. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::helpers::{build_collection_path, build_deprecated_attr, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// Generate the list handler. +pub fn generate_list_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("list_{}", entity_name_str.to_case(Case::Snake)); + let response_dto = entity.ident_with("", "Response"); + + let path = build_collection_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let success_desc = format!("List of {} entities", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), + ("offset" = Option, Query, description = "Number of items to skip for pagination") + ), + responses( + (status = 200, description = #success_desc, body = Vec<#response_dto>), + (status = 401, description = "Authentication required"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Maximum number of items to return (default: 100)"), + ("offset" = Option, Query, description = "Number of items to skip for pagination") + ), + responses( + (status = 200, description = #success_desc, body = Vec<#response_dto>), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "List {} entities with pagination.\n\n\ + # Query Parameters\n\n\ + - `limit` - Maximum number of items to return (default: 100)\n\ + - `offset` - Number of items to skip for pagination\n\n\ + # Responses\n\n\ + - `200 OK` - List of {} entities\n\ + {}\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + } + ); + + quote! { + /// Pagination query parameters. + #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] + #vis struct PaginationQuery { + /// Maximum number of items to return. + #[serde(default = "default_limit")] + pub limit: i64, + /// Number of items to skip for pagination. + #[serde(default)] + pub offset: i64, + } + + fn default_limit() -> i64 { 100 } + + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Query(pagination): axum::extract::Query, + ) -> masterror::AppResult>> + where + R: #repo_trait + 'static, + { + let entities = repo + .list(pagination.limit, pagination.offset) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + let responses: Vec<#response_dto> = entities.into_iter().map(#response_dto::from).collect(); + Ok(axum::response::Json(responses)) + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/mod.rs b/crates/entity-derive-impl/src/entity/api/crud/mod.rs new file mode 100644 index 0000000..161229c --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/mod.rs @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! CRUD handler generation with utoipa OpenAPI annotations. +//! +//! This module generates production-ready REST API handlers for entities. +//! Each handler includes comprehensive OpenAPI documentation via +//! `#[utoipa::path]` attributes, enabling automatic Swagger UI generation. +//! +//! # Overview +//! +//! When you add `handlers` to your entity's API configuration: +//! +//! ```rust,ignore +//! #[entity(table = "users", api(tag = "Users", handlers))] +//! pub struct User { +//! #[id] +//! pub id: Uuid, +//! #[field(create, update, response)] +//! pub name: String, +//! } +//! ``` +//! +//! This module generates five handler functions: +//! +//! | Handler | HTTP | Path | Description | +//! |---------|------|------|-------------| +//! | `create_user` | POST | `/users` | Create new entity | +//! | `get_user` | GET | `/users/{id}` | Get entity by ID | +//! | `update_user` | PATCH | `/users/{id}` | Update entity fields | +//! | `delete_user` | DELETE | `/users/{id}` | Delete entity | +//! | `list_user` | GET | `/users` | List with pagination | +//! +//! # Selective Handler Generation +//! +//! You can generate only specific handlers: +//! +//! ```rust,ignore +//! // Only generate get and list handlers (read-only API) +//! #[entity(table = "users", api(tag = "Users", handlers(get, list)))] +//! pub struct User { ... } +//! ``` +//! +//! Available handler options: `create`, `get`, `update`, `delete`, `list`. +//! +//! # Security Integration +//! +//! Handlers automatically include security annotations when configured: +//! +//! ```rust,ignore +//! #[entity( +//! table = "users", +//! api(tag = "Users", security = "bearer", handlers) +//! )] +//! pub struct User { ... } +//! ``` +//! +//! This adds `401 Unauthorized` responses and security requirements to +//! the OpenAPI spec. +//! +//! # Generated Code Structure +//! +//! Each handler follows this pattern: +//! +//! ```rust,ignore +//! /// Create a new User. +//! /// +//! /// # Responses +//! /// +//! /// - `201 Created` - User created successfully +//! /// - `400 Bad Request` - Invalid request data +//! /// - `401 Unauthorized` - Authentication required +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! post, +//! path = "/users", +//! tag = "Users", +//! request_body(content = CreateUserRequest, description = "..."), +//! responses( +//! (status = 201, description = "User created", body = UserResponse), +//! (status = 400, description = "Invalid request data"), +//! (status = 401, description = "Authentication required"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn create_user( +//! State(repo): State>, +//! Json(dto): Json, +//! ) -> AppResult<(StatusCode, Json)> +//! where +//! R: UserRepository + 'static, +//! { ... } +//! ``` +//! +//! # Module Structure +//! +//! ```text +//! crud/ +//! ├── mod.rs — Main generate() function and re-exports +//! ├── helpers.rs — Path building and attribute helpers +//! ├── create.rs — POST handler generation +//! ├── get.rs — GET by ID handler generation +//! ├── update.rs — PATCH handler generation +//! ├── delete.rs — DELETE handler generation +//! ├── list.rs — GET collection handler generation +//! └── tests.rs — Unit tests +//! ``` +//! +//! # Error Handling +//! +//! All handlers use `masterror::AppResult` for consistent error responses: +//! +//! - `AppError::internal(...)` for database/server errors (500) +//! - `AppError::not_found(...)` for missing entities (404) +//! - Validation errors return 400 Bad Request +//! +//! # Integration with Repository +//! +//! Handlers are generic over the repository trait: +//! +//! ```rust,ignore +//! // In your application: +//! let pool = Arc::new(PgPool::connect(url).await?); +//! +//! let app = Router::new() +//! .route("/users", post(create_user::).get(list_user::)) +//! .route("/users/:id", get(get_user::) +//! .patch(update_user::) +//! .delete(delete_user::)) +//! .with_state(pool); +//! ``` + +mod create; +mod delete; +mod get; +mod helpers; +mod list; +mod update; + +use create::generate_create_handler; +use delete::generate_delete_handler; +use get::generate_get_handler; +#[cfg(test)] +pub use helpers::{build_collection_path, build_item_path}; +use list::generate_list_handler; +use proc_macro2::TokenStream; +use quote::quote; +use update::generate_update_handler; + +use crate::entity::parse::EntityDef; + +/// Generates all CRUD handler functions based on entity configuration. +/// +/// This is the main entry point for CRUD handler generation. It examines +/// the entity's API configuration and generates handlers for each enabled +/// operation. +/// +/// # Generation Process +/// +/// 1. **Check Configuration**: Reads `api(handlers(...))` from entity +/// 2. **Filter Handlers**: Only generates handlers that are enabled +/// 3. **Combine Output**: Merges all handler code into single TokenStream +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition with API configuration +/// +/// # Returns +/// +/// A `TokenStream` containing all generated handler functions, or an empty +/// stream if no handlers are enabled. +/// +/// # Handler Generation +/// +/// | Config | Handler Generated | +/// |--------|-------------------| +/// | `handlers` | All 5 handlers | +/// | `handlers(create, get)` | Only create and get | +/// | `handlers(list)` | Only list | +/// | No `handlers` | Nothing (empty stream) | +/// +/// # Example Usage +/// +/// ```rust,ignore +/// // In the main derive macro: +/// let crud_handlers = crud::generate(&entity); +/// +/// quote! { +/// #crud_handlers +/// // ... other generated code +/// } +/// ``` +/// +/// # Generated Functions +/// +/// For entity `User` with all handlers enabled: +/// +/// - `create_user` - POST /users +/// - `get_user` - GET /users/{id} +/// - `update_user` - PATCH /users/{id} +/// - `delete_user` - DELETE /users/{id} +/// - `list_user` - GET /users +/// +/// Each function is generic over `R: UserRepository + 'static`. +pub fn generate(entity: &EntityDef) -> TokenStream { + if !entity.api_config().has_handlers() { + return TokenStream::new(); + } + + let handlers = entity.api_config().handlers(); + + let create = if handlers.create { + generate_create_handler(entity) + } else { + TokenStream::new() + }; + let get = if handlers.get { + generate_get_handler(entity) + } else { + TokenStream::new() + }; + let update = if handlers.update { + generate_update_handler(entity) + } else { + TokenStream::new() + }; + let delete = if handlers.delete { + generate_delete_handler(entity) + } else { + TokenStream::new() + }; + let list = if handlers.list { + generate_list_handler(entity) + } else { + TokenStream::new() + }; + + quote! { + #create + #get + #update + #delete + #list + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/entity-derive-impl/src/entity/api/crud/tests.rs b/crates/entity-derive-impl/src/entity/api/crud/tests.rs new file mode 100644 index 0000000..3c1979a --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/tests.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Tests for CRUD handler generation. + +use super::*; + +fn create_test_entity() -> EntityDef { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + EntityDef::from_derive_input(&input).unwrap() +} + +#[test] +fn collection_path_format() { + let entity = create_test_entity(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); +} + +#[test] +fn item_path_format() { + let entity = create_test_entity(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); +} + +#[test] +fn generates_handlers_when_enabled() { + let entity = create_test_entity(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("create_user")); + assert!(output.contains("get_user")); + assert!(output.contains("update_user")); + assert!(output.contains("delete_user")); + assert!(output.contains("list_user")); +} + +#[test] +fn no_handlers_when_disabled() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + assert!(tokens.is_empty()); +} diff --git a/crates/entity-derive-impl/src/entity/api/crud/update.rs b/crates/entity-derive-impl/src/entity/api/crud/update.rs new file mode 100644 index 0000000..95d8345 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/update.rs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Update handler generation. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// Generate the update handler. +pub fn generate_update_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + let repo_trait = entity.ident_with("", "Repository"); + let has_security = api_config.security.is_some(); + + let handler_name = format_ident!("update_{}", entity_name_str.to_case(Case::Snake)); + let update_dto = entity.ident_with("Update", "Request"); + let response_dto = entity.ident_with("", "Response"); + + let path = build_item_path(entity); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = build_security_attr(entity); + let deprecated_attr = build_deprecated_attr(entity); + + let id_desc = format!("{} unique identifier", entity_name); + let request_body_desc = format!("Fields to update for {}", entity_name); + let success_desc = format!("{} updated successfully", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let utoipa_attr = if has_security { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 401, description = "Authentication required"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), + responses( + (status = 200, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), + (status = 404, description = #not_found_desc), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + }; + + let doc = format!( + "Update {} by ID.\n\n\ + # Responses\n\n\ + - `200 OK` - {} updated successfully\n\ + - `400 Bad Request` - Invalid request data\n\ + {}\ + - `404 Not Found` - {} not found\n\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + }, + entity_name + ); + + quote! { + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path<#id_type>, + axum::extract::Json(dto): axum::extract::Json<#update_dto>, + ) -> masterror::AppResult> + where + R: #repo_trait + 'static, + { + let entity = repo + .update(id, dto) + .await + .map_err(|e| masterror::AppError::internal(e.to_string()))?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs index c0e86bc..eb85b38 100644 --- a/crates/entity-derive-impl/src/entity/api/handlers.rs +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -72,58 +72,37 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { let entity_name_str = entity.name_str(); let api_config = entity.api_config(); - // Handler function name: register_user, update_email_user let handler_name = handler_function_name(entity, cmd); let handler_method = cmd.handler_method_name(); - - // Command struct name: RegisterUser, UpdateEmailUser let command_struct = cmd.struct_name(&entity_name_str); - - // Handler trait name: UserCommandHandler let handler_trait = format_ident!("{}CommandHandler", entity_name); - - // Build the path for OpenAPI let path = build_path(entity, cmd); - - // HTTP method based on command kind let http_method = http_method_for_command(cmd); let http_method_ident = format_ident!("{}", http_method); - - // Tag for OpenAPI grouping let tag = api_config.tag_or_default(&entity_name_str); - // Security configuration - // Priority: command-level override > entity-level public list > entity-level - // default let security_attr = if cmd.is_public() { - // Command explicitly marked as public quote! {} } else if let Some(cmd_security) = cmd.security() { - // Command has explicit security override let security_name = security_scheme_name(cmd_security); quote! { security(#security_name = []) } } else if api_config.is_public_command(&cmd.name.to_string()) { - // Command is in entity-level public list quote! {} } else if let Some(security) = &api_config.security { - // Use entity-level default security let security_name = security_scheme_name(security); quote! { security(#security_name = []) } } else { quote! {} }; - // Determine response type let (response_type, response_body) = response_type_for_command(entity, cmd); - // Deprecated flag from api config let deprecated_attr = if api_config.is_deprecated() { quote! { , deprecated = true } } else { quote! {} }; - // Build utoipa path attribute let utoipa_attr = if security_attr.is_empty() { quote! { #[utoipa::path( @@ -158,7 +137,6 @@ fn generate_handler(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { } }; - // Generate handler based on whether it requires ID if cmd.requires_id { generate_handler_with_id( entity, diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs deleted file mode 100644 index c9193b9..0000000 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ /dev/null @@ -1,922 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 RAprogramm -// SPDX-License-Identifier: MIT - -//! OpenAPI struct generation for utoipa 5.x. -//! -//! Generates a struct that implements `utoipa::OpenApi` for Swagger UI -//! integration, with security schemes and paths added via the `Modify` trait. -//! -//! # Generated Code -//! -//! For `User` entity with handlers and security: -//! -//! ```rust,ignore -//! /// OpenAPI modifier for User entity. -//! struct UserApiModifier; -//! -//! impl utoipa::Modify for UserApiModifier { -//! fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { -//! // Add security schemes -//! // Add CRUD paths with documentation -//! } -//! } -//! -//! /// OpenAPI documentation for User entity endpoints. -//! #[derive(utoipa::OpenApi)] -//! #[openapi( -//! components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), -//! modifiers(&UserApiModifier), -//! tags((name = "Users", description = "User management")) -//! )] -//! pub struct UserApi; -//! ``` - -use convert_case::{Case, Casing}; -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; - -use crate::entity::parse::{CommandDef, EntityDef}; - -/// Generate the OpenAPI struct with modifier. -pub fn generate(entity: &EntityDef) -> TokenStream { - let has_crud = entity.api_config().has_handlers(); - let has_commands = !entity.command_defs().is_empty(); - - if !has_crud && !has_commands { - return TokenStream::new(); - } - - let vis = &entity.vis; - let entity_name = entity.name(); - let api_config = entity.api_config(); - - let api_struct = format_ident!("{}Api", entity_name); - let modifier_struct = format_ident!("{}ApiModifier", entity_name); - - let tag = api_config.tag_or_default(&entity.name_str()); - let tag_description = api_config - .tag_description - .clone() - .or_else(|| entity.doc().map(String::from)) - .unwrap_or_else(|| format!("{} management", entity_name)); - - let schema_types = generate_all_schema_types(entity); - let modifier_impl = generate_modifier(entity, &modifier_struct); - - let doc = format!( - "OpenAPI documentation for {} entity endpoints.\n\n\ - # Usage\n\n\ - ```rust,ignore\n\ - use utoipa::OpenApi;\n\ - let openapi = {}::openapi();\n\ - ```", - entity_name, api_struct - ); - - quote! { - #modifier_impl - - #[doc = #doc] - #[derive(utoipa::OpenApi)] - #[openapi( - components(schemas(#schema_types)), - modifiers(&#modifier_struct), - tags((name = #tag, description = #tag_description)) - )] - #vis struct #api_struct; - } -} - -/// Generate all schema types (DTOs, commands). -/// -/// Only registers schemas for enabled handlers to keep OpenAPI spec clean. -fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { - let entity_name_str = entity.name_str(); - let mut types: Vec = Vec::new(); - - // CRUD DTOs - only include schemas for enabled handlers - let handlers = entity.api_config().handlers(); - if handlers.any() { - // Response is always needed (for get, list, create, update responses) - let response = entity.ident_with("", "Response"); - types.push(quote! { #response }); - - // CreateRequest only if create handler is enabled - if handlers.create { - let create = entity.ident_with("Create", "Request"); - types.push(quote! { #create }); - } - - // UpdateRequest only if update handler is enabled - if handlers.update { - let update = entity.ident_with("Update", "Request"); - types.push(quote! { #update }); - } - } - - // Command structs - for cmd in entity.command_defs() { - let cmd_struct = cmd.struct_name(&entity_name_str); - types.push(quote! { #cmd_struct }); - } - - quote! { #(#types),* } -} - -/// Generate the modifier struct with Modify implementation. -/// -/// This adds security schemes, common schemas, CRUD paths, and info to the -/// OpenAPI spec. -fn generate_modifier(entity: &EntityDef, modifier_name: &syn::Ident) -> TokenStream { - let entity_name = entity.name(); - let api_config = entity.api_config(); - - let info_code = generate_info_code(entity); - let security_code = generate_security_code(api_config.security.as_deref()); - let common_schemas_code = if api_config.has_handlers() { - generate_common_schemas_code() - } else { - TokenStream::new() - }; - let paths_code = if api_config.has_handlers() { - generate_paths_code(entity) - } else { - TokenStream::new() - }; - - let doc = format!("OpenAPI modifier for {} entity.", entity_name); - - quote! { - #[doc = #doc] - struct #modifier_name; - - impl utoipa::Modify for #modifier_name { - fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - use utoipa::openapi::*; - - #info_code - #security_code - #common_schemas_code - #paths_code - } - } - } -} - -/// Generate code to configure OpenAPI info section. -/// -/// Sets title, description, version, license, and contact information. -fn generate_info_code(entity: &EntityDef) -> TokenStream { - let api_config = entity.api_config(); - - // Title: use configured or generate default - let title_code = if let Some(ref title) = api_config.title { - quote! { openapi.info.title = #title.to_string(); } - } else { - TokenStream::new() - }; - - // Description - let description_code = if let Some(ref description) = api_config.description { - quote! { openapi.info.description = Some(#description.to_string()); } - } else if let Some(doc) = entity.doc() { - // Use entity doc comment if no description configured - quote! { openapi.info.description = Some(#doc.to_string()); } - } else { - TokenStream::new() - }; - - // API Version - let version_code = if let Some(ref version) = api_config.api_version { - quote! { openapi.info.version = #version.to_string(); } - } else { - TokenStream::new() - }; - - // License - let license_code = match (&api_config.license, &api_config.license_url) { - (Some(name), Some(url)) => { - quote! { - openapi.info.license = Some( - info::LicenseBuilder::new() - .name(#name) - .url(Some(#url)) - .build() - ); - } - } - (Some(name), None) => { - quote! { - openapi.info.license = Some( - info::LicenseBuilder::new() - .name(#name) - .build() - ); - } - } - _ => TokenStream::new() - }; - - // Contact - let has_contact = api_config.contact_name.is_some() - || api_config.contact_email.is_some() - || api_config.contact_url.is_some(); - - let contact_code = if has_contact { - let name = api_config.contact_name.as_deref().unwrap_or(""); - let email = api_config.contact_email.as_deref(); - let url = api_config.contact_url.as_deref(); - - let email_setter = if let Some(e) = email { - quote! { .email(Some(#e)) } - } else { - TokenStream::new() - }; - - let url_setter = if let Some(u) = url { - quote! { .url(Some(#u)) } - } else { - TokenStream::new() - }; - - quote! { - openapi.info.contact = Some( - info::ContactBuilder::new() - .name(Some(#name)) - #email_setter - #url_setter - .build() - ); - } - } else { - TokenStream::new() - }; - - // Deprecated flag - let deprecated_code = if api_config.is_deprecated() { - let version = api_config.deprecated_in.as_deref().unwrap_or("unknown"); - let msg = format!("Deprecated since {}", version); - quote! { - // Mark in description that API is deprecated - if let Some(ref desc) = openapi.info.description { - openapi.info.description = Some(format!("**DEPRECATED**: {}\n\n{}", #msg, desc)); - } else { - openapi.info.description = Some(format!("**DEPRECATED**: {}", #msg)); - } - } - } else { - TokenStream::new() - }; - - quote! { - #title_code - #description_code - #version_code - #license_code - #contact_code - #deprecated_code - } -} - -/// Generate common schemas (ErrorResponse, PaginationQuery) for the OpenAPI -/// spec. -fn generate_common_schemas_code() -> TokenStream { - quote! { - // Add ErrorResponse schema for error responses - if let Some(components) = openapi.components.as_mut() { - // ErrorResponse schema (RFC 7807 Problem Details) - let error_schema = schema::ObjectBuilder::new() - .schema_type(schema::Type::Object) - .title(Some("ErrorResponse")) - .description(Some("Error response following RFC 7807 Problem Details")) - .property("type", schema::ObjectBuilder::new() - .schema_type(schema::Type::String) - .description(Some("A URI reference that identifies the problem type")) - .example(Some(serde_json::json!("https://errors.example.com/not-found"))) - .build()) - .required("type") - .property("title", schema::ObjectBuilder::new() - .schema_type(schema::Type::String) - .description(Some("A short, human-readable summary of the problem")) - .example(Some(serde_json::json!("Resource not found"))) - .build()) - .required("title") - .property("status", schema::ObjectBuilder::new() - .schema_type(schema::Type::Integer) - .description(Some("HTTP status code")) - .example(Some(serde_json::json!(404))) - .build()) - .required("status") - .property("detail", schema::ObjectBuilder::new() - .schema_type(schema::Type::String) - .description(Some("A human-readable explanation specific to this occurrence")) - .example(Some(serde_json::json!("User with ID '123' was not found"))) - .build()) - .property("code", schema::ObjectBuilder::new() - .schema_type(schema::Type::String) - .description(Some("Application-specific error code")) - .example(Some(serde_json::json!("NOT_FOUND"))) - .build()) - .build(); - - components.schemas.insert("ErrorResponse".to_string(), error_schema.into()); - - // PaginationQuery schema - let pagination_schema = schema::ObjectBuilder::new() - .schema_type(schema::Type::Object) - .title(Some("PaginationQuery")) - .description(Some("Query parameters for paginated list endpoints")) - .property("limit", schema::ObjectBuilder::new() - .schema_type(schema::Type::Integer) - .description(Some("Maximum number of items to return")) - .default(Some(serde_json::json!(100))) - .minimum(Some(1.0)) - .maximum(Some(1000.0)) - .build()) - .property("offset", schema::ObjectBuilder::new() - .schema_type(schema::Type::Integer) - .description(Some("Number of items to skip for pagination")) - .default(Some(serde_json::json!(0))) - .minimum(Some(0.0)) - .build()) - .build(); - - components.schemas.insert("PaginationQuery".to_string(), pagination_schema.into()); - } - } -} - -/// Generate security scheme code for the Modify implementation. -fn generate_security_code(security: Option<&str>) -> TokenStream { - let Some(security) = security else { - return TokenStream::new(); - }; - - let (scheme_name, scheme_impl) = match security { - "cookie" => ( - "cookieAuth", - quote! { - security::SecurityScheme::ApiKey( - security::ApiKey::Cookie( - security::ApiKeyValue::with_description( - "token", - "JWT token stored in HTTP-only cookie" - ) - ) - ) - } - ), - "bearer" => ( - "bearerAuth", - quote! { - security::SecurityScheme::Http( - security::HttpBuilder::new() - .scheme(security::HttpAuthScheme::Bearer) - .bearer_format("JWT") - .description(Some("JWT token in Authorization header")) - .build() - ) - } - ), - "api_key" => ( - "apiKey", - quote! { - security::SecurityScheme::ApiKey( - security::ApiKey::Header( - security::ApiKeyValue::with_description( - "X-API-Key", - "API key for service-to-service authentication" - ) - ) - ) - } - ), - _ => return TokenStream::new() - }; - - quote! { - if let Some(components) = openapi.components.as_mut() { - components.add_security_scheme(#scheme_name, #scheme_impl); - } - } -} - -/// Generate code to add CRUD paths to OpenAPI. -/// -/// Only generates paths for enabled handlers based on `HandlerConfig`. -fn generate_paths_code(entity: &EntityDef) -> TokenStream { - let api_config = entity.api_config(); - let handlers = api_config.handlers(); - let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let id_field = entity.id_field(); - let id_type = &id_field.ty; - - let tag = api_config.tag_or_default(&entity_name_str); - let collection_path = build_collection_path(entity); - let item_path = build_item_path(entity); - - let response_schema = entity.ident_with("", "Response"); - let create_schema = entity.ident_with("Create", "Request"); - let update_schema = entity.ident_with("Update", "Request"); - - // Schema names for $ref - just the name, not full path - let response_ref = response_schema.to_string(); - let create_ref = create_schema.to_string(); - let update_ref = update_schema.to_string(); - - // Security requirement - let security_req = if let Some(security) = &api_config.security { - let scheme_name = match security.as_str() { - "cookie" => "cookieAuth", - "bearer" => "bearerAuth", - "api_key" => "apiKey", - _ => "cookieAuth" - }; - quote! { - Some(vec![security::SecurityRequirement::new::<_, _, &str>(#scheme_name, [])]) - } - } else { - quote! { None } - }; - - // ID parameter type (only needed for item paths) - let needs_id_param = handlers.get || handlers.update || handlers.delete; - let id_type_str = quote!(#id_type).to_string().replace(' ', ""); - let id_schema_type = if id_type_str.contains("Uuid") { - quote! { - ObjectBuilder::new() - .schema_type(schema::Type::String) - .format(Some(schema::SchemaFormat::Custom("uuid".into()))) - .build() - } - } else { - quote! { - ObjectBuilder::new() - .schema_type(schema::Type::String) - .build() - } - }; - - let create_op_id = format!("create_{}", entity_name_str.to_case(Case::Snake)); - let get_op_id = format!("get_{}", entity_name_str.to_case(Case::Snake)); - let update_op_id = format!("update_{}", entity_name_str.to_case(Case::Snake)); - let delete_op_id = format!("delete_{}", entity_name_str.to_case(Case::Snake)); - let list_op_id = format!("list_{}", entity_name_str.to_case(Case::Snake)); - - let create_summary = format!("Create a new {}", entity_name); - let get_summary = format!("Get {} by ID", entity_name); - let update_summary = format!("Update {} by ID", entity_name); - let delete_summary = format!("Delete {} by ID", entity_name); - let list_summary = format!("List all {}", entity_name); - - let create_desc = format!("Creates a new {} entity", entity_name); - let get_desc = format!("Retrieves a {} by its unique identifier", entity_name); - let update_desc = format!("Updates an existing {} by ID", entity_name); - let delete_desc = format!("Deletes a {} by ID", entity_name); - let list_desc = format!("Returns a paginated list of {} entities", entity_name); - - let id_param_desc = format!("{} unique identifier", entity_name); - let created_desc = format!("{} created successfully", entity_name); - let found_desc = format!("{} found", entity_name); - let updated_desc = format!("{} updated successfully", entity_name); - let deleted_desc = format!("{} deleted successfully", entity_name); - let list_desc_resp = format!("List of {} entities", entity_name); - let not_found_desc = format!("{} not found", entity_name); - - // Common code: error helper, params, security - let common_code = quote! { - // Helper to build error response with ErrorResponse schema - let error_response = |desc: &str| -> response::Response { - response::ResponseBuilder::new() - .description(desc) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name("ErrorResponse"))) - .build() - ) - .build() - }; - - // Security requirements - let security_req: Option> = #security_req; - }; - - // ID parameter (only if needed) - let id_param_code = if needs_id_param { - quote! { - let id_param = path::ParameterBuilder::new() - .name("id") - .parameter_in(path::ParameterIn::Path) - .required(utoipa::openapi::Required::True) - .description(Some(#id_param_desc)) - .schema(Some(#id_schema_type)) - .build(); - } - } else { - TokenStream::new() - }; - - // CREATE handler - let create_code = if handlers.create { - quote! { - let create_op = { - let mut op = path::OperationBuilder::new() - .operation_id(Some(#create_op_id)) - .tag(#tag) - .summary(Some(#create_summary)) - .description(Some(#create_desc)) - .request_body(Some( - request_body::RequestBodyBuilder::new() - .description(Some("Request body")) - .required(Some(utoipa::openapi::Required::True)) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name(#create_ref))) - .build() - ) - .build() - )) - .response("201", - response::ResponseBuilder::new() - .description(#created_desc) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name(#response_ref))) - .build() - ) - .build() - ) - .response("400", error_response("Invalid request data")) - .response("500", error_response("Internal server error")); - if let Some(ref sec) = security_req { - op = op.securities(Some(sec.clone())) - .response("401", error_response("Authentication required")); - } - op.build() - }; - openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Post], create_op); - } - } else { - TokenStream::new() - }; - - // LIST handler - let list_code = if handlers.list { - quote! { - let limit_param = path::ParameterBuilder::new() - .name("limit") - .parameter_in(path::ParameterIn::Query) - .required(utoipa::openapi::Required::False) - .description(Some("Maximum number of items to return (default: 100)")) - .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) - .build(); - - let offset_param = path::ParameterBuilder::new() - .name("offset") - .parameter_in(path::ParameterIn::Query) - .required(utoipa::openapi::Required::False) - .description(Some("Number of items to skip for pagination")) - .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) - .build(); - - let list_op = { - let mut op = path::OperationBuilder::new() - .operation_id(Some(#list_op_id)) - .tag(#tag) - .summary(Some(#list_summary)) - .description(Some(#list_desc)) - .parameter(limit_param) - .parameter(offset_param) - .response("200", - response::ResponseBuilder::new() - .description(#list_desc_resp) - .content("application/json", - content::ContentBuilder::new() - .schema(Some( - schema::ArrayBuilder::new() - .items(Ref::from_schema_name(#response_ref)) - .build() - )) - .build() - ) - .build() - ) - .response("500", error_response("Internal server error")); - if let Some(ref sec) = security_req { - op = op.securities(Some(sec.clone())) - .response("401", error_response("Authentication required")); - } - op.build() - }; - openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Get], list_op); - } - } else { - TokenStream::new() - }; - - // GET handler - let get_code = if handlers.get { - quote! { - let get_op = { - let mut op = path::OperationBuilder::new() - .operation_id(Some(#get_op_id)) - .tag(#tag) - .summary(Some(#get_summary)) - .description(Some(#get_desc)) - .parameter(id_param.clone()) - .response("200", - response::ResponseBuilder::new() - .description(#found_desc) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name(#response_ref))) - .build() - ) - .build() - ) - .response("404", error_response(#not_found_desc)) - .response("500", error_response("Internal server error")); - if let Some(ref sec) = security_req { - op = op.securities(Some(sec.clone())) - .response("401", error_response("Authentication required")); - } - op.build() - }; - openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Get], get_op); - } - } else { - TokenStream::new() - }; - - // UPDATE handler - let update_code = if handlers.update { - quote! { - let update_op = { - let mut op = path::OperationBuilder::new() - .operation_id(Some(#update_op_id)) - .tag(#tag) - .summary(Some(#update_summary)) - .description(Some(#update_desc)) - .parameter(id_param.clone()) - .request_body(Some( - request_body::RequestBodyBuilder::new() - .description(Some("Fields to update")) - .required(Some(utoipa::openapi::Required::True)) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name(#update_ref))) - .build() - ) - .build() - )) - .response("200", - response::ResponseBuilder::new() - .description(#updated_desc) - .content("application/json", - content::ContentBuilder::new() - .schema(Some(Ref::from_schema_name(#response_ref))) - .build() - ) - .build() - ) - .response("400", error_response("Invalid request data")) - .response("404", error_response(#not_found_desc)) - .response("500", error_response("Internal server error")); - if let Some(ref sec) = security_req { - op = op.securities(Some(sec.clone())) - .response("401", error_response("Authentication required")); - } - op.build() - }; - openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Patch], update_op); - } - } else { - TokenStream::new() - }; - - // DELETE handler - let delete_code = if handlers.delete { - quote! { - let delete_op = { - let mut op = path::OperationBuilder::new() - .operation_id(Some(#delete_op_id)) - .tag(#tag) - .summary(Some(#delete_summary)) - .description(Some(#delete_desc)) - .parameter(id_param.clone()) - .response("204", - response::ResponseBuilder::new() - .description(#deleted_desc) - .build() - ) - .response("404", error_response(#not_found_desc)) - .response("500", error_response("Internal server error")); - if let Some(ref sec) = security_req { - op = op.securities(Some(sec.clone())) - .response("401", error_response("Authentication required")); - } - op.build() - }; - openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Delete], delete_op); - } - } else { - TokenStream::new() - }; - - quote! { - #common_code - #id_param_code - #create_code - #list_code - #get_code - #update_code - #delete_code - } -} - -/// Build the collection path (e.g., `/users`). -fn build_collection_path(entity: &EntityDef) -> String { - let api_config = entity.api_config(); - let prefix = api_config.full_path_prefix(); - let entity_path = entity.name_str().to_case(Case::Kebab); - - let path = format!("{}/{}s", prefix, entity_path); - path.replace("//", "/") -} - -/// Build the item path (e.g., `/users/{id}`). -fn build_item_path(entity: &EntityDef) -> String { - let collection = build_collection_path(entity); - format!("{}/{{id}}", collection) -} - -/// Get command handler function name. -#[allow(dead_code)] -fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { - let entity_snake = entity.name_str().to_case(Case::Snake); - let cmd_snake = cmd.name.to_string().to_case(Case::Snake); - format_ident!("{}_{}", cmd_snake, entity_snake) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn generate_crud_only() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - #[field(create, update, response)] - pub name: String, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - assert!(output.contains("UserApi")); - assert!(output.contains("UserApiModifier")); - assert!(output.contains("UserResponse")); - assert!(output.contains("CreateUserRequest")); - } - - #[test] - fn generate_with_security() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", security = "bearer", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - assert!(output.contains("UserApiModifier")); - assert!(output.contains("bearerAuth")); - } - - #[test] - fn generate_cookie_security() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - assert!(output.contains("cookieAuth")); - } - - #[test] - fn no_api_when_disabled() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users")] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - assert!(tokens.is_empty()); - } - - #[test] - fn collection_path_format() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let path = build_collection_path(&entity); - assert_eq!(path, "/users"); - } - - #[test] - fn item_path_format() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let path = build_item_path(&entity); - assert_eq!(path, "/users/{id}"); - } - - #[test] - fn selective_handlers_schemas_get_list_only() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers(get, list)))] - pub struct User { - #[id] - pub id: uuid::Uuid, - #[field(create, update, response)] - pub name: String, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - // Response should be included (needed for get/list) - assert!(output.contains("UserResponse")); - // CreateRequest should NOT be included (no create handler) - assert!(!output.contains("CreateUserRequest")); - // UpdateRequest should NOT be included (no update handler) - assert!(!output.contains("UpdateUserRequest")); - } - - #[test] - fn selective_handlers_schemas_create_only() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers(create)))] - pub struct User { - #[id] - pub id: uuid::Uuid, - #[field(create, update, response)] - pub name: String, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - // Response should be included (create returns response) - assert!(output.contains("UserResponse")); - // CreateRequest should be included - assert!(output.contains("CreateUserRequest")); - // UpdateRequest should NOT be included - assert!(!output.contains("UpdateUserRequest")); - } - - #[test] - fn selective_handlers_all_schemas() { - let input: syn::DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users", handlers(create, update)))] - pub struct User { - #[id] - pub id: uuid::Uuid, - #[field(create, update, response)] - pub name: String, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let tokens = generate(&entity); - let output = tokens.to_string(); - // All schemas should be included - assert!(output.contains("UserResponse")); - assert!(output.contains("CreateUserRequest")); - assert!(output.contains("UpdateUserRequest")); - } -} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/info.rs b/crates/entity-derive-impl/src/entity/api/openapi/info.rs new file mode 100644 index 0000000..fa3d33a --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/info.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI info section generation. +//! +//! Generates code to configure the OpenAPI info section including title, +//! description, version, license, contact information, and deprecation status. + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::entity::parse::EntityDef; + +/// Generate code to configure OpenAPI info section. +/// +/// Sets title, description, version, license, and contact information. +pub fn generate_info_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + + let title_code = if let Some(ref title) = api_config.title { + quote! { openapi.info.title = #title.to_string(); } + } else { + TokenStream::new() + }; + + let description_code = if let Some(ref description) = api_config.description { + quote! { openapi.info.description = Some(#description.to_string()); } + } else if let Some(doc) = entity.doc() { + quote! { openapi.info.description = Some(#doc.to_string()); } + } else { + TokenStream::new() + }; + + let version_code = if let Some(ref version) = api_config.api_version { + quote! { openapi.info.version = #version.to_string(); } + } else { + TokenStream::new() + }; + + let license_code = match (&api_config.license, &api_config.license_url) { + (Some(name), Some(url)) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .url(Some(#url)) + .build() + ); + } + } + (Some(name), None) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .build() + ); + } + } + _ => TokenStream::new() + }; + + let has_contact = api_config.contact_name.is_some() + || api_config.contact_email.is_some() + || api_config.contact_url.is_some(); + + let contact_code = if has_contact { + let name = api_config.contact_name.as_deref().unwrap_or(""); + let email = api_config.contact_email.as_deref(); + let url = api_config.contact_url.as_deref(); + + let email_setter = if let Some(e) = email { + quote! { .email(Some(#e)) } + } else { + TokenStream::new() + }; + + let url_setter = if let Some(u) = url { + quote! { .url(Some(#u)) } + } else { + TokenStream::new() + }; + + quote! { + openapi.info.contact = Some( + info::ContactBuilder::new() + .name(Some(#name)) + #email_setter + #url_setter + .build() + ); + } + } else { + TokenStream::new() + }; + + let deprecated_code = if api_config.is_deprecated() { + let version = api_config.deprecated_in.as_deref().unwrap_or("unknown"); + let msg = format!("Deprecated since {}", version); + quote! { + if let Some(ref desc) = openapi.info.description { + openapi.info.description = Some(format!("**DEPRECATED**: {}\n\n{}", #msg, desc)); + } else { + openapi.info.description = Some(format!("**DEPRECATED**: {}", #msg)); + } + } + } else { + TokenStream::new() + }; + + quote! { + #title_code + #description_code + #version_code + #license_code + #contact_code + #deprecated_code + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/mod.rs b/crates/entity-derive-impl/src/entity/api/openapi/mod.rs new file mode 100644 index 0000000..5f02022 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/mod.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI struct generation for utoipa 5.x. +//! +//! Generates a struct that implements `utoipa::OpenApi` for Swagger UI +//! integration, with security schemes and paths added via the `Modify` trait. +//! +//! # Generated Code +//! +//! For `User` entity with handlers and security: +//! +//! ```rust,ignore +//! /// OpenAPI modifier for User entity. +//! struct UserApiModifier; +//! +//! impl utoipa::Modify for UserApiModifier { +//! fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { +//! // Add security schemes +//! // Add CRUD paths with documentation +//! } +//! } +//! +//! /// OpenAPI documentation for User entity endpoints. +//! #[derive(utoipa::OpenApi)] +//! #[openapi( +//! components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), +//! modifiers(&UserApiModifier), +//! tags((name = "Users", description = "User management")) +//! )] +//! pub struct UserApi; +//! ``` + +mod info; +mod paths; +mod schemas; +mod security; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +#[cfg(test)] +pub use self::paths::{build_collection_path, build_item_path}; +pub use self::{ + info::generate_info_code, + paths::generate_paths_code, + schemas::{generate_all_schema_types, generate_common_schemas_code}, + security::generate_security_code +}; +use crate::entity::parse::EntityDef; + +/// Generate the OpenAPI struct with modifier. +pub fn generate(entity: &EntityDef) -> TokenStream { + let has_crud = entity.api_config().has_handlers(); + let has_commands = !entity.command_defs().is_empty(); + + if !has_crud && !has_commands { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + + let api_struct = format_ident!("{}Api", entity_name); + let modifier_struct = format_ident!("{}ApiModifier", entity_name); + + let tag = api_config.tag_or_default(&entity.name_str()); + let tag_description = api_config + .tag_description + .clone() + .or_else(|| entity.doc().map(String::from)) + .unwrap_or_else(|| format!("{} management", entity_name)); + + let schema_types = generate_all_schema_types(entity); + let modifier_impl = generate_modifier(entity, &modifier_struct); + + let doc = format!( + "OpenAPI documentation for {} entity endpoints.\n\n\ + # Usage\n\n\ + ```rust,ignore\n\ + use utoipa::OpenApi;\n\ + let openapi = {}::openapi();\n\ + ```", + entity_name, api_struct + ); + + quote! { + #modifier_impl + + #[doc = #doc] + #[derive(utoipa::OpenApi)] + #[openapi( + components(schemas(#schema_types)), + modifiers(&#modifier_struct), + tags((name = #tag, description = #tag_description)) + )] + #vis struct #api_struct; + } +} + +/// Generate the modifier struct with Modify implementation. +/// +/// This adds security schemes, common schemas, CRUD paths, and info to the +/// OpenAPI spec. +fn generate_modifier(entity: &EntityDef, modifier_name: &syn::Ident) -> TokenStream { + let entity_name = entity.name(); + let api_config = entity.api_config(); + + let info_code = generate_info_code(entity); + let security_code = generate_security_code(api_config.security.as_deref()); + let common_schemas_code = if api_config.has_handlers() { + generate_common_schemas_code() + } else { + TokenStream::new() + }; + let paths_code = if api_config.has_handlers() { + generate_paths_code(entity) + } else { + TokenStream::new() + }; + + let doc = format!("OpenAPI modifier for {} entity.", entity_name); + + quote! { + #[doc = #doc] + struct #modifier_name; + + impl utoipa::Modify for #modifier_name { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::*; + + #info_code + #security_code + #common_schemas_code + #paths_code + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/entity-derive-impl/src/entity/api/openapi/paths.rs b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs new file mode 100644 index 0000000..2d42565 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI paths generation. +//! +//! Generates CRUD path operations for the OpenAPI spec. + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::security::security_scheme_name; +use crate::entity::parse::{CommandDef, EntityDef}; + +/// Generate code to add CRUD paths to OpenAPI. +/// +/// Only generates paths for enabled handlers based on `HandlerConfig`. +pub fn generate_paths_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + let handlers = api_config.handlers(); + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + let tag = api_config.tag_or_default(&entity_name_str); + let collection_path = build_collection_path(entity); + let item_path = build_item_path(entity); + + let response_schema = entity.ident_with("", "Response"); + let create_schema = entity.ident_with("Create", "Request"); + let update_schema = entity.ident_with("Update", "Request"); + + let response_ref = response_schema.to_string(); + let create_ref = create_schema.to_string(); + let update_ref = update_schema.to_string(); + + let security_req = if let Some(security) = &api_config.security { + let scheme_name = security_scheme_name(security); + quote! { + Some(vec![security::SecurityRequirement::new::<_, _, &str>(#scheme_name, [])]) + } + } else { + quote! { None } + }; + + let needs_id_param = handlers.get || handlers.update || handlers.delete; + let id_type_str = quote!(#id_type).to_string().replace(' ', ""); + let id_schema_type = if id_type_str.contains("Uuid") { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .format(Some(schema::SchemaFormat::Custom("uuid".into()))) + .build() + } + } else { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .build() + } + }; + + let create_op_id = format!("create_{}", entity_name_str.to_case(Case::Snake)); + let get_op_id = format!("get_{}", entity_name_str.to_case(Case::Snake)); + let update_op_id = format!("update_{}", entity_name_str.to_case(Case::Snake)); + let delete_op_id = format!("delete_{}", entity_name_str.to_case(Case::Snake)); + let list_op_id = format!("list_{}", entity_name_str.to_case(Case::Snake)); + + let create_summary = format!("Create a new {}", entity_name); + let get_summary = format!("Get {} by ID", entity_name); + let update_summary = format!("Update {} by ID", entity_name); + let delete_summary = format!("Delete {} by ID", entity_name); + let list_summary = format!("List all {}", entity_name); + + let create_desc = format!("Creates a new {} entity", entity_name); + let get_desc = format!("Retrieves a {} by its unique identifier", entity_name); + let update_desc = format!("Updates an existing {} by ID", entity_name); + let delete_desc = format!("Deletes a {} by ID", entity_name); + let list_desc = format!("Returns a paginated list of {} entities", entity_name); + + let id_param_desc = format!("{} unique identifier", entity_name); + let created_desc = format!("{} created successfully", entity_name); + let found_desc = format!("{} found", entity_name); + let updated_desc = format!("{} updated successfully", entity_name); + let deleted_desc = format!("{} deleted successfully", entity_name); + let list_desc_resp = format!("List of {} entities", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + let common_code = quote! { + let error_response = |desc: &str| -> response::Response { + response::ResponseBuilder::new() + .description(desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name("ErrorResponse"))) + .build() + ) + .build() + }; + + let security_req: Option> = #security_req; + }; + + let id_param_code = if needs_id_param { + quote! { + let id_param = path::ParameterBuilder::new() + .name("id") + .parameter_in(path::ParameterIn::Path) + .required(utoipa::openapi::Required::True) + .description(Some(#id_param_desc)) + .schema(Some(#id_schema_type)) + .build(); + } + } else { + TokenStream::new() + }; + + let create_code = if handlers.create { + quote! { + let create_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#create_op_id)) + .tag(#tag) + .summary(Some(#create_summary)) + .description(Some(#create_desc)) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Request body")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#create_ref))) + .build() + ) + .build() + )) + .response("201", + response::ResponseBuilder::new() + .description(#created_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Post], create_op); + } + } else { + TokenStream::new() + }; + + let list_code = if handlers.list { + quote! { + let limit_param = path::ParameterBuilder::new() + .name("limit") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Maximum number of items to return (default: 100)")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let offset_param = path::ParameterBuilder::new() + .name("offset") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Number of items to skip for pagination")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let list_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#list_op_id)) + .tag(#tag) + .summary(Some(#list_summary)) + .description(Some(#list_desc)) + .parameter(limit_param) + .parameter(offset_param) + .response("200", + response::ResponseBuilder::new() + .description(#list_desc_resp) + .content("application/json", + content::ContentBuilder::new() + .schema(Some( + schema::ArrayBuilder::new() + .items(Ref::from_schema_name(#response_ref)) + .build() + )) + .build() + ) + .build() + ) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Get], list_op); + } + } else { + TokenStream::new() + }; + + let get_code = if handlers.get { + quote! { + let get_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#get_op_id)) + .tag(#tag) + .summary(Some(#get_summary)) + .description(Some(#get_desc)) + .parameter(id_param.clone()) + .response("200", + response::ResponseBuilder::new() + .description(#found_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Get], get_op); + } + } else { + TokenStream::new() + }; + + let update_code = if handlers.update { + quote! { + let update_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#update_op_id)) + .tag(#tag) + .summary(Some(#update_summary)) + .description(Some(#update_desc)) + .parameter(id_param.clone()) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Fields to update")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#update_ref))) + .build() + ) + .build() + )) + .response("200", + response::ResponseBuilder::new() + .description(#updated_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Patch], update_op); + } + } else { + TokenStream::new() + }; + + let delete_code = if handlers.delete { + quote! { + let delete_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#delete_op_id)) + .tag(#tag) + .summary(Some(#delete_summary)) + .description(Some(#delete_desc)) + .parameter(id_param.clone()) + .response("204", + response::ResponseBuilder::new() + .description(#deleted_desc) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Delete], delete_op); + } + } else { + TokenStream::new() + }; + + quote! { + #common_code + #id_param_code + #create_code + #list_code + #get_code + #update_code + #delete_code + } +} + +/// Build the collection path (e.g., `/users`). +pub fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Build the item path (e.g., `/users/{id}`). +pub fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Get command handler function name. +#[allow(dead_code)] +pub fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { + let entity_snake = entity.name_str().to_case(Case::Snake); + let cmd_snake = cmd.name.to_string().to_case(Case::Snake); + format_ident!("{}_{}", cmd_snake, entity_snake) +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs new file mode 100644 index 0000000..cc6d607 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI schema generation. +//! +//! Generates schema types for DTOs and common schemas like ErrorResponse +//! and PaginationQuery. + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::entity::parse::EntityDef; + +/// Generate all schema types (DTOs, commands). +/// +/// Only registers schemas for enabled handlers to keep OpenAPI spec clean. +pub fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { + let entity_name_str = entity.name_str(); + let mut types: Vec = Vec::new(); + + let handlers = entity.api_config().handlers(); + if handlers.any() { + let response = entity.ident_with("", "Response"); + types.push(quote! { #response }); + + if handlers.create { + let create = entity.ident_with("Create", "Request"); + types.push(quote! { #create }); + } + + if handlers.update { + let update = entity.ident_with("Update", "Request"); + types.push(quote! { #update }); + } + } + + for cmd in entity.command_defs() { + let cmd_struct = cmd.struct_name(&entity_name_str); + types.push(quote! { #cmd_struct }); + } + + quote! { #(#types),* } +} + +/// Generate common schemas (ErrorResponse, PaginationQuery) for the OpenAPI +/// spec. +pub fn generate_common_schemas_code() -> TokenStream { + quote! { + if let Some(components) = openapi.components.as_mut() { + let error_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("ErrorResponse")) + .description(Some("Error response following RFC 7807 Problem Details")) + .property("type", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A URI reference that identifies the problem type")) + .example(Some(serde_json::json!("https://errors.example.com/not-found"))) + .build()) + .required("type") + .property("title", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A short, human-readable summary of the problem")) + .example(Some(serde_json::json!("Resource not found"))) + .build()) + .required("title") + .property("status", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("HTTP status code")) + .example(Some(serde_json::json!(404))) + .build()) + .required("status") + .property("detail", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A human-readable explanation specific to this occurrence")) + .example(Some(serde_json::json!("User with ID '123' was not found"))) + .build()) + .property("code", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("Application-specific error code")) + .example(Some(serde_json::json!("NOT_FOUND"))) + .build()) + .build(); + + components.schemas.insert("ErrorResponse".to_string(), error_schema.into()); + + let pagination_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("PaginationQuery")) + .description(Some("Query parameters for paginated list endpoints")) + .property("limit", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Maximum number of items to return")) + .default(Some(serde_json::json!(100))) + .minimum(Some(1.0)) + .maximum(Some(1000.0)) + .build()) + .property("offset", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Number of items to skip for pagination")) + .default(Some(serde_json::json!(0))) + .minimum(Some(0.0)) + .build()) + .build(); + + components.schemas.insert("PaginationQuery".to_string(), pagination_schema.into()); + } + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/security.rs b/crates/entity-derive-impl/src/entity/api/openapi/security.rs new file mode 100644 index 0000000..a814fc3 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/security.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI security scheme generation. +//! +//! Generates security scheme code for cookie, bearer, and API key +//! authentication. + +use proc_macro2::TokenStream; +use quote::quote; + +/// Generate security scheme code for the Modify implementation. +pub fn generate_security_code(security: Option<&str>) -> TokenStream { + let Some(security) = security else { + return TokenStream::new(); + }; + + let (scheme_name, scheme_impl) = match security { + "cookie" => ( + "cookieAuth", + quote! { + security::SecurityScheme::ApiKey( + security::ApiKey::Cookie( + security::ApiKeyValue::with_description( + "token", + "JWT token stored in HTTP-only cookie" + ) + ) + ) + } + ), + "bearer" => ( + "bearerAuth", + quote! { + security::SecurityScheme::Http( + security::HttpBuilder::new() + .scheme(security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("JWT token in Authorization header")) + .build() + ) + } + ), + "api_key" => ( + "apiKey", + quote! { + security::SecurityScheme::ApiKey( + security::ApiKey::Header( + security::ApiKeyValue::with_description( + "X-API-Key", + "API key for service-to-service authentication" + ) + ) + ) + } + ), + _ => return TokenStream::new() + }; + + quote! { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme(#scheme_name, #scheme_impl); + } + } +} + +/// Get the security scheme name for a given security type. +pub fn security_scheme_name(security: &str) -> &'static str { + match security { + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/tests.rs b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs new file mode 100644 index 0000000..96e1845 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Tests for OpenAPI generation. + +use super::*; + +#[test] +fn generate_crud_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserApi")); + assert!(output.contains("UserApiModifier")); + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); +} + +#[test] +fn generate_with_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "bearer", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserApiModifier")); + assert!(output.contains("bearerAuth")); +} + +#[test] +fn generate_cookie_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("cookieAuth")); +} + +#[test] +fn no_api_when_disabled() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + assert!(tokens.is_empty()); +} + +#[test] +fn collection_path_format() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); +} + +#[test] +fn item_path_format() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); +} + +#[test] +fn selective_handlers_schemas_get_list_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(get, list)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserResponse")); + assert!(!output.contains("CreateUserRequest")); + assert!(!output.contains("UpdateUserRequest")); +} + +#[test] +fn selective_handlers_schemas_create_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); + assert!(!output.contains("UpdateUserRequest")); +} + +#[test] +fn selective_handlers_all_schemas() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create, update)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); + assert!(output.contains("UpdateUserRequest")); +} diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index f0445a7..734e5ec 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -108,7 +108,6 @@ fn generate_crud_routes(entity: &EntityDef) -> TokenStream { let delete_handler = format_ident!("delete_{}", snake); let list_handler = format_ident!("list_{}", snake); - // Build collection route methods (POST, GET) let mut collection_methods = Vec::new(); if handlers.create { collection_methods.push(quote! { post(#create_handler::) }); @@ -117,7 +116,6 @@ fn generate_crud_routes(entity: &EntityDef) -> TokenStream { collection_methods.push(quote! { get(#list_handler::) }); } - // Build item route methods (GET, PATCH, DELETE) let mut item_methods = Vec::new(); if handlers.get { item_methods.push(quote! { get(#get_handler::) }); @@ -129,7 +127,6 @@ fn generate_crud_routes(entity: &EntityDef) -> TokenStream { item_methods.push(quote! { delete(#delete_handler::) }); } - // Generate routes only for non-empty method lists let collection_route = if !collection_methods.is_empty() { let first = &collection_methods[0]; let rest: Vec<_> = collection_methods.iter().skip(1).collect(); diff --git a/crates/entity-derive-impl/src/entity/parse/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs deleted file mode 100644 index ec7d419..0000000 --- a/crates/entity-derive-impl/src/entity/parse/api.rs +++ /dev/null @@ -1,576 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 RAprogramm -// SPDX-License-Identifier: MIT - -#![allow(dead_code)] // Methods used by handler generation (#77) - -//! API configuration parsing for OpenAPI/utoipa integration. -//! -//! This module handles parsing of `#[entity(api(...))]` attributes for -//! automatic HTTP handler generation with OpenAPI documentation. -//! -//! # Syntax -//! -//! ```rust,ignore -//! #[entity(api( -//! tag = "Users", // OpenAPI tag name (required) -//! tag_description = "...", // Tag description (optional) -//! path_prefix = "/api/v1", // URL prefix (optional) -//! security = "bearer", // Default security scheme (optional) -//! public = [Register, Login], // Commands without auth (optional) -//! ))] -//! ``` -//! -//! # Generated Output -//! -//! When `api(...)` is present, the macro generates: -//! - Axum handlers with `#[utoipa::path]` annotations -//! - OpenAPI schemas via `#[derive(ToSchema)]` -//! - Router factory function -//! - OpenApi struct for Swagger UI - -use syn::Ident; - -/// Handler configuration for selective CRUD generation. -/// -/// # Syntax -/// -/// - `handlers` - enables all handlers -/// - `handlers(create, get, list)` - enables specific handlers -#[derive(Debug, Clone, Default)] -pub struct HandlerConfig { - /// Generate create handler (POST /collection). - pub create: bool, - /// Generate get handler (GET /collection/{id}). - pub get: bool, - /// Generate update handler (PATCH /collection/{id}). - pub update: bool, - /// Generate delete handler (DELETE /collection/{id}). - pub delete: bool, - /// Generate list handler (GET /collection). - pub list: bool -} - -impl HandlerConfig { - /// Create config with all handlers enabled. - pub fn all() -> Self { - Self { - create: true, - get: true, - update: true, - delete: true, - list: true - } - } - - /// Check if any handler is enabled. - pub fn any(&self) -> bool { - self.create || self.get || self.update || self.delete || self.list - } -} - -/// API configuration parsed from `#[entity(api(...))]`. -/// -/// Controls HTTP handler generation and OpenAPI documentation. -#[derive(Debug, Clone, Default)] -pub struct ApiConfig { - /// OpenAPI tag name for grouping endpoints. - /// - /// Required when API generation is enabled. - /// Example: `"Users"`, `"Products"`, `"Orders"` - pub tag: Option, - - /// Description for the OpenAPI tag. - /// - /// Provides additional context in API documentation. - pub tag_description: Option, - - /// URL path prefix for all endpoints. - /// - /// Example: `"/api/v1"` results in `/api/v1/users` - pub path_prefix: Option, - - /// Default security scheme for endpoints. - /// - /// Supported values: - /// - `"bearer"` - JWT Bearer token - /// - `"api_key"` - API key in header - /// - `"none"` - No authentication - pub security: Option, - - /// Commands that don't require authentication. - /// - /// These endpoints bypass the default security scheme. - /// Example: `[Register, Login]` - pub public_commands: Vec, - - /// API version string. - /// - /// Added to path prefix: `/api/v1` with version `"v1"` - pub version: Option, - - /// Version in which this API is deprecated. - /// - /// Marks all endpoints with `deprecated = true` in OpenAPI. - pub deprecated_in: Option, - - /// CRUD handlers configuration. - /// - /// Controls which handlers to generate: - /// - `handlers` - all handlers - /// - `handlers(create, get, list)` - specific handlers only - pub handlers: HandlerConfig, - - /// OpenAPI info: API title. - /// - /// Overrides the default title in OpenAPI spec. - /// Example: `"User Service API"` - pub title: Option, - - /// OpenAPI info: API description. - /// - /// Full description for the API, supports Markdown. - /// Example: `"RESTful API for user management"` - pub description: Option, - - /// OpenAPI info: API version. - /// - /// Semantic version string for the API. - /// Example: `"1.0.0"` - pub api_version: Option, - - /// OpenAPI info: License name. - /// - /// License under which the API is published. - /// Example: `"MIT"`, `"Apache-2.0"` - pub license: Option, - - /// OpenAPI info: License URL. - /// - /// URL to the license text. - pub license_url: Option, - - /// OpenAPI info: Contact name. - /// - /// Name of the API maintainer or team. - pub contact_name: Option, - - /// OpenAPI info: Contact email. - /// - /// Email for API support inquiries. - pub contact_email: Option, - - /// OpenAPI info: Contact URL. - /// - /// URL to API support or documentation. - pub contact_url: Option -} - -impl ApiConfig { - /// Check if API generation is enabled. - /// - /// Returns `true` if the `api(...)` attribute is present. - pub fn is_enabled(&self) -> bool { - self.tag.is_some() - } - - /// Get the tag name or default to entity name. - /// - /// # Arguments - /// - /// * `entity_name` - Fallback entity name - pub fn tag_or_default(&self, entity_name: &str) -> String { - self.tag.clone().unwrap_or_else(|| entity_name.to_string()) - } - - /// Get the full path prefix including version. - /// - /// Combines `path_prefix` and `version` if both are set. - pub fn full_path_prefix(&self) -> String { - match (&self.path_prefix, &self.version) { - (Some(prefix), Some(version)) => { - format!("{}/{}", prefix.trim_end_matches('/'), version) - } - (Some(prefix), None) => prefix.clone(), - (None, Some(version)) => format!("/{}", version), - (None, None) => String::new() - } - } - - /// Check if a command is public (no auth required). - /// - /// # Arguments - /// - /// * `command_name` - Command name to check - pub fn is_public_command(&self, command_name: &str) -> bool { - self.public_commands.iter().any(|c| c == command_name) - } - - /// Check if API is marked as deprecated. - pub fn is_deprecated(&self) -> bool { - self.deprecated_in.is_some() - } - - /// Check if any CRUD handler should be generated. - pub fn has_handlers(&self) -> bool { - self.handlers.any() - } - - /// Get handler configuration. - pub fn handlers(&self) -> &HandlerConfig { - &self.handlers - } - - /// Get security scheme for a command. - /// - /// Returns `None` for public commands, otherwise the default security. - /// - /// # Arguments - /// - /// * `command_name` - Command name to check - pub fn security_for_command(&self, command_name: &str) -> Option<&str> { - if self.is_public_command(command_name) { - None - } else { - self.security.as_deref() - } - } -} - -/// Parse `#[entity(api(...))]` attribute. -/// -/// Extracts API configuration from the nested attribute. -/// -/// # Arguments -/// -/// * `meta` - The meta content inside `api(...)` -/// -/// # Returns -/// -/// Parsed `ApiConfig` or error. -pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { - let mut config = ApiConfig::default(); - - let list = match meta { - syn::Meta::List(list) => list, - syn::Meta::Path(_) => { - return Err(syn::Error::new_spanned( - meta, - "api attribute requires parameters: api(tag = \"...\")" - )); - } - syn::Meta::NameValue(_) => { - return Err(syn::Error::new_spanned( - meta, - "api attribute must use parentheses: api(tag = \"...\")" - )); - } - }; - - list.parse_nested_meta(|nested| { - let ident = nested - .path - .get_ident() - .ok_or_else(|| syn::Error::new_spanned(&nested.path, "expected identifier"))?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "tag" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.tag = Some(value.value()); - } - "tag_description" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.tag_description = Some(value.value()); - } - "path_prefix" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.path_prefix = Some(value.value()); - } - "security" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.security = Some(value.value()); - } - "public" => { - let _: syn::Token![=] = nested.input.parse()?; - let content; - syn::bracketed!(content in nested.input); - let commands = - syn::punctuated::Punctuated::::parse_terminated( - &content - )?; - config.public_commands = commands.into_iter().collect(); - } - "version" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.version = Some(value.value()); - } - "deprecated_in" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.deprecated_in = Some(value.value()); - } - "handlers" => { - // Support: - // - `handlers` - all handlers - // - `handlers = true/false` - all or none - // - `handlers(create, get, list)` - specific handlers - if nested.input.peek(syn::Token![=]) { - let _: syn::Token![=] = nested.input.parse()?; - let value: syn::LitBool = nested.input.parse()?; - if value.value() { - config.handlers = HandlerConfig::all(); - } - } else if nested.input.peek(syn::token::Paren) { - let content; - syn::parenthesized!(content in nested.input); - let handlers = - syn::punctuated::Punctuated::::parse_terminated( - &content - )?; - for handler in handlers { - match handler.to_string().as_str() { - "create" => config.handlers.create = true, - "get" => config.handlers.get = true, - "update" => config.handlers.update = true, - "delete" => config.handlers.delete = true, - "list" => config.handlers.list = true, - other => { - return Err(syn::Error::new( - handler.span(), - format!( - "unknown handler '{}', expected: create, get, update, delete, list", - other - ) - )); - } - } - } - } else { - config.handlers = HandlerConfig::all(); - } - } - "title" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.title = Some(value.value()); - } - "description" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.description = Some(value.value()); - } - "api_version" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.api_version = Some(value.value()); - } - "license" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.license = Some(value.value()); - } - "license_url" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.license_url = Some(value.value()); - } - "contact_name" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.contact_name = Some(value.value()); - } - "contact_email" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.contact_email = Some(value.value()); - } - "contact_url" => { - let value: syn::LitStr = nested.value()?.parse()?; - config.contact_url = Some(value.value()); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!( - "unknown api option '{}', expected: tag, tag_description, path_prefix, \ - security, public, version, deprecated_in, handlers, title, description, \ - api_version, license, license_url, contact_name, contact_email, contact_url", - ident_str - ) - )); - } - } - - Ok(()) - })?; - - Ok(config) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_test_config(input: &str) -> ApiConfig { - let meta: syn::Meta = syn::parse_str(input).unwrap(); - parse_api_config(&meta).unwrap() - } - - #[test] - fn parse_tag_only() { - let config = parse_test_config(r#"api(tag = "Users")"#); - assert_eq!(config.tag, Some("Users".to_string())); - assert!(config.is_enabled()); - } - - #[test] - fn parse_full_config() { - let config = parse_test_config( - r#"api( - tag = "Users", - tag_description = "User management", - path_prefix = "/api/v1", - security = "bearer" - )"# - ); - assert_eq!(config.tag, Some("Users".to_string())); - assert_eq!(config.tag_description, Some("User management".to_string())); - assert_eq!(config.path_prefix, Some("/api/v1".to_string())); - assert_eq!(config.security, Some("bearer".to_string())); - } - - #[test] - fn parse_public_commands() { - let config = parse_test_config(r#"api(tag = "Users", public = [Register, Login])"#); - assert_eq!(config.public_commands.len(), 2); - assert!(config.is_public_command("Register")); - assert!(config.is_public_command("Login")); - assert!(!config.is_public_command("Update")); - } - - #[test] - fn parse_version() { - let config = parse_test_config(r#"api(tag = "Users", version = "v2")"#); - assert_eq!(config.version, Some("v2".to_string())); - } - - #[test] - fn parse_deprecated() { - let config = parse_test_config(r#"api(tag = "Users", deprecated_in = "v2")"#); - assert!(config.is_deprecated()); - } - - #[test] - fn full_path_prefix_with_version() { - let config = ApiConfig { - path_prefix: Some("/api".to_string()), - version: Some("v1".to_string()), - ..Default::default() - }; - assert_eq!(config.full_path_prefix(), "/api/v1"); - } - - #[test] - fn full_path_prefix_without_version() { - let config = ApiConfig { - path_prefix: Some("/api/v1".to_string()), - ..Default::default() - }; - assert_eq!(config.full_path_prefix(), "/api/v1"); - } - - #[test] - fn full_path_prefix_version_only() { - let config = ApiConfig { - version: Some("v1".to_string()), - ..Default::default() - }; - assert_eq!(config.full_path_prefix(), "/v1"); - } - - #[test] - fn security_for_public_command() { - let config = - parse_test_config(r#"api(tag = "Users", security = "bearer", public = [Register])"#); - assert_eq!(config.security_for_command("Update"), Some("bearer")); - assert_eq!(config.security_for_command("Register"), None); - } - - #[test] - fn tag_or_default_uses_tag() { - let config = parse_test_config(r#"api(tag = "Users")"#); - assert_eq!(config.tag_or_default("User"), "Users"); - } - - #[test] - fn tag_or_default_uses_entity_name() { - let config = ApiConfig::default(); - assert_eq!(config.tag_or_default("User"), "User"); - } - - #[test] - fn default_config_not_enabled() { - let config = ApiConfig::default(); - assert!(!config.is_enabled()); - } - - #[test] - fn parse_trailing_slash_in_prefix() { - let config = ApiConfig { - path_prefix: Some("/api/".to_string()), - version: Some("v1".to_string()), - ..Default::default() - }; - assert_eq!(config.full_path_prefix(), "/api/v1"); - } - - #[test] - fn parse_handlers_flag() { - let config = parse_test_config(r#"api(tag = "Users", handlers)"#); - assert!(config.has_handlers()); - } - - #[test] - fn parse_handlers_true() { - let config = parse_test_config(r#"api(tag = "Users", handlers = true)"#); - assert!(config.has_handlers()); - } - - #[test] - fn parse_handlers_false() { - let config = parse_test_config(r#"api(tag = "Users", handlers = false)"#); - assert!(!config.has_handlers()); - } - - #[test] - fn default_handlers_false() { - let config = parse_test_config(r#"api(tag = "Users")"#); - assert!(!config.has_handlers()); - } - - #[test] - fn parse_handlers_selective() { - let config = parse_test_config(r#"api(tag = "Users", handlers(create, get, list))"#); - assert!(config.has_handlers()); - assert!(config.handlers().create); - assert!(config.handlers().get); - assert!(!config.handlers().update); - assert!(!config.handlers().delete); - assert!(config.handlers().list); - } - - #[test] - fn parse_handlers_single() { - let config = parse_test_config(r#"api(tag = "Users", handlers(get))"#); - assert!(config.has_handlers()); - assert!(!config.handlers().create); - assert!(config.handlers().get); - assert!(!config.handlers().update); - assert!(!config.handlers().delete); - assert!(!config.handlers().list); - } - - #[test] - fn parse_handlers_all_explicit() { - let config = parse_test_config( - r#"api(tag = "Users", handlers(create, get, update, delete, list))"# - ); - assert!(config.handlers().create); - assert!(config.handlers().get); - assert!(config.handlers().update); - assert!(config.handlers().delete); - assert!(config.handlers().list); - } -} diff --git a/crates/entity-derive-impl/src/entity/parse/api/config.rs b/crates/entity-derive-impl/src/entity/parse/api/config.rs new file mode 100644 index 0000000..b13471e --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/config.rs @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! API configuration types. + +use syn::Ident; + +/// Handler configuration for selective CRUD generation. +/// +/// # Syntax +/// +/// - `handlers` - enables all handlers +/// - `handlers(create, get, list)` - enables specific handlers +#[derive(Debug, Clone, Default)] +pub struct HandlerConfig { + /// Generate create handler (POST /collection). + pub create: bool, + /// Generate get handler (GET /collection/{id}). + pub get: bool, + /// Generate update handler (PATCH /collection/{id}). + pub update: bool, + /// Generate delete handler (DELETE /collection/{id}). + pub delete: bool, + /// Generate list handler (GET /collection). + pub list: bool +} + +impl HandlerConfig { + /// Create config with all handlers enabled. + pub fn all() -> Self { + Self { + create: true, + get: true, + update: true, + delete: true, + list: true + } + } + + /// Check if any handler is enabled. + pub fn any(&self) -> bool { + self.create || self.get || self.update || self.delete || self.list + } +} + +/// API configuration parsed from `#[entity(api(...))]`. +/// +/// Controls HTTP handler generation and OpenAPI documentation. +#[derive(Debug, Clone, Default)] +pub struct ApiConfig { + /// OpenAPI tag name for grouping endpoints. + /// + /// Required when API generation is enabled. + /// Example: `"Users"`, `"Products"`, `"Orders"` + pub tag: Option, + + /// Description for the OpenAPI tag. + /// + /// Provides additional context in API documentation. + pub tag_description: Option, + + /// URL path prefix for all endpoints. + /// + /// Example: `"/api/v1"` results in `/api/v1/users` + pub path_prefix: Option, + + /// Default security scheme for endpoints. + /// + /// Supported values: + /// - `"bearer"` - JWT Bearer token + /// - `"api_key"` - API key in header + /// - `"none"` - No authentication + pub security: Option, + + /// Commands that don't require authentication. + /// + /// These endpoints bypass the default security scheme. + /// Example: `[Register, Login]` + pub public_commands: Vec, + + /// API version string. + /// + /// Added to path prefix: `/api/v1` with version `"v1"` + pub version: Option, + + /// Version in which this API is deprecated. + /// + /// Marks all endpoints with `deprecated = true` in OpenAPI. + pub deprecated_in: Option, + + /// CRUD handlers configuration. + /// + /// Controls which handlers to generate: + /// - `handlers` - all handlers + /// - `handlers(create, get, list)` - specific handlers only + pub handlers: HandlerConfig, + + /// OpenAPI info: API title. + /// + /// Overrides the default title in OpenAPI spec. + /// Example: `"User Service API"` + pub title: Option, + + /// OpenAPI info: API description. + /// + /// Full description for the API, supports Markdown. + /// Example: `"RESTful API for user management"` + pub description: Option, + + /// OpenAPI info: API version. + /// + /// Semantic version string for the API. + /// Example: `"1.0.0"` + pub api_version: Option, + + /// OpenAPI info: License name. + /// + /// License under which the API is published. + /// Example: `"MIT"`, `"Apache-2.0"` + pub license: Option, + + /// OpenAPI info: License URL. + /// + /// URL to the license text. + pub license_url: Option, + + /// OpenAPI info: Contact name. + /// + /// Name of the API maintainer or team. + pub contact_name: Option, + + /// OpenAPI info: Contact email. + /// + /// Email for API support inquiries. + pub contact_email: Option, + + /// OpenAPI info: Contact URL. + /// + /// URL to API support or documentation. + pub contact_url: Option +} + +impl ApiConfig { + /// Check if API generation is enabled. + /// + /// Returns `true` if the `api(...)` attribute is present. + pub fn is_enabled(&self) -> bool { + self.tag.is_some() + } + + /// Get the tag name or default to entity name. + /// + /// # Arguments + /// + /// * `entity_name` - Fallback entity name + pub fn tag_or_default(&self, entity_name: &str) -> String { + self.tag.clone().unwrap_or_else(|| entity_name.to_string()) + } + + /// Get the full path prefix including version. + /// + /// Combines `path_prefix` and `version` if both are set. + pub fn full_path_prefix(&self) -> String { + match (&self.path_prefix, &self.version) { + (Some(prefix), Some(version)) => { + format!("{}/{}", prefix.trim_end_matches('/'), version) + } + (Some(prefix), None) => prefix.clone(), + (None, Some(version)) => format!("/{}", version), + (None, None) => String::new() + } + } + + /// Check if a command is public (no auth required). + /// + /// # Arguments + /// + /// * `command_name` - Command name to check + pub fn is_public_command(&self, command_name: &str) -> bool { + self.public_commands.iter().any(|c| c == command_name) + } + + /// Check if API is marked as deprecated. + pub fn is_deprecated(&self) -> bool { + self.deprecated_in.is_some() + } + + /// Check if any CRUD handler should be generated. + pub fn has_handlers(&self) -> bool { + self.handlers.any() + } + + /// Get handler configuration. + pub fn handlers(&self) -> &HandlerConfig { + &self.handlers + } + + /// Get security scheme for a command. + /// + /// Returns `None` for public commands, otherwise the default security. + /// + /// # Arguments + /// + /// * `command_name` - Command name to check + pub fn security_for_command(&self, command_name: &str) -> Option<&str> { + if self.is_public_command(command_name) { + None + } else { + self.security.as_deref() + } + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/api/mod.rs b/crates/entity-derive-impl/src/entity/parse/api/mod.rs new file mode 100644 index 0000000..0010d94 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/mod.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +#![allow(dead_code)] // Methods used by handler generation (#77) + +//! API configuration parsing for OpenAPI/utoipa integration. +//! +//! This module handles parsing of `#[entity(api(...))]` attributes for +//! automatic HTTP handler generation with OpenAPI documentation. +//! +//! # Syntax +//! +//! ```rust,ignore +//! #[entity(api( +//! tag = "Users", // OpenAPI tag name (required) +//! tag_description = "...", // Tag description (optional) +//! path_prefix = "/api/v1", // URL prefix (optional) +//! security = "bearer", // Default security scheme (optional) +//! public = [Register, Login], // Commands without auth (optional) +//! ))] +//! ``` +//! +//! # Generated Output +//! +//! When `api(...)` is present, the macro generates: +//! - Axum handlers with `#[utoipa::path]` annotations +//! - OpenAPI schemas via `#[derive(ToSchema)]` +//! - Router factory function +//! - OpenApi struct for Swagger UI + +mod config; +mod parser; + +pub use config::ApiConfig; +pub use parser::parse_api_config; + +#[cfg(test)] +mod tests; diff --git a/crates/entity-derive-impl/src/entity/parse/api/parser.rs b/crates/entity-derive-impl/src/entity/parse/api/parser.rs new file mode 100644 index 0000000..6b946a5 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/parser.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! API configuration parsing. + +use syn::Ident; + +use super::config::{ApiConfig, HandlerConfig}; + +/// Parse `#[entity(api(...))]` attribute. +/// +/// Extracts API configuration from the nested attribute. +/// +/// # Arguments +/// +/// * `meta` - The meta content inside `api(...)` +/// +/// # Returns +/// +/// Parsed `ApiConfig` or error. +pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { + let mut config = ApiConfig::default(); + + let list = match meta { + syn::Meta::List(list) => list, + syn::Meta::Path(_) => { + return Err(syn::Error::new_spanned( + meta, + "api attribute requires parameters: api(tag = \"...\")" + )); + } + syn::Meta::NameValue(_) => { + return Err(syn::Error::new_spanned( + meta, + "api attribute must use parentheses: api(tag = \"...\")" + )); + } + }; + + list.parse_nested_meta(|nested| { + let ident = nested + .path + .get_ident() + .ok_or_else(|| syn::Error::new_spanned(&nested.path, "expected identifier"))?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "tag" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.tag = Some(value.value()); + } + "tag_description" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.tag_description = Some(value.value()); + } + "path_prefix" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.path_prefix = Some(value.value()); + } + "security" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.security = Some(value.value()); + } + "public" => { + let _: syn::Token![=] = nested.input.parse()?; + let content; + syn::bracketed!(content in nested.input); + let commands = + syn::punctuated::Punctuated::::parse_terminated( + &content + )?; + config.public_commands = commands.into_iter().collect(); + } + "version" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.version = Some(value.value()); + } + "deprecated_in" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.deprecated_in = Some(value.value()); + } + "handlers" => { + if nested.input.peek(syn::Token![=]) { + let _: syn::Token![=] = nested.input.parse()?; + let value: syn::LitBool = nested.input.parse()?; + if value.value() { + config.handlers = HandlerConfig::all(); + } + } else if nested.input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in nested.input); + let handlers = + syn::punctuated::Punctuated::::parse_terminated( + &content + )?; + for handler in handlers { + match handler.to_string().as_str() { + "create" => config.handlers.create = true, + "get" => config.handlers.get = true, + "update" => config.handlers.update = true, + "delete" => config.handlers.delete = true, + "list" => config.handlers.list = true, + other => { + return Err(syn::Error::new( + handler.span(), + format!( + "unknown handler '{}', expected: create, get, update, \ + delete, list", + other + ) + )); + } + } + } + } else { + config.handlers = HandlerConfig::all(); + } + } + "title" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.title = Some(value.value()); + } + "description" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.description = Some(value.value()); + } + "api_version" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.api_version = Some(value.value()); + } + "license" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.license = Some(value.value()); + } + "license_url" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.license_url = Some(value.value()); + } + "contact_name" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_name = Some(value.value()); + } + "contact_email" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_email = Some(value.value()); + } + "contact_url" => { + let value: syn::LitStr = nested.value()?.parse()?; + config.contact_url = Some(value.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown api option '{}', expected: tag, tag_description, path_prefix, \ + security, public, version, deprecated_in, handlers, title, description, \ + api_version, license, license_url, contact_name, contact_email, \ + contact_url", + ident_str + ) + )); + } + } + + Ok(()) + })?; + + Ok(config) +} diff --git a/crates/entity-derive-impl/src/entity/parse/api/tests.rs b/crates/entity-derive-impl/src/entity/parse/api/tests.rs new file mode 100644 index 0000000..00583d7 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/tests.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Tests for API configuration parsing. + +use super::*; + +fn parse_test_config(input: &str) -> ApiConfig { + let meta: syn::Meta = syn::parse_str(input).unwrap(); + parse_api_config(&meta).unwrap() +} + +#[test] +fn parse_tag_only() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert_eq!(config.tag, Some("Users".to_string())); + assert!(config.is_enabled()); +} + +#[test] +fn parse_full_config() { + let config = parse_test_config( + r#"api( + tag = "Users", + tag_description = "User management", + path_prefix = "/api/v1", + security = "bearer" + )"# + ); + assert_eq!(config.tag, Some("Users".to_string())); + assert_eq!(config.tag_description, Some("User management".to_string())); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + assert_eq!(config.security, Some("bearer".to_string())); +} + +#[test] +fn parse_public_commands() { + let config = parse_test_config(r#"api(tag = "Users", public = [Register, Login])"#); + assert_eq!(config.public_commands.len(), 2); + assert!(config.is_public_command("Register")); + assert!(config.is_public_command("Login")); + assert!(!config.is_public_command("Update")); +} + +#[test] +fn parse_version() { + let config = parse_test_config(r#"api(tag = "Users", version = "v2")"#); + assert_eq!(config.version, Some("v2".to_string())); +} + +#[test] +fn parse_deprecated() { + let config = parse_test_config(r#"api(tag = "Users", deprecated_in = "v2")"#); + assert!(config.is_deprecated()); +} + +#[test] +fn full_path_prefix_with_version() { + let config = ApiConfig { + path_prefix: Some("/api".to_string()), + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); +} + +#[test] +fn full_path_prefix_without_version() { + let config = ApiConfig { + path_prefix: Some("/api/v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); +} + +#[test] +fn full_path_prefix_version_only() { + let config = ApiConfig { + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/v1"); +} + +#[test] +fn security_for_public_command() { + let config = + parse_test_config(r#"api(tag = "Users", security = "bearer", public = [Register])"#); + assert_eq!(config.security_for_command("Update"), Some("bearer")); + assert_eq!(config.security_for_command("Register"), None); +} + +#[test] +fn tag_or_default_uses_tag() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert_eq!(config.tag_or_default("User"), "Users"); +} + +#[test] +fn tag_or_default_uses_entity_name() { + let config = ApiConfig::default(); + assert_eq!(config.tag_or_default("User"), "User"); +} + +#[test] +fn default_config_not_enabled() { + let config = ApiConfig::default(); + assert!(!config.is_enabled()); +} + +#[test] +fn parse_trailing_slash_in_prefix() { + let config = ApiConfig { + path_prefix: Some("/api/".to_string()), + version: Some("v1".to_string()), + ..Default::default() + }; + assert_eq!(config.full_path_prefix(), "/api/v1"); +} + +#[test] +fn parse_handlers_flag() { + let config = parse_test_config(r#"api(tag = "Users", handlers)"#); + assert!(config.has_handlers()); +} + +#[test] +fn parse_handlers_true() { + let config = parse_test_config(r#"api(tag = "Users", handlers = true)"#); + assert!(config.has_handlers()); +} + +#[test] +fn parse_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users", handlers = false)"#); + assert!(!config.has_handlers()); +} + +#[test] +fn default_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert!(!config.has_handlers()); +} + +#[test] +fn parse_handlers_selective() { + let config = parse_test_config(r#"api(tag = "Users", handlers(create, get, list))"#); + assert!(config.has_handlers()); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(config.handlers().list); +} + +#[test] +fn parse_handlers_single() { + let config = parse_test_config(r#"api(tag = "Users", handlers(get))"#); + assert!(config.has_handlers()); + assert!(!config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(!config.handlers().list); +} + +#[test] +fn parse_handlers_all_explicit() { + let config = + parse_test_config(r#"api(tag = "Users", handlers(create, get, update, delete, list))"#); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(config.handlers().update); + assert!(config.handlers().delete); + assert!(config.handlers().list); +} diff --git a/crates/entity-derive-impl/src/entity/parse/command.rs b/crates/entity-derive-impl/src/entity/parse/command.rs deleted file mode 100644 index 1f044bc..0000000 --- a/crates/entity-derive-impl/src/entity/parse/command.rs +++ /dev/null @@ -1,630 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 RAprogramm -// SPDX-License-Identifier: MIT - -//! Command definition and parsing. -//! -//! Commands define business operations on entities, following CQRS pattern. -//! Instead of generic CRUD, you get domain-specific commands like -//! `RegisterUser`, `UpdateEmail`, `DeactivateAccount`. -//! -//! # Syntax -//! -//! ```rust,ignore -//! #[command(Register)] // uses create fields -//! #[command(UpdateEmail: email)] // specific fields only -//! #[command(Deactivate, requires_id)] // id only, no fields -//! #[command(Transfer, payload = "TransferPayload")] // custom payload struct -//! ``` -//! -//! # Generated Code -//! -//! Each command generates: -//! - A command struct (e.g., `RegisterUser`) -//! - An entry in `UserCommand` enum -//! - An entry in `UserCommandResult` enum -//! - A handler method in `UserCommandHandler` trait - -use proc_macro2::Span; -use syn::{Attribute, Ident, Type}; - -/// Source of fields for a command. -/// -/// Determines which entity fields are included in the command payload. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum CommandSource { - /// Use fields marked with `#[field(create)]`. - /// - /// Default for commands that create new entities. - #[default] - Create, - - /// Use fields marked with `#[field(update)]`. - /// - /// For commands that modify existing entities. - Update, - - /// Use specific fields listed after colon. - /// - /// Example: `#[command(UpdateEmail: email)]` - Fields(Vec), - - /// Use a custom payload struct. - /// - /// Example: `#[command(Transfer, payload = "TransferPayload")]` - Custom(Type), - - /// No fields in payload. - /// - /// Combined with `requires_id` for id-only commands. - None -} - -/// Kind of command for categorization. -/// -/// Inferred from source or explicitly specified. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CommandKindHint { - /// Creates new entity. - #[default] - Create, - - /// Modifies existing entity. - Update, - - /// Removes entity. - Delete, - - /// Custom business operation. - Custom -} - -/// A command definition parsed from `#[command(...)]`. -/// -/// # Fields -/// -/// | Field | Description | -/// |-------|-------------| -/// | `name` | Command name (e.g., `Register`, `UpdateEmail`) | -/// | `source` | Where to get fields for the command payload | -/// | `requires_id` | Whether command requires entity ID parameter | -/// | `result_type` | Custom result type (default: entity or unit) | -/// | `kind` | Command kind hint for categorization | -/// -/// # Example -/// -/// For `#[command(Register)]`: -/// ```rust,ignore -/// CommandDef { -/// name: Ident("Register"), -/// source: CommandSource::Create, -/// requires_id: false, -/// result_type: None, -/// kind: CommandKindHint::Create -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct CommandDef { - /// Command name (e.g., `Register`, `UpdateEmail`). - pub name: Ident, - - /// Source of fields for the command payload. - pub source: CommandSource, - - /// Whether the command requires an entity ID. - /// - /// When `true`, the command struct includes an `id` field - /// and handler receives the ID separately. - pub requires_id: bool, - - /// Custom result type for this command. - /// - /// When `None`, returns the entity for create/update commands - /// or unit `()` for delete commands. - pub result_type: Option, - - /// Kind hint for command categorization. - pub kind: CommandKindHint, - - /// Security scheme override for this command. - /// - /// When set, overrides the entity-level default security. - /// Use `"none"` to make a command public. - pub security: Option -} - -impl CommandDef { - /// Create a new command definition with defaults. - /// - /// # Arguments - /// - /// * `name` - Command name identifier - pub fn new(name: Ident) -> Self { - Self { - name, - source: CommandSource::default(), - requires_id: false, - result_type: None, - kind: CommandKindHint::default(), - security: None - } - } - - /// Get the full command struct name. - /// - /// Combines command name with entity name. - /// - /// # Arguments - /// - /// * `entity_name` - The entity name (e.g., "User") - /// - /// # Returns - /// - /// Full command name (e.g., "RegisterUser") - pub fn struct_name(&self, entity_name: &str) -> Ident { - Ident::new(&format!("{}{}", self.name, entity_name), Span::call_site()) - } - - /// Get the handler method name. - /// - /// Converts command name to snake_case handler method. - /// - /// # Returns - /// - /// Handler method name (e.g., "handle_register") - pub fn handler_method_name(&self) -> Ident { - use convert_case::{Case, Casing}; - let snake = self.name.to_string().to_case(Case::Snake); - Ident::new(&format!("handle_{}", snake), Span::call_site()) - } - - /// Check if this command has explicit security override. - #[must_use] - #[allow(dead_code)] // Used in tests and for API inspection - pub fn has_security_override(&self) -> bool { - self.security.is_some() - } - - /// Check if this command is explicitly marked as public. - /// - /// Returns `true` if `security = "none"` is set. - #[must_use] - pub fn is_public(&self) -> bool { - self.security.as_deref() == Some("none") - } - - /// Get the security scheme for this command. - /// - /// Returns command-level override if set, otherwise `None`. - #[must_use] - pub fn security(&self) -> Option<&str> { - self.security.as_deref() - } -} - -/// Parse `#[command(...)]` attributes. -/// -/// Extracts all command definitions from the struct's attributes. -/// -/// # Arguments -/// -/// * `attrs` - Slice of syn Attributes from the struct -/// -/// # Returns -/// -/// Vector of parsed command definitions. -/// -/// # Syntax Examples -/// -/// ```text -/// #[command(Register)] // name only (create fields) -/// #[command(Register, source = "create")] // explicit source -/// #[command(UpdateEmail: email)] // specific fields -/// #[command(UpdateEmail: email, name)] // multiple fields -/// #[command(Deactivate, requires_id)] // id-only command -/// #[command(Deactivate, requires_id, kind = "delete")] // with kind hint -/// #[command(Transfer, payload = "TransferPayload")] // custom payload -/// #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] // custom result -/// ``` -pub fn parse_command_attrs(attrs: &[Attribute]) -> Vec { - attrs - .iter() - .filter(|attr| attr.path().is_ident("command")) - .filter_map(|attr| parse_single_command(attr).ok()) - .collect() -} - -/// Parse a single `#[command(...)]` attribute. -fn parse_single_command(attr: &Attribute) -> syn::Result { - attr.parse_args_with(|input: syn::parse::ParseStream<'_>| { - // Parse command name (required) - let name: Ident = input.parse()?; - let mut cmd = CommandDef::new(name); - - // Check for field list syntax: `Name: field1, field2` - if input.peek(syn::Token![:]) && !input.peek2(syn::Token![:]) { - let _: syn::Token![:] = input.parse()?; - let fields = - syn::punctuated::Punctuated::::parse_separated_nonempty( - input - )?; - cmd.source = CommandSource::Fields(fields.into_iter().collect()); - cmd.requires_id = true; - cmd.kind = CommandKindHint::Update; - return Ok(cmd); - } - - // Parse optional comma-separated options - while input.peek(syn::Token![,]) { - let _: syn::Token![,] = input.parse()?; - - if input.is_empty() { - break; - } - - let option_name: Ident = input.parse()?; - let option_str = option_name.to_string(); - - match option_str.as_str() { - "requires_id" => { - cmd.requires_id = true; - if matches!(cmd.source, CommandSource::Create) { - cmd.source = CommandSource::None; - cmd.kind = CommandKindHint::Update; - } - } - "source" => { - let _: syn::Token![=] = input.parse()?; - let source_lit: syn::LitStr = input.parse()?; - let source_val = source_lit.value(); - match source_val.as_str() { - "create" => cmd.source = CommandSource::Create, - "update" => { - cmd.source = CommandSource::Update; - cmd.requires_id = true; - cmd.kind = CommandKindHint::Update; - } - "none" => cmd.source = CommandSource::None, - _ => { - return Err(syn::Error::new( - source_lit.span(), - "source must be \"create\", \"update\", or \"none\"" - )); - } - } - } - "payload" => { - let _: syn::Token![=] = input.parse()?; - let payload_lit: syn::LitStr = input.parse()?; - let payload_str = payload_lit.value(); - let ty: Type = syn::parse_str(&payload_str)?; - cmd.source = CommandSource::Custom(ty); - cmd.kind = CommandKindHint::Custom; - } - "result" => { - let _: syn::Token![=] = input.parse()?; - let result_lit: syn::LitStr = input.parse()?; - let result_str = result_lit.value(); - let ty: Type = syn::parse_str(&result_str)?; - cmd.result_type = Some(ty); - } - "kind" => { - let _: syn::Token![=] = input.parse()?; - let kind_lit: syn::LitStr = input.parse()?; - let kind_val = kind_lit.value(); - match kind_val.as_str() { - "create" => cmd.kind = CommandKindHint::Create, - "update" => cmd.kind = CommandKindHint::Update, - "delete" => cmd.kind = CommandKindHint::Delete, - "custom" => cmd.kind = CommandKindHint::Custom, - _ => { - return Err(syn::Error::new( - kind_lit.span(), - "kind must be \"create\", \"update\", \"delete\", or \"custom\"" - )); - } - } - } - "security" => { - let _: syn::Token![=] = input.parse()?; - let security_lit: syn::LitStr = input.parse()?; - cmd.security = Some(security_lit.value()); - } - _ => { - return Err(syn::Error::new( - option_name.span(), - format!( - "unknown command option '{}', expected: requires_id, source, \ - payload, result, kind, security", - option_str - ) - )); - } - } - } - - Ok(cmd) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_simple_command() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].name.to_string(), "Register"); - assert_eq!(cmds[0].source, CommandSource::Create); - assert!(!cmds[0].requires_id); - } - - #[test] - fn parse_command_with_fields() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(UpdateEmail: email)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].name.to_string(), "UpdateEmail"); - if let CommandSource::Fields(ref fields) = cmds[0].source { - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "email"); - } else { - panic!("Expected Fields source"); - } - assert!(cmds[0].requires_id); - } - - #[test] - fn parse_command_with_multiple_fields() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(UpdateProfile: name, avatar, bio)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - if let CommandSource::Fields(ref fields) = cmds[0].source { - assert_eq!(fields.len(), 3); - assert_eq!(fields[0].to_string(), "name"); - assert_eq!(fields[1].to_string(), "avatar"); - assert_eq!(fields[2].to_string(), "bio"); - } else { - panic!("Expected Fields source"); - } - } - - #[test] - fn parse_requires_id_command() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Deactivate, requires_id)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert!(cmds[0].requires_id); - assert_eq!(cmds[0].source, CommandSource::None); - } - - #[test] - fn parse_custom_payload_command() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Transfer, payload = "TransferPayload")] - struct Account {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert!(matches!(cmds[0].source, CommandSource::Custom(_))); - } - - #[test] - fn parse_command_with_result() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] - struct Account {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert!(cmds[0].result_type.is_some()); - } - - #[test] - fn parse_multiple_commands() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register)] - #[command(UpdateEmail: email)] - #[command(Deactivate, requires_id)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 3); - assert_eq!(cmds[0].name.to_string(), "Register"); - assert_eq!(cmds[1].name.to_string(), "UpdateEmail"); - assert_eq!(cmds[2].name.to_string(), "Deactivate"); - } - - #[test] - fn parse_kind_hint() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Delete, requires_id, kind = "delete")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].kind, CommandKindHint::Delete); - } - - #[test] - fn struct_name_generation() { - let cmd = CommandDef::new(Ident::new("Register", Span::call_site())); - assert_eq!(cmd.struct_name("User").to_string(), "RegisterUser"); - } - - #[test] - fn handler_method_name_generation() { - let cmd = CommandDef::new(Ident::new("UpdateEmail", Span::call_site())); - assert_eq!(cmd.handler_method_name().to_string(), "handle_update_email"); - } - - #[test] - fn parse_source_update() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Modify, source = "update")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].source, CommandSource::Update); - assert!(cmds[0].requires_id); - assert_eq!(cmds[0].kind, CommandKindHint::Update); - } - - #[test] - fn parse_source_none() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Ping, source = "none")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].source, CommandSource::None); - } - - #[test] - fn parse_source_create_explicit() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register, source = "create")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].source, CommandSource::Create); - } - - #[test] - fn parse_kind_create() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register, kind = "create")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].kind, CommandKindHint::Create); - } - - #[test] - fn parse_kind_update() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Modify, kind = "update")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].kind, CommandKindHint::Update); - } - - #[test] - fn parse_kind_custom() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Process, kind = "custom")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].kind, CommandKindHint::Custom); - } - - #[test] - fn parse_trailing_comma() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register,)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].name.to_string(), "Register"); - } - - #[test] - fn parse_invalid_source_returns_empty() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Test, source = "invalid")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert!(cmds.is_empty()); - } - - #[test] - fn parse_invalid_kind_returns_empty() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Test, kind = "invalid")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert!(cmds.is_empty()); - } - - #[test] - fn parse_unknown_option_returns_empty() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Test, unknown_option)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert!(cmds.is_empty()); - } - - #[test] - fn ignores_non_command_attributes() { - let input: syn::DeriveInput = syn::parse_quote! { - #[derive(Debug)] - #[entity(table = "users")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert!(cmds.is_empty()); - } - - #[test] - fn parse_security_bearer() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(AdminDelete, requires_id, security = "admin")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].security(), Some("admin")); - assert!(!cmds[0].is_public()); - } - - #[test] - fn parse_security_none() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(PublicList, security = "none")] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert!(cmds[0].is_public()); - assert!(cmds[0].has_security_override()); - } - - #[test] - fn default_no_security_override() { - let input: syn::DeriveInput = syn::parse_quote! { - #[command(Register)] - struct User {} - }; - let cmds = parse_command_attrs(&input.attrs); - assert_eq!(cmds.len(), 1); - assert!(!cmds[0].has_security_override()); - assert!(!cmds[0].is_public()); - assert_eq!(cmds[0].security(), None); - } -} diff --git a/crates/entity-derive-impl/src/entity/parse/command/mod.rs b/crates/entity-derive-impl/src/entity/parse/command/mod.rs new file mode 100644 index 0000000..3a3e4e8 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/mod.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Command definition and parsing. +//! +//! Commands define business operations on entities, following CQRS pattern. +//! Instead of generic CRUD, you get domain-specific commands like +//! `RegisterUser`, `UpdateEmail`, `DeactivateAccount`. +//! +//! # Syntax +//! +//! ```rust,ignore +//! #[command(Register)] // uses create fields +//! #[command(UpdateEmail: email)] // specific fields only +//! #[command(Deactivate, requires_id)] // id only, no fields +//! #[command(Transfer, payload = "TransferPayload")] // custom payload struct +//! ``` +//! +//! # Generated Code +//! +//! Each command generates: +//! - A command struct (e.g., `RegisterUser`) +//! - An entry in `UserCommand` enum +//! - An entry in `UserCommandResult` enum +//! - A handler method in `UserCommandHandler` trait + +mod parser; +mod types; + +pub use parser::parse_command_attrs; +pub use types::{CommandDef, CommandKindHint, CommandSource}; + +#[cfg(test)] +mod tests; diff --git a/crates/entity-derive-impl/src/entity/parse/command/parser.rs b/crates/entity-derive-impl/src/entity/parse/command/parser.rs new file mode 100644 index 0000000..df530a0 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/parser.rs @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Command attribute parsing. + +use syn::{Attribute, Ident, Type}; + +use super::types::{CommandDef, CommandKindHint, CommandSource}; + +/// Parse `#[command(...)]` attributes. +/// +/// Extracts all command definitions from the struct's attributes. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes from the struct +/// +/// # Returns +/// +/// Vector of parsed command definitions. +/// +/// # Syntax Examples +/// +/// ```text +/// #[command(Register)] // name only (create fields) +/// #[command(Register, source = "create")] // explicit source +/// #[command(UpdateEmail: email)] // specific fields +/// #[command(UpdateEmail: email, name)] // multiple fields +/// #[command(Deactivate, requires_id)] // id-only command +/// #[command(Deactivate, requires_id, kind = "delete")] // with kind hint +/// #[command(Transfer, payload = "TransferPayload")] // custom payload +/// #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] // custom result +/// ``` +pub fn parse_command_attrs(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter(|attr| attr.path().is_ident("command")) + .filter_map(|attr| parse_single_command(attr).ok()) + .collect() +} + +/// Parse a single `#[command(...)]` attribute. +fn parse_single_command(attr: &Attribute) -> syn::Result { + attr.parse_args_with(|input: syn::parse::ParseStream<'_>| { + let name: Ident = input.parse()?; + let mut cmd = CommandDef::new(name); + + if input.peek(syn::Token![:]) && !input.peek2(syn::Token![:]) { + let _: syn::Token![:] = input.parse()?; + let fields = + syn::punctuated::Punctuated::::parse_separated_nonempty( + input + )?; + cmd.source = CommandSource::Fields(fields.into_iter().collect()); + cmd.requires_id = true; + cmd.kind = CommandKindHint::Update; + return Ok(cmd); + } + + while input.peek(syn::Token![,]) { + let _: syn::Token![,] = input.parse()?; + + if input.is_empty() { + break; + } + + let option_name: Ident = input.parse()?; + let option_str = option_name.to_string(); + + match option_str.as_str() { + "requires_id" => { + cmd.requires_id = true; + if matches!(cmd.source, CommandSource::Create) { + cmd.source = CommandSource::None; + cmd.kind = CommandKindHint::Update; + } + } + "source" => { + let _: syn::Token![=] = input.parse()?; + let source_lit: syn::LitStr = input.parse()?; + let source_val = source_lit.value(); + match source_val.as_str() { + "create" => cmd.source = CommandSource::Create, + "update" => { + cmd.source = CommandSource::Update; + cmd.requires_id = true; + cmd.kind = CommandKindHint::Update; + } + "none" => cmd.source = CommandSource::None, + _ => { + return Err(syn::Error::new( + source_lit.span(), + "source must be \"create\", \"update\", or \"none\"" + )); + } + } + } + "payload" => { + let _: syn::Token![=] = input.parse()?; + let payload_lit: syn::LitStr = input.parse()?; + let payload_str = payload_lit.value(); + let ty: Type = syn::parse_str(&payload_str)?; + cmd.source = CommandSource::Custom(ty); + cmd.kind = CommandKindHint::Custom; + } + "result" => { + let _: syn::Token![=] = input.parse()?; + let result_lit: syn::LitStr = input.parse()?; + let result_str = result_lit.value(); + let ty: Type = syn::parse_str(&result_str)?; + cmd.result_type = Some(ty); + } + "kind" => { + let _: syn::Token![=] = input.parse()?; + let kind_lit: syn::LitStr = input.parse()?; + let kind_val = kind_lit.value(); + match kind_val.as_str() { + "create" => cmd.kind = CommandKindHint::Create, + "update" => cmd.kind = CommandKindHint::Update, + "delete" => cmd.kind = CommandKindHint::Delete, + "custom" => cmd.kind = CommandKindHint::Custom, + _ => { + return Err(syn::Error::new( + kind_lit.span(), + "kind must be \"create\", \"update\", \"delete\", or \"custom\"" + )); + } + } + } + "security" => { + let _: syn::Token![=] = input.parse()?; + let security_lit: syn::LitStr = input.parse()?; + cmd.security = Some(security_lit.value()); + } + _ => { + return Err(syn::Error::new( + option_name.span(), + format!( + "unknown command option '{}', expected: requires_id, source, \ + payload, result, kind, security", + option_str + ) + )); + } + } + } + + Ok(cmd) + }) +} diff --git a/crates/entity-derive-impl/src/entity/parse/command/tests.rs b/crates/entity-derive-impl/src/entity/parse/command/tests.rs new file mode 100644 index 0000000..5a27187 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/tests.rs @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Tests for command parsing. + +use proc_macro2::Span; +use syn::Ident; + +use super::*; + +#[test] +fn parse_simple_command() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name.to_string(), "Register"); + assert_eq!(cmds[0].source, CommandSource::Create); + assert!(!cmds[0].requires_id); +} + +#[test] +fn parse_command_with_fields() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(UpdateEmail: email)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name.to_string(), "UpdateEmail"); + if let CommandSource::Fields(ref fields) = cmds[0].source { + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "email"); + } else { + panic!("Expected Fields source"); + } + assert!(cmds[0].requires_id); +} + +#[test] +fn parse_command_with_multiple_fields() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(UpdateProfile: name, avatar, bio)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + if let CommandSource::Fields(ref fields) = cmds[0].source { + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].to_string(), "name"); + assert_eq!(fields[1].to_string(), "avatar"); + assert_eq!(fields[2].to_string(), "bio"); + } else { + panic!("Expected Fields source"); + } +} + +#[test] +fn parse_requires_id_command() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Deactivate, requires_id)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].requires_id); + assert_eq!(cmds[0].source, CommandSource::None); +} + +#[test] +fn parse_custom_payload_command() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Transfer, payload = "TransferPayload")] + struct Account {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(matches!(cmds[0].source, CommandSource::Custom(_))); +} + +#[test] +fn parse_command_with_result() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] + struct Account {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].result_type.is_some()); +} + +#[test] +fn parse_multiple_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register)] + #[command(UpdateEmail: email)] + #[command(Deactivate, requires_id)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 3); + assert_eq!(cmds[0].name.to_string(), "Register"); + assert_eq!(cmds[1].name.to_string(), "UpdateEmail"); + assert_eq!(cmds[2].name.to_string(), "Deactivate"); +} + +#[test] +fn parse_kind_hint() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Delete, requires_id, kind = "delete")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].kind, CommandKindHint::Delete); +} + +#[test] +fn struct_name_generation() { + let cmd = CommandDef::new(Ident::new("Register", Span::call_site())); + assert_eq!(cmd.struct_name("User").to_string(), "RegisterUser"); +} + +#[test] +fn handler_method_name_generation() { + let cmd = CommandDef::new(Ident::new("UpdateEmail", Span::call_site())); + assert_eq!(cmd.handler_method_name().to_string(), "handle_update_email"); +} + +#[test] +fn parse_source_update() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Modify, source = "update")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].source, CommandSource::Update); + assert!(cmds[0].requires_id); + assert_eq!(cmds[0].kind, CommandKindHint::Update); +} + +#[test] +fn parse_source_none() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Ping, source = "none")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].source, CommandSource::None); +} + +#[test] +fn parse_source_create_explicit() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register, source = "create")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].source, CommandSource::Create); +} + +#[test] +fn parse_kind_create() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register, kind = "create")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].kind, CommandKindHint::Create); +} + +#[test] +fn parse_kind_update() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Modify, kind = "update")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].kind, CommandKindHint::Update); +} + +#[test] +fn parse_kind_custom() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Process, kind = "custom")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].kind, CommandKindHint::Custom); +} + +#[test] +fn parse_trailing_comma() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register,)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name.to_string(), "Register"); +} + +#[test] +fn parse_invalid_source_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Test, source = "invalid")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert!(cmds.is_empty()); +} + +#[test] +fn parse_invalid_kind_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Test, kind = "invalid")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert!(cmds.is_empty()); +} + +#[test] +fn parse_unknown_option_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Test, unknown_option)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert!(cmds.is_empty()); +} + +#[test] +fn ignores_non_command_attributes() { + let input: syn::DeriveInput = syn::parse_quote! { + #[derive(Debug)] + #[entity(table = "users")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert!(cmds.is_empty()); +} + +#[test] +fn parse_security_bearer() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(AdminDelete, requires_id, security = "admin")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].security(), Some("admin")); + assert!(!cmds[0].is_public()); +} + +#[test] +fn parse_security_none() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(PublicList, security = "none")] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].is_public()); + assert!(cmds[0].has_security_override()); +} + +#[test] +fn default_no_security_override() { + let input: syn::DeriveInput = syn::parse_quote! { + #[command(Register)] + struct User {} + }; + let cmds = parse_command_attrs(&input.attrs); + assert_eq!(cmds.len(), 1); + assert!(!cmds[0].has_security_override()); + assert!(!cmds[0].is_public()); + assert_eq!(cmds[0].security(), None); +} diff --git a/crates/entity-derive-impl/src/entity/parse/command/types.rs b/crates/entity-derive-impl/src/entity/parse/command/types.rs new file mode 100644 index 0000000..29986b5 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/types.rs @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Command types and definitions. + +use proc_macro2::Span; +use syn::{Ident, Type}; + +/// Source of fields for a command. +/// +/// Determines which entity fields are included in the command payload. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum CommandSource { + /// Use fields marked with `#[field(create)]`. + /// + /// Default for commands that create new entities. + #[default] + Create, + + /// Use fields marked with `#[field(update)]`. + /// + /// For commands that modify existing entities. + Update, + + /// Use specific fields listed after colon. + /// + /// Example: `#[command(UpdateEmail: email)]` + Fields(Vec), + + /// Use a custom payload struct. + /// + /// Example: `#[command(Transfer, payload = "TransferPayload")]` + Custom(Type), + + /// No fields in payload. + /// + /// Combined with `requires_id` for id-only commands. + None +} + +/// Kind of command for categorization. +/// +/// Inferred from source or explicitly specified. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommandKindHint { + /// Creates new entity. + #[default] + Create, + + /// Modifies existing entity. + Update, + + /// Removes entity. + Delete, + + /// Custom business operation. + Custom +} + +/// A command definition parsed from `#[command(...)]`. +/// +/// # Fields +/// +/// | Field | Description | +/// |-------|-------------| +/// | `name` | Command name (e.g., `Register`, `UpdateEmail`) | +/// | `source` | Where to get fields for the command payload | +/// | `requires_id` | Whether command requires entity ID parameter | +/// | `result_type` | Custom result type (default: entity or unit) | +/// | `kind` | Command kind hint for categorization | +/// +/// # Example +/// +/// For `#[command(Register)]`: +/// ```rust,ignore +/// CommandDef { +/// name: Ident("Register"), +/// source: CommandSource::Create, +/// requires_id: false, +/// result_type: None, +/// kind: CommandKindHint::Create +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct CommandDef { + /// Command name (e.g., `Register`, `UpdateEmail`). + pub name: Ident, + + /// Source of fields for the command payload. + pub source: CommandSource, + + /// Whether the command requires an entity ID. + /// + /// When `true`, the command struct includes an `id` field + /// and handler receives the ID separately. + pub requires_id: bool, + + /// Custom result type for this command. + /// + /// When `None`, returns the entity for create/update commands + /// or unit `()` for delete commands. + pub result_type: Option, + + /// Kind hint for command categorization. + pub kind: CommandKindHint, + + /// Security scheme override for this command. + /// + /// When set, overrides the entity-level default security. + /// Use `"none"` to make a command public. + pub security: Option +} + +impl CommandDef { + /// Create a new command definition with defaults. + /// + /// # Arguments + /// + /// * `name` - Command name identifier + pub fn new(name: Ident) -> Self { + Self { + name, + source: CommandSource::default(), + requires_id: false, + result_type: None, + kind: CommandKindHint::default(), + security: None + } + } + + /// Get the full command struct name. + /// + /// Combines command name with entity name. + /// + /// # Arguments + /// + /// * `entity_name` - The entity name (e.g., "User") + /// + /// # Returns + /// + /// Full command name (e.g., "RegisterUser") + pub fn struct_name(&self, entity_name: &str) -> Ident { + Ident::new(&format!("{}{}", self.name, entity_name), Span::call_site()) + } + + /// Get the handler method name. + /// + /// Converts command name to snake_case handler method. + /// + /// # Returns + /// + /// Handler method name (e.g., "handle_register") + pub fn handler_method_name(&self) -> Ident { + use convert_case::{Case, Casing}; + let snake = self.name.to_string().to_case(Case::Snake); + Ident::new(&format!("handle_{}", snake), Span::call_site()) + } + + /// Check if this command has explicit security override. + #[must_use] + #[allow(dead_code)] + pub fn has_security_override(&self) -> bool { + self.security.is_some() + } + + /// Check if this command is explicitly marked as public. + /// + /// Returns `true` if `security = "none"` is set. + #[must_use] + pub fn is_public(&self) -> bool { + self.security.as_deref() == Some("none") + } + + /// Get the security scheme for this command. + /// + /// Returns command-level override if set, otherwise `None`. + #[must_use] + pub fn security(&self) -> Option<&str> { + self.security.as_deref() + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index 2b75813..f030513 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -10,9 +10,14 @@ //! //! ```text //! entity/ -//! ├── mod.rs — Main EntityDef definition and parsing +//! ├── mod.rs — Re-exports and module declarations +//! ├── def.rs — EntityDef struct definition +//! ├── constructor.rs — EntityDef::from_derive_input() +//! ├── accessors.rs — EntityDef accessor methods //! ├── attrs.rs — EntityAttrs (darling parsing struct) -//! └── projection.rs — Projection definition and parsing +//! ├── helpers.rs — Helper parsing functions +//! ├── projection.rs — Projection definition and parsing +//! └── tests.rs — Unit tests //! ``` //! //! # Usage @@ -31,776 +36,16 @@ //! let update_fields = entity.update_fields(); //! ``` +mod accessors; mod attrs; +mod constructor; +mod def; +mod helpers; mod projection; pub use attrs::EntityAttrs; -#[cfg(test)] -use attrs::default_error_type; -use darling::FromDeriveInput; -use proc_macro2::Span; +pub use def::EntityDef; pub use projection::{ProjectionDef, parse_projection_attrs}; -use syn::{Attribute, DeriveInput, Ident, Visibility}; - -use super::{ - api::{ApiConfig, parse_api_config}, - command::{CommandDef, parse_command_attrs}, - dialect::DatabaseDialect, - field::FieldDef, - returning::ReturningMode, - sql_level::SqlLevel, - uuid_version::UuidVersion -}; -use crate::utils::docs::extract_doc_comments; - -/// Parse `#[has_many(Entity)]` attributes from struct attributes. -/// -/// Extracts all has-many relation definitions from the struct's attributes. -/// Each attribute specifies a related entity type for one-to-many -/// relationships. -/// -/// # Arguments -/// -/// * `attrs` - Slice of syn Attributes from the struct -/// -/// # Returns -/// -/// Vector of related entity identifiers. -/// -/// # Example -/// -/// ```rust,ignore -/// // For a User entity with posts and comments: -/// #[has_many(Post)] -/// #[has_many(Comment)] -/// struct User { ... } -/// -/// // Returns: vec![Ident("Post"), Ident("Comment")] -/// ``` -fn parse_has_many_attrs(attrs: &[Attribute]) -> Vec { - attrs - .iter() - .filter(|attr| attr.path().is_ident("has_many")) - .filter_map(|attr| attr.parse_args::().ok()) - .collect() -} - -/// Parse `api(...)` from `#[entity(...)]` attribute. -/// -/// Searches for the `api` key within the entity attribute and parses -/// its nested configuration. -/// -/// # Arguments -/// -/// * `attrs` - Slice of syn Attributes from the struct -/// -/// # Returns -/// -/// `ApiConfig` with parsed values, or default if not present. -fn parse_api_attr(attrs: &[Attribute]) -> ApiConfig { - for attr in attrs { - if !attr.path().is_ident("entity") { - continue; - } - - // Parse the attribute content manually - let result: syn::Result> = - attr.parse_args_with(|input: syn::parse::ParseStream<'_>| { - while !input.is_empty() { - let ident: Ident = input.parse()?; - - if ident == "api" { - // Found api(...), parse the nested content - let content; - syn::parenthesized!(content in input); - - // Build a Meta::List from the content - let tokens = content.parse::()?; - let meta_list = syn::Meta::List(syn::MetaList { - path: syn::parse_quote!(api), - delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), - tokens - }); - - if let Ok(config) = parse_api_config(&meta_list) { - return Ok(Some(config)); - } - } else { - // Skip other attributes (table = "...", schema = "...", etc.) - if input.peek(syn::Token![=]) { - let _: syn::Token![=] = input.parse()?; - // Skip the value - let _ = input.parse::()?; - } else if input.peek(syn::token::Paren) { - let content; - syn::parenthesized!(content in input); - let _ = content.parse::()?; - } - } - - // Skip comma if present - if input.peek(syn::Token![,]) { - let _: syn::Token![,] = input.parse()?; - } - } - Ok(None) - }); - - if let Ok(Some(config)) = result { - return config; - } - } - - ApiConfig::default() -} - -/// Complete parsed entity definition. -/// -/// This is the main data structure passed to all code generators. -/// It contains both entity-level metadata and all field definitions. -/// -/// # Construction -/// -/// Create via [`EntityDef::from_derive_input`]: -/// -/// ```rust,ignore -/// let entity = EntityDef::from_derive_input(&input)?; -/// ``` -/// -/// # Field Access -/// -/// Use the provided methods to access fields by category: -/// -/// ```rust,ignore -/// // All fields for Row/Insertable -/// let all = entity.all_fields(); -/// -/// // Fields for specific DTOs -/// let create_fields = entity.create_fields(); -/// let update_fields = entity.update_fields(); -/// let response_fields = entity.response_fields(); -/// -/// // Primary key field (guaranteed to exist) -/// let id = entity.id_field(); -/// ``` -#[derive(Debug)] -pub struct EntityDef { - /// Struct identifier (e.g., `User`). - pub ident: Ident, - - /// Struct visibility. - /// - /// Propagated to all generated types so they have the same - /// visibility as the source entity. - pub vis: Visibility, - - /// Database table name (e.g., `"users"`). - pub table: String, - - /// Database schema name (e.g., `"public"`, `"core"`). - pub schema: String, - - /// SQL generation level controlling what code is generated. - pub sql: SqlLevel, - - /// Database dialect for code generation. - pub dialect: DatabaseDialect, - - /// UUID version for ID generation. - pub uuid: UuidVersion, - - /// Custom error type for repository implementation. - /// - /// Defaults to `sqlx::Error`. Custom types must implement - /// `From` for the `?` operator to work. - pub error: syn::Path, - - /// All field definitions from the struct. - pub fields: Vec, - - /// Index of the primary key field in `fields`. - /// - /// Validated at parse time to always be valid. - id_field_index: usize, - - /// Has-many relations defined via `#[has_many(Entity)]`. - /// - /// Each entry is the related entity name. - pub has_many: Vec, - - /// Projections defined via `#[projection(Name: field1, field2)]`. - /// - /// Each projection defines a subset of fields for a specific view. - pub projections: Vec, - - /// Whether soft delete is enabled. - /// - /// When `true`, the `delete` method sets `deleted_at` instead of removing - /// the row, and all queries filter out records where `deleted_at IS NOT - /// NULL`. - pub soft_delete: bool, - - /// RETURNING clause mode for INSERT/UPDATE operations. - /// - /// Controls what data is fetched back from the database after writes. - pub returning: ReturningMode, - - /// Whether to generate lifecycle events. - /// - /// When `true`, generates a `{Entity}Event` enum with variants for - /// Created, Updated, Deleted, etc. - pub events: bool, - - /// Whether to generate lifecycle hooks trait. - /// - /// When `true`, generates a `{Entity}Hooks` trait with before/after - /// methods for CRUD operations. - pub hooks: bool, - - /// Whether to generate CQRS-style commands. - /// - /// When `true`, processes `#[command(...)]` attributes. - pub commands: bool, - - /// Command definitions parsed from `#[command(...)]` attributes. - /// - /// Each entry describes a business command (e.g., Register, UpdateEmail). - pub command_defs: Vec, - - /// Whether to generate authorization policy trait. - /// - /// When `true`, generates `{Entity}Policy` trait and related types. - pub policy: bool, - - /// Whether to enable real-time streaming. - /// - /// When `true`, generates `{Entity}Subscriber` and NOTIFY calls. - pub streams: bool, - - /// Whether to generate transaction support. - /// - /// When `true`, generates transaction repository adapter and builder - /// methods. - pub transactions: bool, - - /// API configuration for HTTP handler generation. - /// - /// When enabled via `#[entity(api(...))]`, generates axum handlers - /// with OpenAPI documentation via utoipa. - pub api_config: ApiConfig, - - /// Documentation comment from the entity struct. - /// - /// Extracted from `///` comments for use in OpenAPI tag descriptions. - pub doc: Option -} - -impl EntityDef { - /// Parse entity definition from syn's `DeriveInput`. - /// - /// This is the main entry point for parsing. It: - /// - /// 1. Parses entity-level attributes using darling - /// 2. Extracts all named fields from the struct - /// 3. Parses field-level attributes for each field - /// 4. Combines everything into an `EntityDef` - /// - /// # Arguments - /// - /// * `input` - Parsed derive input from syn - /// - /// # Returns - /// - /// `Ok(EntityDef)` on success, or `Err` with darling errors. - /// - /// # Errors - /// - /// - Missing `table` attribute - /// - Applied to non-struct (enum, union) - /// - Applied to tuple struct or unit struct - /// - Invalid attribute values - /// - /// # Example - /// - /// ```rust,ignore - /// pub fn derive(input: TokenStream) -> TokenStream { - /// let input = parse_macro_input!(input as DeriveInput); - /// - /// match EntityDef::from_derive_input(&input) { - /// Ok(entity) => generate(entity), - /// Err(err) => err.write_errors().into() - /// } - /// } - /// ``` - pub fn from_derive_input(input: &DeriveInput) -> darling::Result { - let attrs = EntityAttrs::from_derive_input(input)?; - - let fields: Vec = match &input.data { - syn::Data::Struct(data) => match &data.fields { - syn::Fields::Named(named) => named - .named - .iter() - .map(FieldDef::from_field) - .collect::>>()?, - _ => { - return Err(darling::Error::custom("Entity requires named fields") - .with_span(&input.ident)); - } - }, - _ => { - return Err( - darling::Error::custom("Entity can only be derived for structs") - .with_span(&input.ident) - ); - } - }; - - let has_many = parse_has_many_attrs(&input.attrs); - let projections = parse_projection_attrs(&input.attrs); - let command_defs = parse_command_attrs(&input.attrs); - let api_config = parse_api_attr(&input.attrs); - let doc = extract_doc_comments(&input.attrs); - - let id_field_index = fields.iter().position(|f| f.is_id()).ok_or_else(|| { - darling::Error::custom("Entity must have exactly one field with #[id] attribute") - .with_span(&input.ident) - })?; - - Ok(Self { - ident: attrs.ident, - vis: attrs.vis, - table: attrs.table, - schema: attrs.schema, - sql: attrs.sql, - dialect: attrs.dialect, - uuid: attrs.uuid, - error: attrs.error, - fields, - id_field_index, - has_many, - projections, - soft_delete: attrs.soft_delete, - returning: attrs.returning, - events: attrs.events, - hooks: attrs.hooks, - commands: attrs.commands, - command_defs, - policy: attrs.policy, - streams: attrs.streams, - transactions: attrs.transactions, - api_config, - doc - }) - } - - /// Get the primary key field marked with `#[id]`. - /// - /// This field is guaranteed to exist as it's validated during parsing. - /// - /// # Returns - /// - /// Reference to the primary key field definition. - pub fn id_field(&self) -> &FieldDef { - &self.fields[self.id_field_index] - } - - /// Get fields to include in `CreateRequest` DTO. - /// - /// Returns fields where: - /// - `#[field(create)]` is present - /// - NOT marked with `#[id]` (IDs are auto-generated) - /// - NOT marked with `#[auto]` (timestamps are auto-generated) - /// - NOT marked with `#[field(skip)]` - /// - /// # Returns - /// - /// Vector of field references for the create DTO. - pub fn create_fields(&self) -> Vec<&FieldDef> { - self.fields - .iter() - .filter(|f| f.in_create() && !f.is_id() && !f.is_auto()) - .collect() - } - - /// Get fields to include in `UpdateRequest` DTO. - /// - /// Returns fields where: - /// - `#[field(update)]` is present - /// - NOT marked with `#[id]` (can't update primary key) - /// - NOT marked with `#[auto]` (timestamps auto-update) - /// - NOT marked with `#[field(skip)]` - /// - /// # Returns - /// - /// Vector of field references for the update DTO. - pub fn update_fields(&self) -> Vec<&FieldDef> { - self.fields - .iter() - .filter(|f| f.in_update() && !f.is_id() && !f.is_auto()) - .collect() - } - - /// Get fields to include in `Response` DTO. - /// - /// Returns fields where: - /// - `#[field(response)]` is present, OR - /// - `#[id]` is present (IDs always in response) - /// - NOT marked with `#[field(skip)]` - /// - /// # Returns - /// - /// Vector of field references for the response DTO. - pub fn response_fields(&self) -> Vec<&FieldDef> { - self.fields.iter().filter(|f| f.in_response()).collect() - } - - /// Get all fields for Row and Insertable structs. - /// - /// These database-layer structs include ALL fields from the - /// entity, regardless of DTO inclusion settings. - /// - /// # Returns - /// - /// Slice of all field definitions. - pub fn all_fields(&self) -> &[FieldDef] { - &self.fields - } - - /// Get fields with `#[belongs_to]` relations. - /// - /// Returns fields that are foreign keys to other entities. - /// Used to generate relation methods in the repository. - /// - /// # Returns - /// - /// Vector of field references with belongs_to relations. - pub fn relation_fields(&self) -> Vec<&FieldDef> { - self.fields.iter().filter(|f| f.is_relation()).collect() - } - - /// Get fields with `#[filter]` attribute. - /// - /// Returns fields that can be used in query filtering. - /// Used to generate the Query struct and query method. - /// - /// # Returns - /// - /// Vector of field references with filter configuration. - pub fn filter_fields(&self) -> Vec<&FieldDef> { - self.fields.iter().filter(|f| f.has_filter()).collect() - } - - /// Check if this entity has any filterable fields. - /// - /// # Returns - /// - /// `true` if any field has `#[filter]` attribute. - pub fn has_filters(&self) -> bool { - self.fields.iter().any(|f| f.has_filter()) - } - - /// Get has-many relations defined via `#[has_many(Entity)]`. - /// - /// Returns entity identifiers for one-to-many relationships. - /// Used to generate collection methods in the repository. - /// - /// # Returns - /// - /// Slice of related entity identifiers. - pub fn has_many_relations(&self) -> &[Ident] { - &self.has_many - } - - /// Get the entity name as an identifier. - /// - /// # Returns - /// - /// Reference to the struct's `Ident`. - /// - /// # Example - /// - /// ```rust,ignore - /// let entity_name = entity.name(); // e.g., Ident("User") - /// quote! { impl #entity_name { } } - /// ``` - pub fn name(&self) -> &Ident { - &self.ident - } - - /// Get the entity name as a string. - /// - /// # Returns - /// - /// String representation of the entity name. - /// - /// # Example - /// - /// ```rust,ignore - /// entity.name_str() // "User" - /// ``` - pub fn name_str(&self) -> String { - self.ident.to_string() - } - - /// Get the fully qualified table name with schema. - /// - /// # Returns - /// - /// String in format `"schema.table"`. - /// - /// # Example - /// - /// ```rust,ignore - /// entity.full_table_name() // "core.users", "public.products" - /// ``` - pub fn full_table_name(&self) -> String { - format!("{}.{}", self.schema, self.table) - } - - /// Create a new identifier with prefix and/or suffix. - /// - /// Used to generate related type names following naming conventions. - /// - /// # Arguments - /// - /// * `prefix` - String to prepend (e.g., `"Create"`, `"Insertable"`) - /// * `suffix` - String to append (e.g., `"Request"`, `"Row"`) - /// - /// # Returns - /// - /// New `Ident` at `call_site` span. - /// - /// # Examples - /// - /// ```rust,ignore - /// // For entity "User": - /// entity.ident_with("Create", "Request") // CreateUserRequest - /// entity.ident_with("Update", "Request") // UpdateUserRequest - /// entity.ident_with("", "Response") // UserResponse - /// entity.ident_with("", "Row") // UserRow - /// entity.ident_with("Insertable", "") // InsertableUser - /// entity.ident_with("", "Repository") // UserRepository - /// ``` - pub fn ident_with(&self, prefix: &str, suffix: &str) -> Ident { - Ident::new( - &format!("{}{}{}", prefix, self.name_str(), suffix), - Span::call_site() - ) - } - - /// Get the error type for repository implementation. - /// - /// # Returns - /// - /// Reference to the error type path. - pub fn error_type(&self) -> &syn::Path { - &self.error - } - - /// Check if soft delete is enabled for this entity. - /// - /// # Returns - /// - /// `true` if `#[entity(soft_delete)]` is present. - pub fn is_soft_delete(&self) -> bool { - self.soft_delete - } - - /// Check if lifecycle events should be generated. - /// - /// # Returns - /// - /// `true` if `#[entity(events)]` is present. - pub fn has_events(&self) -> bool { - self.events - } - - /// Check if lifecycle hooks trait should be generated. - /// - /// # Returns - /// - /// `true` if `#[entity(hooks)]` is present. - pub fn has_hooks(&self) -> bool { - self.hooks - } - - /// Check if CQRS-style commands should be generated. - /// - /// # Returns - /// - /// `true` if `#[entity(commands)]` is present. - pub fn has_commands(&self) -> bool { - self.commands - } - - /// Get command definitions. - /// - /// # Returns - /// - /// Slice of command definitions parsed from `#[command(...)]` attributes. - pub fn command_defs(&self) -> &[CommandDef] { - &self.command_defs - } - - /// Check if authorization policy should be generated. - /// - /// # Returns - /// - /// `true` if `#[entity(policy)]` is present. - pub fn has_policy(&self) -> bool { - self.policy - } - - /// Check if real-time streaming should be enabled. - /// - /// # Returns - /// - /// `true` if `#[entity(streams)]` is present. - pub fn has_streams(&self) -> bool { - self.streams - } - - /// Check if transaction support should be generated. - /// - /// # Returns - /// - /// `true` if `#[entity(transactions)]` is present. - pub fn has_transactions(&self) -> bool { - self.transactions - } - - /// Check if API generation is enabled. - /// - /// # Returns - /// - /// `true` if `#[entity(api(...))]` is present with a tag. - /// - /// Used by handler generation (#77). - #[allow(dead_code)] - pub fn has_api(&self) -> bool { - self.api_config.is_enabled() - } - - /// Get API configuration. - /// - /// # Returns - /// - /// Reference to the API configuration. - /// - /// Used by handler generation (#77). - #[allow(dead_code)] - pub fn api_config(&self) -> &ApiConfig { - &self.api_config - } - - /// Get the documentation comment if present. - /// - /// Returns the extracted doc comment for use in OpenAPI descriptions. - #[must_use] - #[allow(dead_code)] - pub fn doc(&self) -> Option<&str> { - self.doc.as_deref() - } -} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_error_type_is_sqlx_error() { - let path = default_error_type(); - let path_str = quote::quote!(#path).to_string(); - assert!(path_str.contains("sqlx")); - assert!(path_str.contains("Error")); - } - - #[test] - fn entity_def_error_type_accessor() { - let input: DeriveInput = syn::parse_quote! { - #[entity(table = "users")] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let error_path = entity.error_type(); - let path_str = quote::quote!(#error_path).to_string(); - assert!(path_str.contains("sqlx")); - } - - #[test] - fn entity_def_without_api() { - let input: DeriveInput = syn::parse_quote! { - #[entity(table = "users")] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - assert!(!entity.has_api()); - } - - #[test] - fn entity_def_with_api() { - let input: DeriveInput = syn::parse_quote! { - #[entity(table = "users", api(tag = "Users"))] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - assert!(entity.has_api()); - assert_eq!(entity.api_config().tag, Some("Users".to_string())); - } - - #[test] - fn entity_def_with_full_api_config() { - let input: DeriveInput = syn::parse_quote! { - #[entity( - table = "users", - api( - tag = "Users", - tag_description = "User management", - path_prefix = "/api/v1", - security = "bearer" - ) - )] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - assert!(entity.has_api()); - let config = entity.api_config(); - assert_eq!(config.tag, Some("Users".to_string())); - assert_eq!(config.tag_description, Some("User management".to_string())); - assert_eq!(config.path_prefix, Some("/api/v1".to_string())); - assert_eq!(config.security, Some("bearer".to_string())); - } - - #[test] - fn entity_def_api_with_public_commands() { - let input: DeriveInput = syn::parse_quote! { - #[entity( - table = "users", - api(tag = "Users", security = "bearer", public = [Register, Login]) - )] - pub struct User { - #[id] - pub id: uuid::Uuid, - } - }; - let entity = EntityDef::from_derive_input(&input).unwrap(); - let config = entity.api_config(); - assert!(config.is_public_command("Register")); - assert!(config.is_public_command("Login")); - assert!(!config.is_public_command("Update")); - assert_eq!(config.security_for_command("Register"), None); - assert_eq!(config.security_for_command("Update"), Some("bearer")); - } -} +mod tests; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs b/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs new file mode 100644 index 0000000..d94c9b1 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Accessor methods for EntityDef. + +use proc_macro2::Span; +use syn::Ident; + +use super::{ + super::{api::ApiConfig, command::CommandDef, field::FieldDef}, + EntityDef +}; + +impl EntityDef { + /// Get the primary key field marked with `#[id]`. + /// + /// This field is guaranteed to exist as it's validated during parsing. + pub fn id_field(&self) -> &FieldDef { + &self.fields[self.id_field_index] + } + + /// Get fields to include in `CreateRequest` DTO. + /// + /// Returns fields where: + /// - `#[field(create)]` is present + /// - NOT marked with `#[id]` (IDs are auto-generated) + /// - NOT marked with `#[auto]` (timestamps are auto-generated) + /// - NOT marked with `#[field(skip)]` + pub fn create_fields(&self) -> Vec<&FieldDef> { + self.fields + .iter() + .filter(|f| f.in_create() && !f.is_id() && !f.is_auto()) + .collect() + } + + /// Get fields to include in `UpdateRequest` DTO. + /// + /// Returns fields where: + /// - `#[field(update)]` is present + /// - NOT marked with `#[id]` (can't update primary key) + /// - NOT marked with `#[auto]` (timestamps auto-update) + /// - NOT marked with `#[field(skip)]` + pub fn update_fields(&self) -> Vec<&FieldDef> { + self.fields + .iter() + .filter(|f| f.in_update() && !f.is_id() && !f.is_auto()) + .collect() + } + + /// Get fields to include in `Response` DTO. + /// + /// Returns fields where: + /// - `#[field(response)]` is present, OR + /// - `#[id]` is present (IDs always in response) + /// - NOT marked with `#[field(skip)]` + pub fn response_fields(&self) -> Vec<&FieldDef> { + self.fields.iter().filter(|f| f.in_response()).collect() + } + + /// Get all fields for Row and Insertable structs. + /// + /// These database-layer structs include ALL fields from the + /// entity, regardless of DTO inclusion settings. + pub fn all_fields(&self) -> &[FieldDef] { + &self.fields + } + + /// Get fields with `#[belongs_to]` relations. + /// + /// Returns fields that are foreign keys to other entities. + /// Used to generate relation methods in the repository. + pub fn relation_fields(&self) -> Vec<&FieldDef> { + self.fields.iter().filter(|f| f.is_relation()).collect() + } + + /// Get fields with `#[filter]` attribute. + /// + /// Returns fields that can be used in query filtering. + /// Used to generate the Query struct and query method. + pub fn filter_fields(&self) -> Vec<&FieldDef> { + self.fields.iter().filter(|f| f.has_filter()).collect() + } + + /// Check if this entity has any filterable fields. + pub fn has_filters(&self) -> bool { + self.fields.iter().any(|f| f.has_filter()) + } + + /// Get has-many relations defined via `#[has_many(Entity)]`. + /// + /// Returns entity identifiers for one-to-many relationships. + /// Used to generate collection methods in the repository. + pub fn has_many_relations(&self) -> &[Ident] { + &self.has_many + } + + /// Get the entity name as an identifier. + pub fn name(&self) -> &Ident { + &self.ident + } + + /// Get the entity name as a string. + pub fn name_str(&self) -> String { + self.ident.to_string() + } + + /// Get the fully qualified table name with schema. + pub fn full_table_name(&self) -> String { + format!("{}.{}", self.schema, self.table) + } + + /// Create a new identifier with prefix and/or suffix. + /// + /// Used to generate related type names following naming conventions. + /// + /// # Examples + /// + /// ```rust,ignore + /// // For entity "User": + /// entity.ident_with("Create", "Request") // CreateUserRequest + /// entity.ident_with("Update", "Request") // UpdateUserRequest + /// entity.ident_with("", "Response") // UserResponse + /// entity.ident_with("", "Row") // UserRow + /// entity.ident_with("Insertable", "") // InsertableUser + /// entity.ident_with("", "Repository") // UserRepository + /// ``` + pub fn ident_with(&self, prefix: &str, suffix: &str) -> Ident { + Ident::new( + &format!("{}{}{}", prefix, self.name_str(), suffix), + Span::call_site() + ) + } + + /// Get the error type for repository implementation. + pub fn error_type(&self) -> &syn::Path { + &self.error + } + + /// Check if soft delete is enabled for this entity. + pub fn is_soft_delete(&self) -> bool { + self.soft_delete + } + + /// Check if lifecycle events should be generated. + pub fn has_events(&self) -> bool { + self.events + } + + /// Check if lifecycle hooks trait should be generated. + pub fn has_hooks(&self) -> bool { + self.hooks + } + + /// Check if CQRS-style commands should be generated. + pub fn has_commands(&self) -> bool { + self.commands + } + + /// Get command definitions. + pub fn command_defs(&self) -> &[CommandDef] { + &self.command_defs + } + + /// Check if authorization policy should be generated. + pub fn has_policy(&self) -> bool { + self.policy + } + + /// Check if real-time streaming should be enabled. + pub fn has_streams(&self) -> bool { + self.streams + } + + /// Check if transaction support should be generated. + pub fn has_transactions(&self) -> bool { + self.transactions + } + + /// Check if API generation is enabled. + #[allow(dead_code)] + pub fn has_api(&self) -> bool { + self.api_config.is_enabled() + } + + /// Get API configuration. + #[allow(dead_code)] + pub fn api_config(&self) -> &ApiConfig { + &self.api_config + } + + /// Get the documentation comment if present. + #[must_use] + #[allow(dead_code)] + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs new file mode 100644 index 0000000..58dc666 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! EntityDef constructor (from_derive_input). + +use darling::FromDeriveInput; +use syn::DeriveInput; + +use super::{ + super::{command::parse_command_attrs, field::FieldDef}, + EntityAttrs, EntityDef, + helpers::{parse_api_attr, parse_has_many_attrs}, + parse_projection_attrs +}; +use crate::utils::docs::extract_doc_comments; + +impl EntityDef { + /// Parse entity definition from syn's `DeriveInput`. + /// + /// This is the main entry point for parsing. It: + /// + /// 1. Parses entity-level attributes using darling + /// 2. Extracts all named fields from the struct + /// 3. Parses field-level attributes for each field + /// 4. Combines everything into an `EntityDef` + /// + /// # Arguments + /// + /// * `input` - Parsed derive input from syn + /// + /// # Returns + /// + /// `Ok(EntityDef)` on success, or `Err` with darling errors. + /// + /// # Errors + /// + /// - Missing `table` attribute + /// - Applied to non-struct (enum, union) + /// - Applied to tuple struct or unit struct + /// - Invalid attribute values + /// + /// # Example + /// + /// ```rust,ignore + /// pub fn derive(input: TokenStream) -> TokenStream { + /// let input = parse_macro_input!(input as DeriveInput); + /// + /// match EntityDef::from_derive_input(&input) { + /// Ok(entity) => generate(entity), + /// Err(err) => err.write_errors().into() + /// } + /// } + /// ``` + pub fn from_derive_input(input: &DeriveInput) -> darling::Result { + let attrs = EntityAttrs::from_derive_input(input)?; + + let fields: Vec = match &input.data { + syn::Data::Struct(data) => match &data.fields { + syn::Fields::Named(named) => named + .named + .iter() + .map(FieldDef::from_field) + .collect::>>()?, + _ => { + return Err(darling::Error::custom("Entity requires named fields") + .with_span(&input.ident)); + } + }, + _ => { + return Err( + darling::Error::custom("Entity can only be derived for structs") + .with_span(&input.ident) + ); + } + }; + + let has_many = parse_has_many_attrs(&input.attrs); + let projections = parse_projection_attrs(&input.attrs); + let command_defs = parse_command_attrs(&input.attrs); + let api_config = parse_api_attr(&input.attrs); + let doc = extract_doc_comments(&input.attrs); + + let id_field_index = fields.iter().position(|f| f.is_id()).ok_or_else(|| { + darling::Error::custom("Entity must have exactly one field with #[id] attribute") + .with_span(&input.ident) + })?; + + Ok(Self { + ident: attrs.ident, + vis: attrs.vis, + table: attrs.table, + schema: attrs.schema, + sql: attrs.sql, + dialect: attrs.dialect, + uuid: attrs.uuid, + error: attrs.error, + fields, + id_field_index, + has_many, + projections, + soft_delete: attrs.soft_delete, + returning: attrs.returning, + events: attrs.events, + hooks: attrs.hooks, + commands: attrs.commands, + command_defs, + policy: attrs.policy, + streams: attrs.streams, + transactions: attrs.transactions, + api_config, + doc + }) + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity/def.rs b/crates/entity-derive-impl/src/entity/parse/entity/def.rs new file mode 100644 index 0000000..9f807eb --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! EntityDef struct definition. + +use syn::{Ident, Visibility}; + +use super::{ + super::{ + api::ApiConfig, command::CommandDef, dialect::DatabaseDialect, field::FieldDef, + returning::ReturningMode, sql_level::SqlLevel, uuid_version::UuidVersion + }, + ProjectionDef +}; + +/// Complete parsed entity definition. +/// +/// This is the main data structure passed to all code generators. +/// It contains both entity-level metadata and all field definitions. +/// +/// # Construction +/// +/// Create via [`EntityDef::from_derive_input`]: +/// +/// ```rust,ignore +/// let entity = EntityDef::from_derive_input(&input)?; +/// ``` +/// +/// # Field Access +/// +/// Use the provided methods to access fields by category: +/// +/// ```rust,ignore +/// // All fields for Row/Insertable +/// let all = entity.all_fields(); +/// +/// // Fields for specific DTOs +/// let create_fields = entity.create_fields(); +/// let update_fields = entity.update_fields(); +/// let response_fields = entity.response_fields(); +/// +/// // Primary key field (guaranteed to exist) +/// let id = entity.id_field(); +/// ``` +#[derive(Debug)] +pub struct EntityDef { + /// Struct identifier (e.g., `User`). + pub ident: Ident, + + /// Struct visibility. + /// + /// Propagated to all generated types so they have the same + /// visibility as the source entity. + pub vis: Visibility, + + /// Database table name (e.g., `"users"`). + pub table: String, + + /// Database schema name (e.g., `"public"`, `"core"`). + pub schema: String, + + /// SQL generation level controlling what code is generated. + pub sql: SqlLevel, + + /// Database dialect for code generation. + pub dialect: DatabaseDialect, + + /// UUID version for ID generation. + pub uuid: UuidVersion, + + /// Custom error type for repository implementation. + /// + /// Defaults to `sqlx::Error`. Custom types must implement + /// `From` for the `?` operator to work. + pub error: syn::Path, + + /// All field definitions from the struct. + pub fields: Vec, + + /// Index of the primary key field in `fields`. + /// + /// Validated at parse time to always be valid. + pub(super) id_field_index: usize, + + /// Has-many relations defined via `#[has_many(Entity)]`. + /// + /// Each entry is the related entity name. + pub has_many: Vec, + + /// Projections defined via `#[projection(Name: field1, field2)]`. + /// + /// Each projection defines a subset of fields for a specific view. + pub projections: Vec, + + /// Whether soft delete is enabled. + /// + /// When `true`, the `delete` method sets `deleted_at` instead of removing + /// the row, and all queries filter out records where `deleted_at IS NOT + /// NULL`. + pub soft_delete: bool, + + /// RETURNING clause mode for INSERT/UPDATE operations. + /// + /// Controls what data is fetched back from the database after writes. + pub returning: ReturningMode, + + /// Whether to generate lifecycle events. + /// + /// When `true`, generates a `{Entity}Event` enum with variants for + /// Created, Updated, Deleted, etc. + pub events: bool, + + /// Whether to generate lifecycle hooks trait. + /// + /// When `true`, generates a `{Entity}Hooks` trait with before/after + /// methods for CRUD operations. + pub hooks: bool, + + /// Whether to generate CQRS-style commands. + /// + /// When `true`, processes `#[command(...)]` attributes. + pub commands: bool, + + /// Command definitions parsed from `#[command(...)]` attributes. + /// + /// Each entry describes a business command (e.g., Register, UpdateEmail). + pub command_defs: Vec, + + /// Whether to generate authorization policy trait. + /// + /// When `true`, generates `{Entity}Policy` trait and related types. + pub policy: bool, + + /// Whether to enable real-time streaming. + /// + /// When `true`, generates `{Entity}Subscriber` and NOTIFY calls. + pub streams: bool, + + /// Whether to generate transaction support. + /// + /// When `true`, generates transaction repository adapter and builder + /// methods. + pub transactions: bool, + + /// API configuration for HTTP handler generation. + /// + /// When enabled via `#[entity(api(...))]`, generates axum handlers + /// with OpenAPI documentation via utoipa. + pub api_config: ApiConfig, + + /// Documentation comment from the entity struct. + /// + /// Extracted from `///` comments for use in OpenAPI tag descriptions. + pub doc: Option +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs new file mode 100644 index 0000000..426f4ff --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Helper functions for entity parsing. + +use syn::{Attribute, Ident}; + +use super::super::api::{ApiConfig, parse_api_config}; + +/// Parse `#[has_many(Entity)]` attributes from struct attributes. +/// +/// Extracts all has-many relation definitions from the struct's attributes. +/// Each attribute specifies a related entity type for one-to-many +/// relationships. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes from the struct +/// +/// # Returns +/// +/// Vector of related entity identifiers. +/// +/// # Example +/// +/// ```rust,ignore +/// // For a User entity with posts and comments: +/// #[has_many(Post)] +/// #[has_many(Comment)] +/// struct User { ... } +/// +/// // Returns: vec![Ident("Post"), Ident("Comment")] +/// ``` +pub fn parse_has_many_attrs(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter(|attr| attr.path().is_ident("has_many")) + .filter_map(|attr| attr.parse_args::().ok()) + .collect() +} + +/// Parse `api(...)` from `#[entity(...)]` attribute. +/// +/// Searches for the `api` key within the entity attribute and parses +/// its nested configuration. +/// +/// # Arguments +/// +/// * `attrs` - Slice of syn Attributes from the struct +/// +/// # Returns +/// +/// `ApiConfig` with parsed values, or default if not present. +pub fn parse_api_attr(attrs: &[Attribute]) -> ApiConfig { + for attr in attrs { + if !attr.path().is_ident("entity") { + continue; + } + + let result: syn::Result> = + attr.parse_args_with(|input: syn::parse::ParseStream<'_>| { + while !input.is_empty() { + let ident: Ident = input.parse()?; + + if ident == "api" { + let content; + syn::parenthesized!(content in input); + + let tokens = content.parse::()?; + let meta_list = syn::Meta::List(syn::MetaList { + path: syn::parse_quote!(api), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens + }); + + if let Ok(config) = parse_api_config(&meta_list) { + return Ok(Some(config)); + } + } else if input.peek(syn::Token![=]) { + let _: syn::Token![=] = input.parse()?; + let _ = input.parse::()?; + } else if input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in input); + let _ = content.parse::()?; + } + + if input.peek(syn::Token![,]) { + let _: syn::Token![,] = input.parse()?; + } + } + Ok(None) + }); + + if let Ok(Some(config)) = result { + return config; + } + } + + ApiConfig::default() +} diff --git a/crates/entity-derive-impl/src/entity/parse/entity/tests.rs b/crates/entity-derive-impl/src/entity/parse/entity/tests.rs new file mode 100644 index 0000000..711d5d5 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/tests.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Tests for entity parsing. + +use syn::DeriveInput; + +use super::{EntityDef, attrs::default_error_type}; + +#[test] +fn default_error_type_is_sqlx_error() { + let path = default_error_type(); + let path_str = quote::quote!(#path).to_string(); + assert!(path_str.contains("sqlx")); + assert!(path_str.contains("Error")); +} + +#[test] +fn entity_def_error_type_accessor() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let error_path = entity.error_type(); + let path_str = quote::quote!(#error_path).to_string(); + assert!(path_str.contains("sqlx")); +} + +#[test] +fn entity_def_without_api() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(!entity.has_api()); +} + +#[test] +fn entity_def_with_api() { + let input: DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(entity.has_api()); + assert_eq!(entity.api_config().tag, Some("Users".to_string())); +} + +#[test] +fn entity_def_with_full_api_config() { + let input: DeriveInput = syn::parse_quote! { + #[entity( + table = "users", + api( + tag = "Users", + tag_description = "User management", + path_prefix = "/api/v1", + security = "bearer" + ) + )] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + assert!(entity.has_api()); + let config = entity.api_config(); + assert_eq!(config.tag, Some("Users".to_string())); + assert_eq!(config.tag_description, Some("User management".to_string())); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + assert_eq!(config.security, Some("bearer".to_string())); +} + +#[test] +fn entity_def_api_with_public_commands() { + let input: DeriveInput = syn::parse_quote! { + #[entity( + table = "users", + api(tag = "Users", security = "bearer", public = [Register, Login]) + )] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let config = entity.api_config(); + assert!(config.is_public_command("Register")); + assert!(config.is_public_command("Login")); + assert!(!config.is_public_command("Update")); + assert_eq!(config.security_for_command("Register"), None); + assert_eq!(config.security_for_command("Update"), Some("bearer")); +} From d441925b6686670ab87d05533c38d13fa9b2e160 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 15:18:40 +0700 Subject: [PATCH 25/30] docs: add comprehensive module documentation Add tokio-level documentation to all parse/ and api/ submodules: - crud/: delete, get, list, update handler generators - openapi/: info, paths, schemas, security modules - parse/api/: config, parser modules - parse/command/: types, parser modules - parse/entity/: def, accessors, constructor, helpers, projection Each module now includes: - Architecture diagrams showing data flow - Type hierarchy tables - Usage examples - Function reference tables --- .../src/entity/api/crud/delete.rs | 148 ++++++++- .../src/entity/api/crud/get.rs | 132 +++++++- .../src/entity/api/crud/list.rs | 197 +++++++++++- .../src/entity/api/crud/update.rs | 147 ++++++++- .../src/entity/api/openapi/info.rs | 161 +++++++++- .../src/entity/api/openapi/mod.rs | 237 +++++++++++++- .../src/entity/api/openapi/paths.rs | 296 +++++++++++++++++- .../src/entity/api/openapi/schemas.rs | 197 +++++++++++- .../src/entity/api/openapi/security.rs | 202 +++++++++++- .../src/entity/api/openapi/tests.rs | 29 ++ .../src/entity/parse/api/config.rs | 164 +++++++++- .../src/entity/parse/api/mod.rs | 134 +++++++- .../src/entity/parse/api/parser.rs | 140 ++++++++- .../src/entity/parse/api/tests.rs | 32 ++ .../src/entity/parse/command/mod.rs | 141 ++++++++- .../src/entity/parse/command/parser.rs | 128 +++++++- .../src/entity/parse/command/tests.rs | 44 ++- .../src/entity/parse/command/types.rs | 91 +++++- .../src/entity/parse/entity.rs | 139 ++++++-- .../src/entity/parse/entity/accessors.rs | 52 +++ .../src/entity/parse/entity/constructor.rs | 54 +++- .../src/entity/parse/entity/def.rs | 53 ++++ .../src/entity/parse/entity/helpers.rs | 55 +++- .../src/entity/parse/entity/projection.rs | 51 ++- .../src/entity/parse/entity/tests.rs | 41 +++ 25 files changed, 2937 insertions(+), 128 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/api/crud/delete.rs b/crates/entity-derive-impl/src/entity/api/crud/delete.rs index b3ba4e2..a9269d0 100644 --- a/crates/entity-derive-impl/src/entity/api/crud/delete.rs +++ b/crates/entity-derive-impl/src/entity/api/crud/delete.rs @@ -1,7 +1,105 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Delete handler generation. +//! DELETE handler generation for removing entities. +//! +//! This module generates the `delete_{entity}` HTTP handler function +//! that removes entities from the database. +//! +//! # Generated Handler +//! +//! For an entity `User`, generates: +//! +//! ```rust,ignore +//! /// Delete User by ID. +//! /// +//! /// # Path Parameters +//! /// +//! /// - `id` - The unique identifier of the User to delete +//! /// +//! /// # Responses +//! /// +//! /// - `204 No Content` - User deleted successfully +//! /// - `401 Unauthorized` - Authentication required (if security enabled) +//! /// - `404 Not Found` - User with given ID does not exist +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! delete, +//! path = "/users/{id}", +//! tag = "Users", +//! params(("id" = Uuid, Path, description = "User ID")), +//! responses( +//! (status = 204, description = "User deleted"), +//! (status = 401, description = "Authentication required"), +//! (status = 404, description = "User not found"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn delete_user( +//! State(repo): State>, +//! Path(id): Path, +//! ) -> AppResult +//! where +//! R: UserRepository + 'static, +//! { +//! let deleted = repo +//! .delete(id) +//! .await +//! .map_err(|e| AppError::internal(e.to_string()))?; +//! if deleted { +//! Ok(StatusCode::NO_CONTENT) +//! } else { +//! Err(AppError::not_found("User not found")) +//! } +//! } +//! ``` +//! +//! # Soft Delete vs Hard Delete +//! +//! The actual deletion behavior depends on the entity's configuration: +//! +//! | Configuration | SQL Generated | Effect | +//! |---------------|---------------|--------| +//! | Default | `DELETE FROM table WHERE id = $1` | Row removed | +//! | `soft_delete` | `UPDATE table SET deleted_at = NOW()` | Row marked | +//! +//! Soft delete is enabled via `#[entity(soft_delete)]` and requires +//! a `deleted_at: Option` field. +//! +//! # Request Flow +//! +//! ```text +//! Client Handler Repository Database +//! │ │ │ │ +//! │ DELETE /users/{id} │ │ │ +//! │─────────────────────>│ │ │ +//! │ │ │ │ +//! │ │ repo.delete(id) │ │ +//! │ │─────────────────────>│ │ +//! │ │ │ │ +//! │ │ │ DELETE/UPDATE │ +//! │ │ │──────────────────>│ +//! │ │ │ │ +//! │ │ │<──────────────────│ +//! │ │ │ rows_affected │ +//! │ │<─────────────────────│ │ +//! │ │ bool │ │ +//! │ │ │ │ +//! │<─────────────────────│ │ │ +//! │ 204 No Content / 404 │ │ │ +//! ``` +//! +//! # Response Codes +//! +//! | Code | Meaning | Body | +//! |------|---------|------| +//! | 204 | Successfully deleted | Empty | +//! | 401 | Not authenticated | Error JSON | +//! | 404 | Entity not found | Error JSON | +//! | 500 | Database error | Error JSON | +//! +//! Note: 204 No Content has no response body per HTTP spec. use convert_case::{Case, Casing}; use proc_macro2::TokenStream; @@ -10,7 +108,53 @@ use quote::{format_ident, quote}; use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; use crate::entity::parse::EntityDef; -/// Generate the delete handler. +/// Generates the DELETE handler for removing entities. +/// +/// Creates a handler function that: +/// +/// 1. Extracts entity ID from URL path parameter +/// 2. Calls `repository.delete(id)` to remove the entity +/// 3. Returns `204 No Content` on success or `404 Not Found` +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A `TokenStream` containing the complete handler function with: +/// - Doc comments describing the endpoint +/// - `#[utoipa::path]` attribute for OpenAPI documentation +/// - The async handler function implementation +/// +/// # Generated Components +/// +/// | Component | Description | +/// |-----------|-------------| +/// | Function name | `delete_{entity_snake}` (e.g., `delete_user`) | +/// | Path | Item path with `{id}` (e.g., `/users/{id}`) | +/// | Method | DELETE | +/// | Path parameter | `id` with entity's ID type | +/// | Response | `StatusCode::NO_CONTENT` (204) | +/// | Status codes | 204, 401 (if auth), 404, 500 | +/// +/// # Return Type +/// +/// Unlike other handlers, DELETE returns only a status code: +/// +/// ```rust,ignore +/// -> AppResult // Not Json<...> +/// ``` +/// +/// This follows REST conventions where successful DELETE returns +/// 204 No Content with an empty body. +/// +/// # Repository Contract +/// +/// The `repository.delete(id)` method returns `Result`: +/// - `Ok(true)` - Entity was found and deleted +/// - `Ok(false)` - Entity with given ID doesn't exist +/// - `Err(e)` - Database error occurred pub fn generate_delete_handler(entity: &EntityDef) -> TokenStream { let vis = &entity.vis; let entity_name = entity.name(); diff --git a/crates/entity-derive-impl/src/entity/api/crud/get.rs b/crates/entity-derive-impl/src/entity/api/crud/get.rs index 03df73b..a494bce 100644 --- a/crates/entity-derive-impl/src/entity/api/crud/get.rs +++ b/crates/entity-derive-impl/src/entity/api/crud/get.rs @@ -1,7 +1,93 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Get handler generation. +//! GET handler generation for retrieving entities by ID. +//! +//! This module generates the `get_{entity}` HTTP handler function +//! that fetches a single entity by its primary key. +//! +//! # Generated Handler +//! +//! For an entity `User`, generates: +//! +//! ```rust,ignore +//! /// Get User by ID. +//! /// +//! /// # Path Parameters +//! /// +//! /// - `id` - The unique identifier of the User +//! /// +//! /// # Responses +//! /// +//! /// - `200 OK` - User found +//! /// - `401 Unauthorized` - Authentication required (if security enabled) +//! /// - `404 Not Found` - User with given ID does not exist +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! get, +//! path = "/users/{id}", +//! tag = "Users", +//! params(("id" = Uuid, Path, description = "User ID")), +//! responses( +//! (status = 200, description = "User found", body = UserResponse), +//! (status = 401, description = "Authentication required"), +//! (status = 404, description = "User not found"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn get_user( +//! State(repo): State>, +//! Path(id): Path, +//! ) -> AppResult> +//! where +//! R: UserRepository + 'static, +//! { +//! let entity = repo +//! .find_by_id(id) +//! .await +//! .map_err(|e| AppError::internal(e.to_string()))? +//! .ok_or_else(|| AppError::not_found("User not found"))?; +//! Ok(Json(UserResponse::from(entity))) +//! } +//! ``` +//! +//! # Request Flow +//! +//! ```text +//! Client Handler Repository Database +//! │ │ │ │ +//! │ GET /users/{id} │ │ │ +//! │─────────────────────>│ │ │ +//! │ │ │ │ +//! │ │ repo.find_by_id(id) │ │ +//! │ │─────────────────────>│ │ +//! │ │ │ │ +//! │ │ │ SELECT * WHERE id │ +//! │ │ │──────────────────>│ +//! │ │ │ │ +//! │ │ │<──────────────────│ +//! │ │ │ Option │ +//! │ │<─────────────────────│ │ +//! │ │ Option │ │ +//! │ │ │ │ +//! │<─────────────────────│ │ │ +//! │ 200 OK / 404 │ │ │ +//! │ UserResponse │ │ │ +//! ``` +//! +//! # Error Handling +//! +//! The handler distinguishes between two error cases: +//! +//! | Case | Response | Description | +//! |------|----------|-------------| +//! | Database error | 500 | Query failed (connection, timeout, etc.) | +//! | Not found | 404 | Entity with given ID doesn't exist | +//! +//! The `Option` from the repository is converted: +//! - `Some(entity)` → 200 OK with response body +//! - `None` → 404 Not Found error use convert_case::{Case, Casing}; use proc_macro2::TokenStream; @@ -10,7 +96,49 @@ use quote::{format_ident, quote}; use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; use crate::entity::parse::EntityDef; -/// Generate the get handler. +/// Generates the GET handler for retrieving a single entity by ID. +/// +/// Creates a handler function that: +/// +/// 1. Extracts entity ID from URL path parameter +/// 2. Calls `repository.find_by_id(id)` to fetch the entity +/// 3. Returns `200 OK` with entity data or `404 Not Found` +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A `TokenStream` containing the complete handler function with: +/// - Doc comments describing the endpoint +/// - `#[utoipa::path]` attribute for OpenAPI documentation +/// - The async handler function implementation +/// +/// # Generated Components +/// +/// | Component | Description | +/// |-----------|-------------| +/// | Function name | `get_{entity_snake}` (e.g., `get_user`) | +/// | Path | Item path with `{id}` (e.g., `/users/{id}`) | +/// | Method | GET | +/// | Path parameter | `id` with entity's ID type | +/// | Response body | `{Entity}Response` | +/// | Status codes | 200, 401 (if auth), 404, 500 | +/// +/// # Path Parameter +/// +/// The `{id}` path parameter type is derived from the entity's `#[id]` field: +/// +/// - `Uuid` for UUID primary keys +/// - `i32`/`i64` for integer primary keys +/// - Custom types are also supported +/// +/// # Security Handling +/// +/// When security is configured: +/// - Adds `401 Unauthorized` to response list +/// - Includes security requirement in OpenAPI spec pub fn generate_get_handler(entity: &EntityDef) -> TokenStream { let vis = &entity.vis; let entity_name = entity.name(); diff --git a/crates/entity-derive-impl/src/entity/api/crud/list.rs b/crates/entity-derive-impl/src/entity/api/crud/list.rs index 8bd9274..29232cc 100644 --- a/crates/entity-derive-impl/src/entity/api/crud/list.rs +++ b/crates/entity-derive-impl/src/entity/api/crud/list.rs @@ -1,7 +1,123 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! List handler generation. +//! GET handler generation for listing entities with pagination. +//! +//! This module generates the `list_{entity}` HTTP handler function +//! that returns a paginated list of all entities. +//! +//! # Generated Handler +//! +//! For an entity `User`, generates: +//! +//! ```rust,ignore +//! /// Pagination query parameters. +//! #[derive(Debug, Clone, Deserialize, IntoParams)] +//! pub struct PaginationQuery { +//! /// Maximum number of items to return. +//! #[serde(default = "default_limit")] +//! pub limit: i64, +//! /// Number of items to skip for pagination. +//! #[serde(default)] +//! pub offset: i64, +//! } +//! +//! fn default_limit() -> i64 { 100 } +//! +//! /// List User entities with pagination. +//! /// +//! /// # Query Parameters +//! /// +//! /// - `limit` - Maximum items to return (default: 100) +//! /// - `offset` - Items to skip for pagination +//! /// +//! /// # Responses +//! /// +//! /// - `200 OK` - List of User entities +//! /// - `401 Unauthorized` - Authentication required (if security enabled) +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! get, +//! path = "/users", +//! tag = "Users", +//! params( +//! ("limit" = Option, Query, description = "Max items"), +//! ("offset" = Option, Query, description = "Items to skip") +//! ), +//! responses( +//! (status = 200, description = "List of users", body = Vec), +//! (status = 401, description = "Authentication required"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn list_user( +//! State(repo): State>, +//! Query(pagination): Query, +//! ) -> AppResult>> +//! where +//! R: UserRepository + 'static, +//! { ... } +//! ``` +//! +//! # Pagination +//! +//! The handler supports offset-based pagination via query parameters: +//! +//! | Parameter | Type | Default | Description | +//! |-----------|------|---------|-------------| +//! | `limit` | `i64` | `100` | Maximum items per page | +//! | `offset` | `i64` | `0` | Items to skip | +//! +//! ## Usage Examples +//! +//! ```text +//! GET /users # First 100 users +//! GET /users?limit=10 # First 10 users +//! GET /users?offset=10 # Users 11-110 +//! GET /users?limit=10&offset=20 # Users 21-30 +//! ``` +//! +//! # Request Flow +//! +//! ```text +//! Client Handler Repository Database +//! │ │ │ │ +//! │ GET /users?limit=10 │ │ │ +//! │─────────────────────>│ │ │ +//! │ │ │ │ +//! │ │ repo.list(10, 0) │ │ +//! │ │─────────────────────>│ │ +//! │ │ │ │ +//! │ │ │ SELECT * LIMIT 10 │ +//! │ │ │──────────────────>│ +//! │ │ │ │ +//! │ │ │<──────────────────│ +//! │ │ │ Vec │ +//! │ │<─────────────────────│ │ +//! │ │ Vec │ │ +//! │ │ │ │ +//! │<─────────────────────│ │ │ +//! │ 200 OK │ │ │ +//! │ [UserResponse, ...] │ │ │ +//! ``` +//! +//! # Response Format +//! +//! Returns a JSON array of entity responses: +//! +//! ```json +//! [ +//! { "id": "uuid-1", "name": "Alice", ... }, +//! { "id": "uuid-2", "name": "Bob", ... } +//! ] +//! ``` +//! +//! # Performance Considerations +//! +//! - Default limit of 100 prevents unbounded queries +//! - Offset pagination can be slow for large offsets +//! - Consider cursor-based pagination for very large datasets use convert_case::{Case, Casing}; use proc_macro2::TokenStream; @@ -10,7 +126,57 @@ use quote::{format_ident, quote}; use super::helpers::{build_collection_path, build_deprecated_attr, build_security_attr}; use crate::entity::parse::EntityDef; -/// Generate the list handler. +/// Generates the GET handler for listing entities with pagination. +/// +/// Creates a handler function that: +/// +/// 1. Accepts `limit` and `offset` query parameters +/// 2. Calls `repository.list(limit, offset)` to fetch entities +/// 3. Returns `200 OK` with array of entity responses +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A `TokenStream` containing: +/// - `PaginationQuery` struct with serde derives +/// - `default_limit()` helper function +/// - The async handler function with OpenAPI annotations +/// +/// # Generated Components +/// +/// | Component | Description | +/// |-----------|-------------| +/// | Function name | `list_{entity_snake}` (e.g., `list_user`) | +/// | Path | Collection path (e.g., `/users`) | +/// | Method | GET | +/// | Query params | `limit` (default 100), `offset` (default 0) | +/// | Response body | `Vec<{Entity}Response>` | +/// | Status codes | 200, 401 (if auth), 500 | +/// +/// # PaginationQuery Struct +/// +/// A helper struct is generated alongside the handler: +/// +/// ```rust,ignore +/// #[derive(Debug, Clone, Deserialize, IntoParams)] +/// pub struct PaginationQuery { +/// #[serde(default = "default_limit")] +/// pub limit: i64, +/// #[serde(default)] +/// pub offset: i64, +/// } +/// ``` +/// +/// This struct implements `utoipa::IntoParams` for OpenAPI documentation. +/// +/// # Default Limit +/// +/// The default limit of 100 items prevents accidental full-table scans. +/// Clients can override this but should implement proper pagination +/// for large datasets. pub fn generate_list_handler(entity: &EntityDef) -> TokenStream { let vis = &entity.vis; let entity_name = entity.name(); @@ -87,17 +253,42 @@ pub fn generate_list_handler(entity: &EntityDef) -> TokenStream { ); quote! { - /// Pagination query parameters. + /// Pagination query parameters for list endpoints. + /// + /// Supports offset-based pagination with configurable page size. + /// + /// # Fields + /// + /// - `limit` - Maximum items per page (default: 100) + /// - `offset` - Items to skip (default: 0) + /// + /// # Example + /// + /// ```text + /// GET /users?limit=10&offset=20 + /// ``` #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] #vis struct PaginationQuery { /// Maximum number of items to return. + /// + /// Defaults to 100 if not specified. Use reasonable limits + /// to prevent performance issues with large datasets. #[serde(default = "default_limit")] pub limit: i64, + /// Number of items to skip for pagination. + /// + /// Defaults to 0 (start from beginning). Use with `limit` + /// to implement page-based navigation. #[serde(default)] pub offset: i64, } + /// Returns the default pagination limit. + /// + /// This value (100) balances usability with performance, + /// preventing accidental full-table scans while allowing + /// reasonable batch sizes. fn default_limit() -> i64 { 100 } #[doc = #doc] diff --git a/crates/entity-derive-impl/src/entity/api/crud/update.rs b/crates/entity-derive-impl/src/entity/api/crud/update.rs index 95d8345..e46293a 100644 --- a/crates/entity-derive-impl/src/entity/api/crud/update.rs +++ b/crates/entity-derive-impl/src/entity/api/crud/update.rs @@ -1,7 +1,107 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Update handler generation. +//! PATCH handler generation for updating existing entities. +//! +//! This module generates the `update_{entity}` HTTP handler function +//! that performs partial updates on existing entities. +//! +//! # Generated Handler +//! +//! For an entity `User`, generates: +//! +//! ```rust,ignore +//! /// Update User by ID. +//! /// +//! /// # Path Parameters +//! /// +//! /// - `id` - The unique identifier of the User to update +//! /// +//! /// # Responses +//! /// +//! /// - `200 OK` - User updated successfully +//! /// - `400 Bad Request` - Invalid request data +//! /// - `401 Unauthorized` - Authentication required (if security enabled) +//! /// - `404 Not Found` - User with given ID does not exist +//! /// - `500 Internal Server Error` - Database or server error +//! #[utoipa::path( +//! patch, +//! path = "/users/{id}", +//! tag = "Users", +//! params(("id" = Uuid, Path, description = "User ID")), +//! request_body(content = UpdateUserRequest, description = "..."), +//! responses( +//! (status = 200, description = "User updated", body = UserResponse), +//! (status = 400, description = "Invalid request data"), +//! (status = 401, description = "Authentication required"), +//! (status = 404, description = "User not found"), +//! (status = 500, description = "Internal server error") +//! ), +//! security(("bearerAuth" = [])) +//! )] +//! pub async fn update_user( +//! State(repo): State>, +//! Path(id): Path, +//! Json(dto): Json, +//! ) -> AppResult> +//! where +//! R: UserRepository + 'static, +//! { ... } +//! ``` +//! +//! # PATCH vs PUT Semantics +//! +//! This handler uses PATCH (partial update) semantics: +//! +//! | Method | Semantics | UpdateRequest Fields | +//! |--------|-----------|---------------------| +//! | PATCH | Partial update | All fields `Option` | +//! | PUT | Full replacement | All fields required | +//! +//! The `UpdateUserRequest` DTO has optional fields, allowing clients +//! to update only specific fields: +//! +//! ```json +//! // Only update name, leave email unchanged +//! { "name": "New Name" } +//! +//! // Update both fields +//! { "name": "New Name", "email": "new@example.com" } +//! ``` +//! +//! # Request Flow +//! +//! ```text +//! Client Handler Repository Database +//! │ │ │ │ +//! │ PATCH /users/{id} │ │ │ +//! │ UpdateUserRequest │ │ │ +//! │─────────────────────>│ │ │ +//! │ │ │ │ +//! │ │ repo.update(id, dto) │ │ +//! │ │─────────────────────>│ │ +//! │ │ │ │ +//! │ │ │ UPDATE users SET │ +//! │ │ │──────────────────>│ +//! │ │ │ │ +//! │ │ │<──────────────────│ +//! │ │ │ UserRow │ +//! │ │<─────────────────────│ │ +//! │ │ User │ │ +//! │ │ │ │ +//! │<─────────────────────│ │ │ +//! │ 200 OK │ │ │ +//! │ UserResponse │ │ │ +//! ``` +//! +//! # Error Handling +//! +//! | Case | Response | Description | +//! |------|----------|-------------| +//! | Invalid JSON | 400 | Request body parsing failed | +//! | Validation error | 400 | Field constraints violated | +//! | Not authenticated | 401 | Missing or invalid token | +//! | Database error | 500 | Query execution failed | use convert_case::{Case, Casing}; use proc_macro2::TokenStream; @@ -10,7 +110,50 @@ use quote::{format_ident, quote}; use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; use crate::entity::parse::EntityDef; -/// Generate the update handler. +/// Generates the PATCH handler for updating existing entities. +/// +/// Creates a handler function that: +/// +/// 1. Extracts entity ID from URL path parameter +/// 2. Accepts `UpdateEntityRequest` in JSON body +/// 3. Calls `repository.update(id, dto)` to persist changes +/// 4. Returns `200 OK` with updated entity +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A `TokenStream` containing the complete handler function with: +/// - Doc comments describing the endpoint +/// - `#[utoipa::path]` attribute for OpenAPI documentation +/// - The async handler function implementation +/// +/// # Generated Components +/// +/// | Component | Description | +/// |-----------|-------------| +/// | Function name | `update_{entity_snake}` (e.g., `update_user`) | +/// | Path | Item path with `{id}` (e.g., `/users/{id}`) | +/// | Method | PATCH | +/// | Path parameter | `id` with entity's ID type | +/// | Request body | `Update{Entity}Request` | +/// | Response body | `{Entity}Response` | +/// | Status codes | 200, 400, 401 (if auth), 500 | +/// +/// # UpdateRequest Generation +/// +/// The `UpdateEntityRequest` is generated separately with all fields +/// marked with `#[field(update)]` as `Option`: +/// +/// ```rust,ignore +/// #[derive(Debug, Deserialize, ToSchema)] +/// pub struct UpdateUserRequest { +/// pub name: Option, // from #[field(update)] +/// pub email: Option, // from #[field(update)] +/// } +/// ``` pub fn generate_update_handler(entity: &EntityDef) -> TokenStream { let vis = &entity.vis; let entity_name = entity.name(); diff --git a/crates/entity-derive-impl/src/entity/api/openapi/info.rs b/crates/entity-derive-impl/src/entity/api/openapi/info.rs index fa3d33a..ec4babc 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/info.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/info.rs @@ -1,19 +1,170 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! OpenAPI info section generation. +//! OpenAPI Info section generation. //! -//! Generates code to configure the OpenAPI info section including title, -//! description, version, license, contact information, and deprecation status. +//! This module generates code to configure the OpenAPI specification's info +//! object, which provides metadata about the API. The info section is required +//! by OpenAPI 3.0+ and appears at the top level of the specification. +//! +//! # OpenAPI Info Object +//! +//! According to the OpenAPI 3.0 specification, the info object contains: +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ OpenAPI Info Object │ +//! ├─────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Required Fields │ +//! │ ├─► title: API name displayed in Swagger UI │ +//! │ └─► version: API version string (e.g., "1.0.0") │ +//! │ │ +//! │ Optional Fields │ +//! │ ├─► description: Detailed API description (markdown) │ +//! │ ├─► license: License information │ +//! │ │ ├─► name: License name (e.g., "MIT") │ +//! │ │ └─► url: License URL │ +//! │ └─► contact: API maintainer information │ +//! │ ├─► name: Contact person/organization │ +//! │ ├─► email: Support email │ +//! │ └─► url: Support website │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Configuration Sources +//! +//! Info fields are populated from the `#[entity(api(...))]` attribute: +//! +//! | Attribute | Info Field | Default | +//! |-----------|------------|---------| +//! | `title` | `info.title` | None | +//! | `description` | `info.description` | Entity doc comment | +//! | `api_version` | `info.version` | None | +//! | `license` | `info.license.name` | None | +//! | `license_url` | `info.license.url` | None | +//! | `contact_name` | `info.contact.name` | None | +//! | `contact_email` | `info.contact.email` | None | +//! | `contact_url` | `info.contact.url` | None | +//! +//! # Generated Code Example +//! +//! For an entity with full info configuration: +//! +//! ```rust,ignore +//! #[entity( +//! table = "users", +//! api( +//! title = "User API", +//! description = "Manage user accounts", +//! api_version = "2.0.0", +//! license = "MIT", +//! license_url = "https://opensource.org/licenses/MIT", +//! contact_name = "API Team", +//! contact_email = "api@example.com", +//! handlers +//! ) +//! )] +//! pub struct User { ... } +//! ``` +//! +//! Generates: +//! +//! ```rust,ignore +//! openapi.info.title = "User API".to_string(); +//! openapi.info.description = Some("Manage user accounts".to_string()); +//! openapi.info.version = "2.0.0".to_string(); +//! openapi.info.license = Some( +//! info::LicenseBuilder::new() +//! .name("MIT") +//! .url(Some("https://opensource.org/licenses/MIT")) +//! .build() +//! ); +//! openapi.info.contact = Some( +//! info::ContactBuilder::new() +//! .name(Some("API Team")) +//! .email(Some("api@example.com")) +//! .build() +//! ); +//! ``` +//! +//! # Deprecation Notice +//! +//! When `#[entity(api(deprecated))]` or `deprecated_in = "x.x.x"` is set, +//! the description is prefixed with a deprecation warning: +//! +//! ```text +//! **DEPRECATED**: Deprecated since 1.5.0 +//! +//! Original description here... +//! ``` +//! +//! # Swagger UI Rendering +//! +//! The info section appears prominently in Swagger UI: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────┐ +//! │ User API v2.0.0 │ +//! │ ────────────────────────────────────────────────────────│ +//! │ Manage user accounts │ +//! │ │ +//! │ License: MIT │ +//! │ Contact: API Team │ +//! └──────────────────────────────────────────────────────────┘ +//! ``` use proc_macro2::TokenStream; use quote::quote; use crate::entity::parse::EntityDef; -/// Generate code to configure OpenAPI info section. +/// Generates code to configure the OpenAPI info section. +/// +/// This function produces a `TokenStream` that sets various properties on +/// `openapi.info` within the `Modify::modify()` implementation. Only configured +/// fields are set; unconfigured fields retain their default values. +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing API configuration +/// +/// # Returns +/// +/// A `TokenStream` containing assignment statements for the info object. +/// May be empty if no info fields are configured. +/// +/// # Field Generation +/// +/// ```text +/// ApiConfig +/// │ +/// ├─► title ─────────────► openapi.info.title = ... +/// ├─► description ───────► openapi.info.description = Some(...) +/// │ └─► or entity doc +/// ├─► api_version ───────► openapi.info.version = ... +/// ├─► license ───────────► openapi.info.license = Some(...) +/// │ └─► license_url ───► .url(Some(...)) +/// ├─► contact_* ─────────► openapi.info.contact = Some(...) +/// │ ├─► contact_name ──► .name(Some(...)) +/// │ ├─► contact_email ─► .email(Some(...)) +/// │ └─► contact_url ───► .url(Some(...)) +/// └─► deprecated ────────► Prepend warning to description +/// ``` +/// +/// # Builder Pattern +/// +/// License and contact use utoipa's builder pattern: +/// +/// ```rust,ignore +/// info::LicenseBuilder::new() +/// .name("MIT") +/// .url(Some("https://...")) +/// .build() +/// ``` /// -/// Sets title, description, version, license, and contact information. +/// This ensures type safety and proper optional field handling. pub fn generate_info_code(entity: &EntityDef) -> TokenStream { let api_config = entity.api_config(); diff --git a/crates/entity-derive-impl/src/entity/api/openapi/mod.rs b/crates/entity-derive-impl/src/entity/api/openapi/mod.rs index 5f02022..14e4cbc 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/mod.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/mod.rs @@ -3,25 +3,88 @@ //! OpenAPI struct generation for utoipa 5.x. //! -//! Generates a struct that implements `utoipa::OpenApi` for Swagger UI -//! integration, with security schemes and paths added via the `Modify` trait. +//! This module generates complete OpenAPI documentation structs that implement +//! `utoipa::OpenApi` for seamless Swagger UI integration. It leverages the +//! `Modify` trait pattern to dynamically add security schemes, paths, and +//! additional components at runtime. +//! +//! # Architecture Overview +//! +//! The generation process produces two interconnected components: +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ OpenAPI Generation │ +//! ├─────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ EntityDef ─────────────────────────────────────────────────┐ │ +//! │ │ │ │ +//! │ ▼ │ │ +//! │ ┌─────────────────┐ ┌────────────────────────────────┐ │ │ +//! │ │ {Entity}Api │────>│ {Entity}ApiModifier │ │ │ +//! │ │ #[OpenApi] │ │ impl Modify │ │ │ +//! │ │ - schemas │ │ - add_security_scheme() │ │ │ +//! │ │ - modifiers │ │ - add_path_operation() │ │ │ +//! │ │ - tags │ │ - insert schemas │ │ │ +//! │ └─────────────────┘ └────────────────────────────────┘ │ │ +//! │ │ │ +//! │ Generated at │ │ +//! │ compile time │ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` //! //! # Generated Code //! -//! For `User` entity with handlers and security: +//! For a `User` entity with CRUD handlers and bearer security: //! //! ```rust,ignore //! /// OpenAPI modifier for User entity. +//! /// +//! /// Implements utoipa's Modify trait to dynamically configure +//! /// the OpenAPI specification at runtime. //! struct UserApiModifier; //! //! impl utoipa::Modify for UserApiModifier { //! fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { -//! // Add security schemes -//! // Add CRUD paths with documentation +//! use utoipa::openapi::*; +//! +//! // Configure API metadata +//! openapi.info.title = "User API".to_string(); +//! openapi.info.version = "1.0.0".to_string(); +//! +//! // Add bearer authentication scheme +//! if let Some(components) = openapi.components.as_mut() { +//! components.add_security_scheme("bearerAuth", +//! security::SecurityScheme::Http( +//! security::HttpBuilder::new() +//! .scheme(security::HttpAuthScheme::Bearer) +//! .bearer_format("JWT") +//! .build() +//! ) +//! ); +//! +//! // Add ErrorResponse and PaginationQuery schemas +//! components.schemas.insert("ErrorResponse".to_string(), ...); +//! components.schemas.insert("PaginationQuery".to_string(), ...); +//! } +//! +//! // Add CRUD path operations +//! // POST /users - Create user +//! // GET /users - List users +//! // GET /users/{id} - Get user by ID +//! // PATCH /users/{id} - Update user +//! // DELETE /users/{id} - Delete user //! } //! } //! //! /// OpenAPI documentation for User entity endpoints. +//! /// +//! /// # Usage +//! /// +//! /// ```rust,ignore +//! /// use utoipa::OpenApi; +//! /// let openapi = UserApi::openapi(); +//! /// ``` //! #[derive(utoipa::OpenApi)] //! #[openapi( //! components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), @@ -30,6 +93,36 @@ //! )] //! pub struct UserApi; //! ``` +//! +//! # Module Structure +//! +//! | Module | Purpose | +//! |--------|---------| +//! | [`info`] | API metadata (title, version, contact, license) | +//! | [`paths`] | CRUD operation paths with parameters and responses | +//! | [`schemas`] | DTO schemas and common types (ErrorResponse) | +//! | [`security`] | Authentication schemes (bearer, cookie, api_key) | +//! +//! # Swagger UI Integration +//! +//! The generated `{Entity}Api` struct can be served via utoipa-swagger-ui: +//! +//! ```rust,ignore +//! use utoipa::OpenApi; +//! use utoipa_swagger_ui::SwaggerUi; +//! +//! let app = Router::new() +//! .merge(SwaggerUi::new("/swagger-ui") +//! .url("/api-docs/openapi.json", UserApi::openapi())); +//! ``` +//! +//! # Conditional Generation +//! +//! OpenAPI struct is only generated when either: +//! - CRUD handlers are enabled via `api(handlers)` or `api(handlers(...))` +//! - Custom commands are defined via `#[command(...)]` +//! +//! If neither is present, `generate()` returns an empty `TokenStream`. mod info; mod paths; @@ -49,7 +142,68 @@ pub use self::{ }; use crate::entity::parse::EntityDef; -/// Generate the OpenAPI struct with modifier. +/// Generates the complete OpenAPI documentation struct with modifier. +/// +/// This is the main entry point for OpenAPI generation. It produces: +/// +/// 1. A modifier struct implementing `utoipa::Modify` +/// 2. An API struct deriving `utoipa::OpenApi` +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing API configuration +/// +/// # Returns +/// +/// A `TokenStream` containing both the modifier and API structs, or an empty +/// stream if no handlers or commands are configured. +/// +/// # Generation Flow +/// +/// ```text +/// EntityDef +/// │ +/// ├─► has_crud? ────────────────────────────────────────┐ +/// │ │ │ +/// ├─► has_commands? ────────────────────────────────────┤ +/// │ │ +/// │ Neither? ─► Return empty TokenStream │ +/// │ │ +/// └───────────────────────────────────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────┐ +/// │ Generate components │ +/// │ - schema_types │ +/// │ - modifier_impl │ +/// │ - api_struct │ +/// └─────────────────────┘ +/// ``` +/// +/// # Generated Components +/// +/// | Component | Naming | Purpose | +/// |-----------|--------|---------| +/// | Modifier | `{Entity}ApiModifier` | Runtime OpenAPI customization | +/// | API struct | `{Entity}Api` | Main OpenAPI entry point | +/// | Tag | Configured or entity name | API grouping in Swagger UI | +/// +/// # Example Output +/// +/// For `User` entity with all handlers enabled: +/// +/// ```rust,ignore +/// struct UserApiModifier; +/// impl utoipa::Modify for UserApiModifier { ... } +/// +/// #[derive(utoipa::OpenApi)] +/// #[openapi( +/// components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), +/// modifiers(&UserApiModifier), +/// tags((name = "Users", description = "User management")) +/// )] +/// pub struct UserApi; +/// ``` pub fn generate(entity: &EntityDef) -> TokenStream { let has_crud = entity.api_config().has_handlers(); let has_commands = !entity.command_defs().is_empty(); @@ -99,10 +253,75 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } } -/// Generate the modifier struct with Modify implementation. +/// Generates the modifier struct with `utoipa::Modify` implementation. +/// +/// The modifier pattern allows runtime customization of the OpenAPI spec +/// that cannot be expressed through derive macros alone. This includes: +/// +/// - Dynamic security scheme configuration +/// - Additional schemas not derived from struct definitions +/// - Path operations with complex parameter types +/// - API info metadata (title, version, contact) +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// * `modifier_name` - The identifier for the modifier struct +/// +/// # Returns +/// +/// A `TokenStream` containing: +/// - The modifier struct definition +/// - The `impl utoipa::Modify` block +/// +/// # Modifier Responsibilities +/// +/// ```text +/// ┌────────────────────────────────────────────────────────────┐ +/// │ {Entity}ApiModifier::modify() │ +/// ├────────────────────────────────────────────────────────────┤ +/// │ │ +/// │ 1. Info Configuration │ +/// │ ├─► title, version, description │ +/// │ ├─► license (name, URL) │ +/// │ └─► contact (name, email, URL) │ +/// │ │ +/// │ 2. Security Schemes │ +/// │ ├─► Bearer JWT (Authorization header) │ +/// │ ├─► Cookie authentication (HTTP-only cookie) │ +/// │ └─► API Key (X-API-Key header) │ +/// │ │ +/// │ 3. Common Schemas │ +/// │ ├─► ErrorResponse (RFC 7807 Problem Details) │ +/// │ └─► PaginationQuery (limit, offset) │ +/// │ │ +/// │ 4. CRUD Paths │ +/// │ ├─► POST /entities (create) │ +/// │ ├─► GET /entities (list) │ +/// │ ├─► GET /entities/{id} (get) │ +/// │ ├─► PATCH /entities/{id} (update) │ +/// │ └─► DELETE /entities/{id} (delete) │ +/// │ │ +/// └────────────────────────────────────────────────────────────┘ +/// ``` +/// +/// # Generated Structure +/// +/// ```rust,ignore +/// /// OpenAPI modifier for User entity. +/// struct UserApiModifier; +/// +/// impl utoipa::Modify for UserApiModifier { +/// fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { +/// use utoipa::openapi::*; /// -/// This adds security schemes, common schemas, CRUD paths, and info to the -/// OpenAPI spec. +/// // Info configuration code +/// // Security scheme code +/// // Common schemas code +/// // CRUD paths code +/// } +/// } +/// ``` fn generate_modifier(entity: &EntityDef, modifier_name: &syn::Ident) -> TokenStream { let entity_name = entity.name(); let api_config = entity.api_config(); diff --git a/crates/entity-derive-impl/src/entity/api/openapi/paths.rs b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs index 2d42565..3fac9e7 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/paths.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs @@ -1,9 +1,107 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! OpenAPI paths generation. +//! OpenAPI path operations generation. //! -//! Generates CRUD path operations for the OpenAPI spec. +//! This module generates CRUD path operations for the OpenAPI specification. +//! Path operations define the available endpoints, their HTTP methods, +//! parameters, request/response bodies, and security requirements. +//! +//! # OpenAPI Paths Object +//! +//! The paths object is the core of the OpenAPI specification: +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ OpenAPI Paths │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ /users: # Collection path │ +//! │ ├─► POST create_user # Create new entity │ +//! │ │ ├─► requestBody: CreateUserRequest │ +//! │ │ ├─► responses: 201, 400, 401, 500 │ +//! │ │ └─► security: bearerAuth │ +//! │ │ │ +//! │ └─► GET list_user # List entities with pagination │ +//! │ ├─► parameters: limit, offset │ +//! │ ├─► responses: 200, 401, 500 │ +//! │ └─► security: bearerAuth │ +//! │ │ +//! │ /users/{id}: # Item path │ +//! │ ├─► GET get_user # Get single entity │ +//! │ │ ├─► parameters: id (path) │ +//! │ │ ├─► responses: 200, 401, 404, 500 │ +//! │ │ └─► security: bearerAuth │ +//! │ │ │ +//! │ ├─► PATCH update_user # Partial update │ +//! │ │ ├─► parameters: id (path) │ +//! │ │ ├─► requestBody: UpdateUserRequest │ +//! │ │ ├─► responses: 200, 400, 401, 404, 500 │ +//! │ │ └─► security: bearerAuth │ +//! │ │ │ +//! │ └─► DELETE delete_user # Remove entity │ +//! │ ├─► parameters: id (path) │ +//! │ ├─► responses: 204, 401, 404, 500 │ +//! │ └─► security: bearerAuth │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Path Patterns +//! +//! Two URL patterns are used following REST conventions: +//! +//! | Pattern | Name | Operations | Example | +//! |---------|------|------------|---------| +//! | `/{prefix}/{entities}` | Collection | POST, GET | `/api/v1/users` | +//! | `/{prefix}/{entities}/{id}` | Item | GET, PATCH, DELETE | `/api/v1/users/{id}` | +//! +//! # Path Configuration +//! +//! Paths are constructed from entity configuration: +//! +//! ```rust,ignore +//! #[entity( +//! table = "users", +//! api( +//! prefix = "api", // Base prefix +//! api_version = "v1", // Version segment +//! handlers(get, list) // Enabled operations +//! ) +//! )] +//! pub struct User { ... } +//! +//! // Generated paths: +//! // GET /api/v1/users +//! // GET /api/v1/users/{id} +//! ``` +//! +//! # Operation Components +//! +//! Each operation includes: +//! +//! | Component | Description | Example | +//! |-----------|-------------|---------| +//! | `operationId` | Unique identifier | `create_user` | +//! | `summary` | Short description | "Create a new User" | +//! | `description` | Detailed description | "Creates a new User entity" | +//! | `tag` | API grouping | "Users" | +//! | `parameters` | Path/query params | `id: Uuid` | +//! | `requestBody` | Request schema | `CreateUserRequest` | +//! | `responses` | Response codes/bodies | 200, 404, 500 | +//! | `security` | Auth requirements | `bearerAuth` | +//! +//! # Response Codes +//! +//! Standard HTTP response codes per operation: +//! +//! | Operation | Success | Client Error | Server Error | +//! |-----------|---------|--------------|--------------| +//! | Create | 201 | 400, 401 | 500 | +//! | List | 200 | 401 | 500 | +//! | Get | 200 | 401, 404 | 500 | +//! | Update | 200 | 400, 401, 404 | 500 | +//! | Delete | 204 | 401, 404 | 500 | use convert_case::{Case, Casing}; use proc_macro2::TokenStream; @@ -12,9 +110,60 @@ use quote::{format_ident, quote}; use super::security::security_scheme_name; use crate::entity::parse::{CommandDef, EntityDef}; -/// Generate code to add CRUD paths to OpenAPI. +/// Generates code to add CRUD path operations to the OpenAPI specification. +/// +/// This function produces code that registers all enabled CRUD operations +/// as paths in the OpenAPI spec. Each operation is fully documented with +/// parameters, request bodies, responses, and security requirements. /// -/// Only generates paths for enabled handlers based on `HandlerConfig`. +/// # Arguments +/// +/// * `entity` - The parsed entity definition containing handler configuration +/// +/// # Returns +/// +/// A `TokenStream` containing code to add paths via `openapi.paths.add_path_operation()`. +/// +/// # Conditional Generation +/// +/// Only enabled handlers generate path operations: +/// +/// ```text +/// HandlerConfig +/// │ +/// ├─► create == true ──► POST /entities +/// ├─► list == true ────► GET /entities +/// ├─► get == true ─────► GET /entities/{id} +/// ├─► update == true ──► PATCH /entities/{id} +/// └─► delete == true ──► DELETE /entities/{id} +/// ``` +/// +/// # Generated Code Structure +/// +/// ```rust,ignore +/// // Common setup +/// let error_response = |desc: &str| -> response::Response { ... }; +/// let security_req: Option> = ...; +/// let id_param = path::ParameterBuilder::new()...; +/// +/// // Create operation (if enabled) +/// let create_op = path::OperationBuilder::new() +/// .operation_id(Some("create_user")) +/// .tag("Users") +/// .request_body(Some(...)) +/// .response("201", ...) +/// .build(); +/// openapi.paths.add_path_operation("/users", vec![HttpMethod::Post], create_op); +/// +/// // Similar for other operations... +/// ``` +/// +/// # Security Handling +/// +/// When security is configured: +/// - Each operation includes security requirements +/// - 401 response is added to all operations +/// - Lock icon appears in Swagger UI pub fn generate_paths_code(entity: &EntityDef) -> TokenStream { let api_config = entity.api_config(); let handlers = api_config.handlers(); @@ -329,7 +478,46 @@ pub fn generate_paths_code(entity: &EntityDef) -> TokenStream { } } -/// Build the collection path (e.g., `/users`). +/// Builds the collection path for an entity (e.g., `/users`). +/// +/// Collection paths are used for operations that affect multiple entities +/// or create new entities: `POST` (create) and `GET` (list). +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A path string with the format `/{prefix}/{version}/{entity}s`. +/// +/// # Path Construction +/// +/// ```text +/// ApiConfig Result +/// │ +/// ├─► prefix: "api" +/// │ │ +/// ├─► api_version: "v1" ─────► /api/v1/users +/// │ │ +/// └─► entity: "User" +/// └─► kebab-case + plural +/// ``` +/// +/// # Examples +/// +/// | Entity | Prefix | Version | Result | +/// |--------|--------|---------|--------| +/// | `User` | - | - | `/users` | +/// | `User` | `api` | - | `/api/users` | +/// | `User` | `api` | `v1` | `/api/v1/users` | +/// | `BlogPost` | - | - | `/blog-posts` | +/// | `OrderItem` | `api` | `v2` | `/api/v2/order-items` | +/// +/// # Pluralization +/// +/// Simple `s` suffix is added. For irregular plurals, use `prefix` to +/// customize the full path. pub fn build_collection_path(entity: &EntityDef) -> String { let api_config = entity.api_config(); let prefix = api_config.full_path_prefix(); @@ -339,13 +527,107 @@ pub fn build_collection_path(entity: &EntityDef) -> String { path.replace("//", "/") } -/// Build the item path (e.g., `/users/{id}`). +/// Builds the item path for an entity (e.g., `/users/{id}`). +/// +/// Item paths are used for operations that affect a single entity identified +/// by its primary key: `GET` (get), `PATCH` (update), and `DELETE` (delete). +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns +/// +/// A path string with the format `/{collection}/{id}`. +/// +/// # Path Construction +/// +/// ```text +/// build_collection_path() +/// │ +/// ▼ +/// /api/v1/users +/// │ +/// ├─► append "/{id}" +/// │ +/// ▼ +/// /api/v1/users/{id} +/// ``` +/// +/// # OpenAPI Path Parameters +/// +/// The `{id}` placeholder is an OpenAPI path parameter. When documented: +/// +/// ```yaml +/// /users/{id}: +/// get: +/// parameters: +/// - name: id +/// in: path +/// required: true +/// schema: +/// type: string +/// format: uuid +/// ``` +/// +/// # Examples +/// +/// | Entity | Prefix | Version | Result | +/// |--------|--------|---------|--------| +/// | `User` | - | - | `/users/{id}` | +/// | `User` | `api` | `v1` | `/api/v1/users/{id}` | +/// | `BlogPost` | - | - | `/blog-posts/{id}` | pub fn build_item_path(entity: &EntityDef) -> String { let collection = build_collection_path(entity); format!("{}/{{id}}", collection) } -/// Get command handler function name. +/// Generates the handler function name for a command. +/// +/// Command handlers follow the naming pattern `{command}_{entity}` in snake_case, +/// consistent with the CRUD handler naming convention. +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// * `cmd` - The command definition +/// +/// # Returns +/// +/// A `syn::Ident` for the handler function name. +/// +/// # Naming Convention +/// +/// ```text +/// Command: "Ban" Entity: "User" +/// │ │ +/// ▼ ▼ +/// "ban" + "_" + "user" +/// │ │ +/// └───────┬───────────┘ +/// ▼ +/// "ban_user" +/// ``` +/// +/// # Examples +/// +/// | Command | Entity | Result | +/// |---------|--------|--------| +/// | `Ban` | `User` | `ban_user` | +/// | `Activate` | `Account` | `activate_account` | +/// | `SendVerification` | `User` | `send_verification_user` | +/// +/// # Usage +/// +/// Used when generating command path operations and their operationIds: +/// +/// ```rust,ignore +/// let handler = command_handler_name(&entity, &cmd); +/// // handler = "ban_user" +/// +/// // In generated code: +/// pub async fn ban_user(...) { ... } +/// ``` #[allow(dead_code)] pub fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); diff --git a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs index cc6d607..3f9f7bd 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs @@ -1,19 +1,133 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! OpenAPI schema generation. +//! OpenAPI schema generation for DTOs and common types. //! -//! Generates schema types for DTOs and common schemas like ErrorResponse -//! and PaginationQuery. +//! This module generates schema registrations for the OpenAPI components section. +//! Schemas define the structure of request/response bodies and are referenced +//! throughout the API specification. +//! +//! # OpenAPI Components/Schemas +//! +//! The components/schemas section contains reusable schema definitions: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────────┐ +//! │ OpenAPI Components │ +//! ├──────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ schemas: │ +//! │ ├─► UserResponse # Entity response DTO │ +//! │ ├─► CreateUserRequest # Create request body │ +//! │ ├─► UpdateUserRequest # Update request body │ +//! │ ├─► ErrorResponse # Standard error format │ +//! │ └─► PaginationQuery # List endpoint parameters │ +//! │ │ +//! │ securitySchemes: │ +//! │ └─► bearerAuth # (handled by security module) │ +//! │ │ +//! └──────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Schema Types +//! +//! Two categories of schemas are generated: +//! +//! ## Entity DTOs (Derived) +//! +//! These schemas are derived from structs using `utoipa::ToSchema`: +//! +//! | Schema | Source | When Generated | +//! |--------|--------|----------------| +//! | `{Entity}Response` | Entity struct | Always (if handlers) | +//! | `Create{Entity}Request` | Create DTO | If `create` handler enabled | +//! | `Update{Entity}Request` | Update DTO | If `update` handler enabled | +//! | `{Command}` | Command struct | If commands defined | +//! +//! ## Common Schemas (Runtime) +//! +//! These schemas are built programmatically via the `Modify` trait: +//! +//! | Schema | Purpose | Fields | +//! |--------|---------|--------| +//! | `ErrorResponse` | RFC 7807 Problem Details | type, title, status, detail, code | +//! | `PaginationQuery` | List endpoint params | limit, offset | +//! +//! # ErrorResponse Schema +//! +//! Follows RFC 7807 "Problem Details for HTTP APIs": +//! +//! ```json +//! { +//! "type": "https://errors.example.com/not-found", +//! "title": "Resource not found", +//! "status": 404, +//! "detail": "User with ID '123' was not found", +//! "code": "NOT_FOUND" +//! } +//! ``` +//! +//! # PaginationQuery Schema +//! +//! Defines parameters for offset-based pagination: +//! +//! ```json +//! { +//! "limit": 100, // default: 100, min: 1, max: 1000 +//! "offset": 0 // default: 0, min: 0 +//! } +//! ``` +//! +//! # Selective Registration +//! +//! Schema types are only registered when needed to keep the spec clean: +//! +//! ```text +//! handlers(get, list) → UserResponse only +//! handlers(create) → UserResponse, CreateUserRequest +//! handlers(update) → UserResponse, UpdateUserRequest +//! handlers → All DTOs +//! ``` use proc_macro2::TokenStream; use quote::quote; use crate::entity::parse::EntityDef; -/// Generate all schema types (DTOs, commands). +/// Generates the list of schema types to register with OpenAPI. +/// +/// This function produces a comma-separated list of type identifiers +/// for the `components(schemas(...))` attribute of `#[openapi]`. +/// +/// # Arguments +/// +/// * `entity` - The parsed entity definition +/// +/// # Returns /// -/// Only registers schemas for enabled handlers to keep OpenAPI spec clean. +/// A `TokenStream` containing comma-separated schema type identifiers. +/// +/// # Selection Logic +/// +/// ```text +/// HandlerConfig +/// │ +/// ├─► any() == true ─────────────► {Entity}Response +/// │ │ +/// │ ├─► create == true ────► Create{Entity}Request +/// │ │ +/// │ └─► update == true ────► Update{Entity}Request +/// │ +/// └─► CommandDefs ───────────────► {Command} for each command +/// ``` +/// +/// # Example Output +/// +/// For `User` with all handlers and a `BanUser` command: +/// +/// ```rust,ignore +/// UserResponse, CreateUserRequest, UpdateUserRequest, BanUser +/// ``` pub fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { let entity_name_str = entity.name_str(); let mut types: Vec = Vec::new(); @@ -42,8 +156,77 @@ pub fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { quote! { #(#types),* } } -/// Generate common schemas (ErrorResponse, PaginationQuery) for the OpenAPI -/// spec. +/// Generates common schemas for the OpenAPI specification. +/// +/// This function produces code that registers `ErrorResponse` and +/// `PaginationQuery` schemas in the OpenAPI components section. These +/// schemas are built at runtime using utoipa's builder API rather than +/// being derived from structs. +/// +/// # Returns +/// +/// A `TokenStream` containing code to insert schemas into `openapi.components`. +/// +/// # Generated Schemas +/// +/// ## ErrorResponse +/// +/// Implements RFC 7807 "Problem Details for HTTP APIs" with fields: +/// +/// | Field | Type | Required | Description | +/// |-------|------|----------|-------------| +/// | `type` | string | Yes | URI identifying the problem type | +/// | `title` | string | Yes | Short human-readable summary | +/// | `status` | integer | Yes | HTTP status code | +/// | `detail` | string | No | Detailed explanation | +/// | `code` | string | No | Application-specific error code | +/// +/// Example JSON: +/// +/// ```json +/// { +/// "type": "https://errors.example.com/validation", +/// "title": "Validation Error", +/// "status": 400, +/// "detail": "Email format is invalid", +/// "code": "INVALID_EMAIL" +/// } +/// ``` +/// +/// ## PaginationQuery +/// +/// Defines offset-based pagination parameters: +/// +/// | Field | Type | Default | Min | Max | Description | +/// |-------|------|---------|-----|-----|-------------| +/// | `limit` | integer | 100 | 1 | 1000 | Items per page | +/// | `offset` | integer | 0 | 0 | - | Items to skip | +/// +/// # Implementation +/// +/// Uses utoipa's builder pattern to construct schemas programmatically: +/// +/// ```rust,ignore +/// schema::ObjectBuilder::new() +/// .schema_type(schema::Type::Object) +/// .title(Some("ErrorResponse")) +/// .property("type", schema::ObjectBuilder::new() +/// .schema_type(schema::Type::String) +/// .build()) +/// .required("type") +/// .build() +/// ``` +/// +/// # Usage in Generated Code +/// +/// Called within the `Modify::modify()` implementation: +/// +/// ```rust,ignore +/// if let Some(components) = openapi.components.as_mut() { +/// // Insert ErrorResponse schema +/// // Insert PaginationQuery schema +/// } +/// ``` pub fn generate_common_schemas_code() -> TokenStream { quote! { if let Some(components) = openapi.components.as_mut() { diff --git a/crates/entity-derive-impl/src/entity/api/openapi/security.rs b/crates/entity-derive-impl/src/entity/api/openapi/security.rs index a814fc3..e3a372e 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/security.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/security.rs @@ -3,13 +3,169 @@ //! OpenAPI security scheme generation. //! -//! Generates security scheme code for cookie, bearer, and API key +//! This module generates security scheme definitions for the OpenAPI +//! specification. Security schemes define how API endpoints are protected +//! and how clients should authenticate. +//! +//! # Supported Security Types +//! +//! The macro supports three authentication mechanisms: +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ Security Schemes │ +//! ├─────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ 1. Bearer Token (JWT) │ +//! │ ├─► Scheme name: "bearerAuth" │ +//! │ ├─► Type: HTTP Bearer │ +//! │ ├─► Header: Authorization: Bearer │ +//! │ └─► Format: JWT │ +//! │ │ +//! │ 2. Cookie Authentication │ +//! │ ├─► Scheme name: "cookieAuth" │ +//! │ ├─► Type: API Key (Cookie) │ +//! │ ├─► Cookie name: "token" │ +//! │ └─► Note: HTTP-only for XSS protection │ +//! │ │ +//! │ 3. API Key │ +//! │ ├─► Scheme name: "apiKey" │ +//! │ ├─► Type: API Key (Header) │ +//! │ ├─► Header: X-API-Key: │ +//! │ └─► Use case: Service-to-service auth │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Configuration +//! +//! Security type is set via the `security` attribute: +//! +//! ```rust,ignore +//! #[entity( +//! table = "users", +//! api( +//! security = "bearer", // or "cookie", "api_key" +//! handlers +//! ) +//! )] +//! pub struct User { ... } +//! ``` +//! +//! # Generated Code Examples +//! +//! ## Bearer Token +//! +//! ```rust,ignore +//! components.add_security_scheme("bearerAuth", +//! security::SecurityScheme::Http( +//! security::HttpBuilder::new() +//! .scheme(security::HttpAuthScheme::Bearer) +//! .bearer_format("JWT") +//! .description(Some("JWT token in Authorization header")) +//! .build() +//! ) +//! ); +//! ``` +//! +//! ## Cookie Authentication +//! +//! ```rust,ignore +//! components.add_security_scheme("cookieAuth", +//! security::SecurityScheme::ApiKey( +//! security::ApiKey::Cookie( +//! security::ApiKeyValue::with_description( +//! "token", +//! "JWT token stored in HTTP-only cookie" +//! ) +//! ) +//! ) +//! ); +//! ``` +//! +//! ## API Key +//! +//! ```rust,ignore +//! components.add_security_scheme("apiKey", +//! security::SecurityScheme::ApiKey( +//! security::ApiKey::Header( +//! security::ApiKeyValue::with_description( +//! "X-API-Key", +//! "API key for service-to-service authentication" +//! ) +//! ) +//! ) +//! ); +//! ``` +//! +//! # Swagger UI Integration +//! +//! When a security scheme is configured, Swagger UI displays: +//! +//! ```text +//! ┌──────────────────────────────────────────────┐ +//! │ 🔒 Authorize │ +//! │ ────────────────────────────────────────────│ +//! │ bearerAuth (http, Bearer) │ +//! │ JWT token in Authorization header │ +//! │ │ +//! │ Value: [________________] [Authorize] │ +//! └──────────────────────────────────────────────┘ +//! ``` +//! +//! # Security Requirements +//! +//! Once a security scheme is defined, it can be applied to operations: +//! +//! ```rust,ignore +//! #[utoipa::path( +//! get, +//! security(("bearerAuth" = [])) +//! )] +//! ``` +//! +//! This adds a lock icon in Swagger UI indicating the endpoint requires //! authentication. use proc_macro2::TokenStream; use quote::quote; -/// Generate security scheme code for the Modify implementation. +/// Generates security scheme code for the `Modify` implementation. +/// +/// This function produces code that registers a security scheme in the +/// OpenAPI components section. The scheme defines how the API authenticates +/// requests and is displayed in Swagger UI's "Authorize" dialog. +/// +/// # Arguments +/// +/// * `security` - Optional security type string: `"bearer"`, `"cookie"`, or `"api_key"` +/// +/// # Returns +/// +/// A `TokenStream` containing code to add the security scheme to components. +/// Returns empty stream if security is `None` or unrecognized. +/// +/// # Security Type Mapping +/// +/// | Input | Scheme Name | Type | +/// |-------|-------------|------| +/// | `"bearer"` | `bearerAuth` | HTTP Bearer with JWT format | +/// | `"cookie"` | `cookieAuth` | API Key in cookie named "token" | +/// | `"api_key"` | `apiKey` | API Key in "X-API-Key" header | +/// +/// # Usage +/// +/// Called within `generate_modifier()` to add security schemes: +/// +/// ```rust,ignore +/// let security_code = generate_security_code(api_config.security.as_deref()); +/// +/// quote! { +/// fn modify(&self, openapi: &mut OpenApi) { +/// #security_code // Adds scheme to components +/// } +/// } +/// ``` pub fn generate_security_code(security: Option<&str>) -> TokenStream { let Some(security) = security else { return TokenStream::new(); @@ -64,7 +220,47 @@ pub fn generate_security_code(security: Option<&str>) -> TokenStream { } } -/// Get the security scheme name for a given security type. +/// Returns the OpenAPI security scheme name for a given security type. +/// +/// This function maps user-facing security type names to their corresponding +/// OpenAPI security scheme identifiers. The scheme name is used both when +/// defining the security scheme and when applying it to operations. +/// +/// # Arguments +/// +/// * `security` - The security type: `"bearer"`, `"cookie"`, or `"api_key"` +/// +/// # Returns +/// +/// The canonical OpenAPI scheme name used throughout the specification. +/// +/// # Mapping +/// +/// | Input | Output | Description | +/// |-------|--------|-------------| +/// | `"bearer"` | `"bearerAuth"` | JWT in Authorization header | +/// | `"cookie"` | `"cookieAuth"` | JWT in HTTP-only cookie | +/// | `"api_key"` | `"apiKey"` | Key in X-API-Key header | +/// | other | `"cookieAuth"` | Default fallback | +/// +/// # Usage +/// +/// The scheme name is used in two places: +/// +/// 1. **Defining the scheme** (in components/securitySchemes): +/// ```rust,ignore +/// components.add_security_scheme("bearerAuth", scheme); +/// ``` +/// +/// 2. **Applying to operations** (in path operations): +/// ```rust,ignore +/// security::SecurityRequirement::new::<_, _, &str>("bearerAuth", []) +/// ``` +/// +/// # Consistency +/// +/// The same scheme name must be used in both places. This function ensures +/// consistency by providing a single source of truth for the mapping. pub fn security_scheme_name(security: &str) -> &'static str { match security { "cookie" => "cookieAuth", diff --git a/crates/entity-derive-impl/src/entity/api/openapi/tests.rs b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs index 96e1845..c597fb8 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/tests.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs @@ -2,6 +2,35 @@ // SPDX-License-Identifier: MIT //! Tests for OpenAPI generation. +//! +//! This module contains unit tests for the OpenAPI code generation functionality. +//! Tests verify that the generated OpenAPI structs, modifiers, and schemas are +//! correct for various entity configurations. +//! +//! # Test Categories +//! +//! | Category | Tests | Purpose | +//! |----------|-------|---------| +//! | Basic | `generate_crud_only` | Verify struct generation | +//! | Security | `generate_with_security`, `generate_cookie_security` | Auth schemes | +//! | Disabled | `no_api_when_disabled` | No output when API disabled | +//! | Paths | `collection_path_format`, `item_path_format` | URL patterns | +//! | Handlers | `selective_handlers_*` | Conditional schema generation | +//! +//! # Test Methodology +//! +//! Tests use `syn::parse_quote!` to create entity definitions from attribute +//! syntax, then verify the generated `TokenStream` contains expected identifiers. +//! +//! ```rust,ignore +//! let input: syn::DeriveInput = syn::parse_quote! { +//! #[entity(table = "users", api(handlers))] +//! pub struct User { ... } +//! }; +//! let entity = EntityDef::from_derive_input(&input).unwrap(); +//! let tokens = generate(&entity); +//! assert!(tokens.to_string().contains("UserApi")); +//! ``` use super::*; diff --git a/crates/entity-derive-impl/src/entity/parse/api/config.rs b/crates/entity-derive-impl/src/entity/parse/api/config.rs index b13471e..9d670dc 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/config.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/config.rs @@ -1,16 +1,119 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! API configuration types. +//! API configuration type definitions. +//! +//! This module defines the data structures that hold parsed API configuration +//! from `#[entity(api(...))]` attributes. These types drive code generation +//! for HTTP handlers, OpenAPI documentation, and router setup. +//! +//! # Type Hierarchy +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ Configuration Types │ +//! ├─────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ ApiConfig │ +//! │ ├─► tag: Option # OpenAPI tag name │ +//! │ ├─► tag_description: Option │ +//! │ ├─► path_prefix: Option # URL prefix │ +//! │ ├─► security: Option # Auth scheme │ +//! │ ├─► public_commands: Vec # No-auth commands │ +//! │ ├─► version: Option # API version │ +//! │ ├─► deprecated_in: Option │ +//! │ ├─► handlers: HandlerConfig # CRUD settings │ +//! │ └─► OpenAPI Info Fields │ +//! │ ├─► title, description, api_version │ +//! │ ├─► license, license_url │ +//! │ └─► contact_name, contact_email, contact_url │ +//! │ │ +//! │ HandlerConfig │ +//! │ ├─► create: bool # POST /collection │ +//! │ ├─► get: bool # GET /collection/{id} │ +//! │ ├─► update: bool # PATCH /collection/{id} │ +//! │ ├─► delete: bool # DELETE /collection/{id} │ +//! │ └─► list: bool # GET /collection │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Handler Configuration +//! +//! The `handlers` field controls which CRUD operations generate handlers: +//! +//! | Syntax | Result | +//! |--------|--------| +//! | `handlers` | All five handlers | +//! | `handlers = true` | All five handlers | +//! | `handlers = false` | No handlers | +//! | `handlers(create, get)` | Only specified handlers | +//! +//! # Security Behavior +//! +//! Security is applied to all handlers unless overridden: +//! +//! ```text +//! security = "bearer" ─────► All handlers require auth +//! │ +//! └─► public = [Login] ─────► Login command has no auth +//! ``` +//! +//! # Path Construction +//! +//! Paths are built from prefix and version: +//! +//! | prefix | version | Entity | Result | +//! |--------|---------|--------|--------| +//! | - | - | User | `/users` | +//! | `/api` | - | User | `/api/users` | +//! | `/api` | `v1` | User | `/api/v1/users` | +//! | `/api/` | `v1` | User | `/api/v1/users` (trailing slash handled) | use syn::Ident; -/// Handler configuration for selective CRUD generation. +/// Configuration for selective CRUD handler generation. /// -/// # Syntax +/// Controls which of the five standard CRUD handlers are generated: +/// create, get, update, delete, and list. /// -/// - `handlers` - enables all handlers -/// - `handlers(create, get, list)` - enables specific handlers +/// # Syntax Variants +/// +/// The `handlers` option in `api(...)` supports three forms: +/// +/// ## Flag Form +/// +/// ```rust,ignore +/// api(tag = "Users", handlers) // All handlers enabled +/// ``` +/// +/// ## Boolean Form +/// +/// ```rust,ignore +/// api(tag = "Users", handlers = true) // All handlers +/// api(tag = "Users", handlers = false) // No handlers +/// ``` +/// +/// ## Selective Form +/// +/// ```rust,ignore +/// api(tag = "Users", handlers(create, get, list)) // Specific handlers +/// ``` +/// +/// # HTTP Method Mapping +/// +/// | Handler | HTTP Method | Path | Description | +/// |---------|-------------|------|-------------| +/// | `create` | POST | `/entities` | Create new entity | +/// | `get` | GET | `/entities/{id}` | Retrieve by ID | +/// | `update` | PATCH | `/entities/{id}` | Partial update | +/// | `delete` | DELETE | `/entities/{id}` | Remove entity | +/// | `list` | GET | `/entities` | List with pagination | +/// +/// # Default Behavior +/// +/// All handlers are `false` by default. To generate handlers, you must +/// explicitly enable them via one of the syntax forms above. #[derive(Debug, Clone, Default)] pub struct HandlerConfig { /// Generate create handler (POST /collection). @@ -43,9 +146,56 @@ impl HandlerConfig { } } -/// API configuration parsed from `#[entity(api(...))]`. +/// Complete API configuration parsed from `#[entity(api(...))]`. +/// +/// This struct holds all configuration options that control HTTP handler +/// generation and OpenAPI documentation. It is populated by [`parse_api_config`] +/// and consumed by code generation modules. +/// +/// # Configuration Categories +/// +/// ## Routing Configuration +/// +/// | Field | Purpose | Example | +/// |-------|---------|---------| +/// | `tag` | OpenAPI grouping | `"Users"` | +/// | `path_prefix` | URL base path | `"/api"` | +/// | `version` | API version segment | `"v1"` | +/// +/// ## Security Configuration +/// +/// | Field | Purpose | Example | +/// |-------|---------|---------| +/// | `security` | Default auth scheme | `"bearer"` | +/// | `public_commands` | No-auth commands | `[Login, Register]` | +/// +/// ## OpenAPI Info +/// +/// | Field | OpenAPI Location | +/// |-------|------------------| +/// | `title` | `info.title` | +/// | `description` | `info.description` | +/// | `api_version` | `info.version` | +/// | `license` | `info.license.name` | +/// | `license_url` | `info.license.url` | +/// | `contact_name` | `info.contact.name` | +/// | `contact_email` | `info.contact.email` | +/// | `contact_url` | `info.contact.url` | +/// +/// # Usage in Code Generation +/// +/// ```text +/// ApiConfig +/// │ +/// ├─► crud/mod.rs ─────────► CRUD handler functions +/// ├─► openapi/mod.rs ──────► OpenAPI struct + modifier +/// └─► router.rs ───────────► Axum Router factory +/// ``` +/// +/// # Default State /// -/// Controls HTTP handler generation and OpenAPI documentation. +/// A default `ApiConfig` has all options set to `None` or empty. +/// Use `is_enabled()` to check if API generation should proceed. #[derive(Debug, Clone, Default)] pub struct ApiConfig { /// OpenAPI tag name for grouping endpoints. diff --git a/crates/entity-derive-impl/src/entity/parse/api/mod.rs b/crates/entity-derive-impl/src/entity/parse/api/mod.rs index 0010d94..cb4a1a2 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/mod.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/mod.rs @@ -1,32 +1,132 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -#![allow(dead_code)] // Methods used by handler generation (#77) +#![allow(dead_code)] //! API configuration parsing for OpenAPI/utoipa integration. //! -//! This module handles parsing of `#[entity(api(...))]` attributes for -//! automatic HTTP handler generation with OpenAPI documentation. +//! This module handles parsing of `#[entity(api(...))]` attributes that control +//! automatic HTTP handler generation with OpenAPI documentation. The API +//! configuration determines what handlers are generated, how they're secured, +//! and how they appear in Swagger UI. //! -//! # Syntax +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ API Configuration Parsing │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Source Parsing Output │ +//! │ │ +//! │ #[entity( parse_api_config() ApiConfig │ +//! │ api( │ │ │ +//! │ tag = "Users", │ ├── tag │ +//! │ security = "bearer", │ ├── security │ +//! │ handlers(create, get) │ ├── handlers │ +//! │ ) │ └── ... │ +//! │ )] ▼ │ +//! │ HandlerConfig │ +//! │ │ │ +//! │ ├── create: true │ +//! │ ├── get: true │ +//! │ └── update/delete/list: false │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Configuration Options +//! +//! The `api(...)` attribute supports the following options: +//! +//! ## Core Options +//! +//! | Option | Type | Required | Description | +//! |--------|------|----------|-------------| +//! | `tag` | string | Yes | OpenAPI tag for endpoint grouping | +//! | `tag_description` | string | No | Tag description for docs | +//! | `handlers` | flag/list | No | CRUD handlers to generate | +//! +//! ## URL Configuration +//! +//! | Option | Type | Example | Result | +//! |--------|------|---------|--------| +//! | `path_prefix` | string | `"/api"` | `/api/users` | +//! | `version` | string | `"v1"` | `/api/v1/users` | +//! +//! ## Security Configuration +//! +//! | Option | Type | Values | Description | +//! |--------|------|--------|-------------| +//! | `security` | string | `"bearer"`, `"cookie"`, `"api_key"` | Default auth | +//! | `public` | list | `[Register, Login]` | Commands without auth | +//! +//! ## OpenAPI Info +//! +//! | Option | Description | +//! |--------|-------------| +//! | `title` | API title for OpenAPI spec | +//! | `description` | API description (markdown) | +//! | `api_version` | Semantic version string | +//! | `license` | License name (e.g., "MIT") | +//! | `license_url` | URL to license text | +//! | `contact_name` | API maintainer name | +//! | `contact_email` | Support email address | +//! | `contact_url` | Support website URL | +//! +//! ## Deprecation +//! +//! | Option | Description | +//! |--------|-------------| +//! | `deprecated_in` | Version where API was deprecated | +//! +//! # Handler Configuration +//! +//! The `handlers` option controls CRUD handler generation: +//! +//! ```rust,ignore +//! // Generate all handlers (create, get, update, delete, list) +//! api(tag = "Users", handlers) +//! +//! // Generate specific handlers only +//! api(tag = "Users", handlers(create, get, list)) +//! +//! // Disable handlers (commands only) +//! api(tag = "Users", handlers = false) +//! ``` +//! +//! # Complete Example //! //! ```rust,ignore -//! #[entity(api( -//! tag = "Users", // OpenAPI tag name (required) -//! tag_description = "...", // Tag description (optional) -//! path_prefix = "/api/v1", // URL prefix (optional) -//! security = "bearer", // Default security scheme (optional) -//! public = [Register, Login], // Commands without auth (optional) -//! ))] +//! #[entity( +//! table = "users", +//! api( +//! tag = "Users", +//! tag_description = "User account management endpoints", +//! path_prefix = "/api", +//! version = "v1", +//! security = "bearer", +//! public = [Register, Login], +//! handlers(create, get, update, list), +//! title = "User Service", +//! api_version = "1.0.0", +//! license = "MIT" +//! ) +//! )] +//! pub struct User { +//! #[id] +//! pub id: Uuid, +//! #[field(create, update, response)] +//! pub email: String, +//! } //! ``` //! -//! # Generated Output +//! # Module Structure //! -//! When `api(...)` is present, the macro generates: -//! - Axum handlers with `#[utoipa::path]` annotations -//! - OpenAPI schemas via `#[derive(ToSchema)]` -//! - Router factory function -//! - OpenApi struct for Swagger UI +//! | Module | Purpose | +//! |--------|---------| +//! | [`config`] | Type definitions for `ApiConfig` and `HandlerConfig` | +//! | [`parser`] | Attribute parsing logic for `api(...)` | mod config; mod parser; diff --git a/crates/entity-derive-impl/src/entity/parse/api/parser.rs b/crates/entity-derive-impl/src/entity/parse/api/parser.rs index 6b946a5..869b0a4 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/parser.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/parser.rs @@ -1,23 +1,153 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! API configuration parsing. +//! API configuration parsing from `#[entity(api(...))]` attributes. +//! +//! This module provides the parser that extracts API configuration from +//! the `api(...)` nested attribute within `#[entity(...)]`. It validates +//! syntax, handles all configuration options, and produces an `ApiConfig`. +//! +//! # Parsing Flow +//! +//! ```text +//! Input Attribute Parser Output +//! +//! #[entity( parse_api_config() +//! api( │ +//! tag = "Users", ──────────────►├── tag = Some("Users") +//! security = "bearer", ─────────────►├── security = Some("bearer") +//! handlers(create, get) ────────────►├── handlers.create = true +//! ) │ handlers.get = true +//! )] ▼ +//! ApiConfig { ... } +//! ``` +//! +//! # Supported Syntax +//! +//! The parser handles multiple attribute forms: +//! +//! ## String Values +//! +//! ```rust,ignore +//! api(tag = "Users") // Simple string +//! api(path_prefix = "/api/v1") // Path string +//! ``` +//! +//! ## Boolean Values +//! +//! ```rust,ignore +//! api(handlers = true) // Explicit boolean +//! api(handlers = false) // Disable handlers +//! ``` +//! +//! ## Flags +//! +//! ```rust,ignore +//! api(handlers) // Equivalent to handlers = true +//! ``` +//! +//! ## Lists +//! +//! ```rust,ignore +//! api(public = [Login, Register]) // Bracketed list +//! api(handlers(create, get, list)) // Parenthesized list +//! ``` +//! +//! # Error Handling +//! +//! The parser provides clear error messages for invalid syntax: +//! +//! ```text +//! error: api attribute requires parameters: api(tag = "...") +//! --> src/lib.rs:5:3 +//! | +//! 5 | #[entity(api)] +//! | ^^^ +//! +//! error: unknown api option 'unknown_option', expected: tag, ... +//! --> src/lib.rs:5:7 +//! | +//! 5 | #[entity(api(unknown_option = "value"))] +//! | ^^^^^^^^^^^^^^ +//! ``` +//! +//! # Option Reference +//! +//! | Option | Syntax | Type | +//! |--------|--------|------| +//! | `tag` | `tag = "..."` | String | +//! | `tag_description` | `tag_description = "..."` | String | +//! | `path_prefix` | `path_prefix = "..."` | String | +//! | `security` | `security = "..."` | String | +//! | `public` | `public = [A, B]` | List of Idents | +//! | `version` | `version = "..."` | String | +//! | `deprecated_in` | `deprecated_in = "..."` | String | +//! | `handlers` | `handlers` / `handlers(...)` / `handlers = bool` | Flag/List/Bool | +//! | `title` | `title = "..."` | String | +//! | `description` | `description = "..."` | String | +//! | `api_version` | `api_version = "..."` | String | +//! | `license` | `license = "..."` | String | +//! | `license_url` | `license_url = "..."` | String | +//! | `contact_name` | `contact_name = "..."` | String | +//! | `contact_email` | `contact_email = "..."` | String | +//! | `contact_url` | `contact_url = "..."` | String | use syn::Ident; use super::config::{ApiConfig, HandlerConfig}; -/// Parse `#[entity(api(...))]` attribute. +/// Parses the `#[entity(api(...))]` attribute into an [`ApiConfig`]. /// -/// Extracts API configuration from the nested attribute. +/// This function extracts all API configuration options from the nested +/// `api(...)` attribute. It validates the syntax and returns helpful +/// error messages for invalid input. /// /// # Arguments /// -/// * `meta` - The meta content inside `api(...)` +/// * `meta` - The `syn::Meta` representing the `api(...)` attribute /// /// # Returns /// -/// Parsed `ApiConfig` or error. +/// - `Ok(ApiConfig)` - Successfully parsed configuration +/// - `Err(syn::Error)` - Syntax error with span information +/// +/// # Parsing Process +/// +/// ```text +/// syn::Meta::List("api(...)") +/// │ +/// ▼ +/// parse_nested_meta(|nested| { +/// match nested.path { +/// "tag" → config.tag = Some(value) +/// "handlers" → parse handlers syntax +/// ... +/// } +/// }) +/// │ +/// ▼ +/// ApiConfig +/// ``` +/// +/// # Handler Parsing +/// +/// The `handlers` option has special parsing logic: +/// +/// | Syntax | Interpretation | +/// |--------|----------------| +/// | `handlers` | Enable all handlers | +/// | `handlers = true` | Enable all handlers | +/// | `handlers = false` | Disable all handlers | +/// | `handlers(create, get)` | Enable specific handlers | +/// +/// # Error Cases +/// +/// | Input | Error | +/// |-------|-------| +/// | `api` | "api attribute requires parameters" | +/// | `api = "value"` | "api attribute must use parentheses" | +/// | `api(unknown = "x")` | "unknown api option 'unknown'" | +/// | `api(handlers(invalid))` | "unknown handler 'invalid'" | pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { let mut config = ApiConfig::default(); diff --git a/crates/entity-derive-impl/src/entity/parse/api/tests.rs b/crates/entity-derive-impl/src/entity/parse/api/tests.rs index 00583d7..90bf54a 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/tests.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/tests.rs @@ -2,6 +2,38 @@ // SPDX-License-Identifier: MIT //! Tests for API configuration parsing. +//! +//! This module tests the `parse_api_config` function and `ApiConfig` methods. +//! Tests cover all supported attribute syntax variations and edge cases. +//! +//! # Test Categories +//! +//! | Category | Tests | Coverage | +//! |----------|-------|----------| +//! | Basic parsing | `parse_tag_only`, `parse_full_config` | Core attributes | +//! | Security | `parse_public_commands`, `security_for_public_command` | Auth config | +//! | Handlers | `parse_handlers_*` | CRUD handler selection | +//! | Paths | `full_path_prefix_*` | URL construction | +//! | Defaults | `default_*` | Default value behavior | +//! +//! # Test Methodology +//! +//! Tests parse attribute strings directly using `syn::parse_str`: +//! +//! ```rust,ignore +//! let config = parse_test_config(r#"api(tag = "Users")"#); +//! assert_eq!(config.tag, Some("Users".to_string())); +//! ``` +//! +//! # Handler Selection Tests +//! +//! The handler tests verify all three syntax forms: +//! +//! | Form | Test | +//! |------|------| +//! | `handlers` | `parse_handlers_flag` | +//! | `handlers = true` | `parse_handlers_true` | +//! | `handlers(...)` | `parse_handlers_selective` | use super::*; diff --git a/crates/entity-derive-impl/src/entity/parse/command/mod.rs b/crates/entity-derive-impl/src/entity/parse/command/mod.rs index 3a3e4e8..8f2cc1a 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/mod.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/mod.rs @@ -1,28 +1,141 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Command definition and parsing. +//! Command definition and parsing for CQRS-style operations. //! -//! Commands define business operations on entities, following CQRS pattern. -//! Instead of generic CRUD, you get domain-specific commands like -//! `RegisterUser`, `UpdateEmail`, `DeactivateAccount`. +//! This module handles parsing of `#[command(...)]` attributes that define +//! domain-specific business operations on entities. Commands follow the +//! CQRS (Command Query Responsibility Segregation) pattern, providing +//! explicit, named operations instead of generic CRUD. //! -//! # Syntax +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Command Parsing │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Attribute Parser Output │ +//! │ │ +//! │ #[command(Register)] parse_command_attrs() CommandDef │ +//! │ #[command(UpdateEmail: │ │ │ +//! │ email, name)] │ ├── name │ +//! │ #[command(Deactivate, │ ├── source │ +//! │ requires_id)] │ ├── requires_id│ +//! │ ▼ └── kind │ +//! │ │ +//! │ Vec │ +//! │ │ │ +//! │ ▼ │ +//! │ Code Generation │ +//! │ ├── RegisterUser struct │ +//! │ ├── UpdateEmailUser struct │ +//! │ ├── UserCommand enum │ +//! │ └── UserCommandHandler trait │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Command Syntax +//! +//! Commands support multiple syntax forms: +//! +//! ## Simple Command +//! +//! Uses fields marked with `#[field(create)]`: +//! +//! ```rust,ignore +//! #[command(Register)] +//! ``` +//! +//! ## Field-Specific Command +//! +//! Uses only the listed fields (requires ID): +//! +//! ```rust,ignore +//! #[command(UpdateEmail: email)] +//! #[command(UpdateProfile: name, avatar, bio)] +//! ``` +//! +//! ## ID-Only Command +//! +//! No fields, just the entity ID: +//! +//! ```rust,ignore +//! #[command(Deactivate, requires_id)] +//! #[command(Delete, requires_id, kind = "delete")] +//! ``` +//! +//! ## Custom Payload Command +//! +//! Uses an external struct for the payload: //! //! ```rust,ignore -//! #[command(Register)] // uses create fields -//! #[command(UpdateEmail: email)] // specific fields only -//! #[command(Deactivate, requires_id)] // id only, no fields -//! #[command(Transfer, payload = "TransferPayload")] // custom payload struct +//! #[command(Transfer, payload = "TransferPayload")] +//! #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] //! ``` //! +//! # Command Options +//! +//! | Option | Type | Description | +//! |--------|------|-------------| +//! | `requires_id` | flag | Command needs entity ID | +//! | `source` | string | Field source: `"create"`, `"update"`, `"none"` | +//! | `payload` | string | Custom payload struct type | +//! | `result` | string | Custom result type | +//! | `kind` | string | Kind hint: `"create"`, `"update"`, `"delete"`, `"custom"` | +//! | `security` | string | Security override: scheme name or `"none"` | +//! //! # Generated Code //! -//! Each command generates: -//! - A command struct (e.g., `RegisterUser`) -//! - An entry in `UserCommand` enum -//! - An entry in `UserCommandResult` enum -//! - A handler method in `UserCommandHandler` trait +//! For entity `User` with commands: +//! +//! ```rust,ignore +//! #[command(Register)] +//! #[command(UpdateEmail: email)] +//! #[command(Deactivate, requires_id)] +//! ``` +//! +//! Generates: +//! +//! ```rust,ignore +//! // Command structs +//! pub struct RegisterUser { +//! pub name: String, +//! pub email: String, +//! } +//! +//! pub struct UpdateEmailUser { +//! pub id: Uuid, +//! pub email: String, +//! } +//! +//! pub struct DeactivateUser { +//! pub id: Uuid, +//! } +//! +//! // Command enum +//! pub enum UserCommand { +//! Register(RegisterUser), +//! UpdateEmail(UpdateEmailUser), +//! Deactivate(DeactivateUser), +//! } +//! +//! // Handler trait +//! #[async_trait] +//! pub trait UserCommandHandler { +//! async fn handle_register(&self, cmd: RegisterUser) -> Result; +//! async fn handle_update_email(&self, cmd: UpdateEmailUser) -> Result; +//! async fn handle_deactivate(&self, cmd: DeactivateUser) -> Result<(), Error>; +//! } +//! ``` +//! +//! # Module Structure +//! +//! | Module | Purpose | +//! |--------|---------| +//! | [`types`] | Type definitions: `CommandDef`, `CommandSource`, `CommandKindHint` | +//! | [`parser`] | Attribute parsing: `parse_command_attrs` | mod parser; mod types; diff --git a/crates/entity-derive-impl/src/entity/parse/command/parser.rs b/crates/entity-derive-impl/src/entity/parse/command/parser.rs index df530a0..bb3aa85 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/parser.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/parser.rs @@ -1,35 +1,137 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Command attribute parsing. +//! Command attribute parsing from `#[command(...)]`. +//! +//! This module provides the parser that extracts command definitions from +//! `#[command(...)]` attributes on entity structs. It handles all syntax +//! variations and produces `CommandDef` instances for code generation. +//! +//! # Parsing Architecture +//! +//! ```text +//! Input Attributes Parser Output +//! +//! #[command(Register)] parse_command_attrs() Vec +//! #[command(Update: email)] │ │ +//! #[command(Delete, │ ├── CommandDef { +//! requires_id)] │ │ name: "Register" +//! │ │ │ source: Create +//! ▼ │ │ } +//! &[Attribute] ──────────────────►│ ├── CommandDef { +//! │ │ name: "Update" +//! │ │ source: Fields +//! │ │ } +//! │ └── ... +//! ▼ +//! filter "command" +//! parse_single_command() +//! │ +//! ▼ +//! Vec +//! ``` +//! +//! # Syntax Forms +//! +//! The parser supports several syntax variations: +//! +//! ## Basic Command +//! +//! ```rust,ignore +//! #[command(Register)] // Uses create fields, no ID +//! ``` +//! +//! ## Field Selection with Colon +//! +//! ```rust,ignore +//! #[command(UpdateEmail: email)] // Single field +//! #[command(UpdateProfile: name, bio)] // Multiple fields +//! ``` +//! +//! ## Options After Comma +//! +//! ```rust,ignore +//! #[command(Delete, requires_id)] +//! #[command(Modify, source = "update")] +//! #[command(Process, kind = "custom")] +//! #[command(Transfer, payload = "TransferPayload")] +//! #[command(AdminOp, security = "admin")] +//! ``` +//! +//! # Option Reference +//! +//! | Option | Syntax | Effect | +//! |--------|--------|--------| +//! | `requires_id` | flag | Sets `requires_id = true`, source to `None` | +//! | `source` | `= "create/update/none"` | Sets field source | +//! | `payload` | `= "TypeName"` | Uses custom payload type | +//! | `result` | `= "TypeName"` | Uses custom result type | +//! | `kind` | `= "create/update/delete/custom"` | Sets kind hint | +//! | `security` | `= "scheme/none"` | Sets security override | +//! +//! # Error Handling +//! +//! Invalid commands are silently filtered out (via `filter_map`). +//! This allows partial compilation with some valid commands even if +//! others have syntax errors. use syn::{Attribute, Ident, Type}; use super::types::{CommandDef, CommandKindHint, CommandSource}; -/// Parse `#[command(...)]` attributes. +/// Parses all `#[command(...)]` attributes from a struct. /// -/// Extracts all command definitions from the struct's attributes. +/// This function filters struct attributes for `#[command(...)]`, parses +/// each one, and collects valid command definitions. Invalid commands are +/// silently skipped to allow partial success. /// /// # Arguments /// -/// * `attrs` - Slice of syn Attributes from the struct +/// * `attrs` - Slice of `syn::Attribute` from the struct definition /// /// # Returns /// -/// Vector of parsed command definitions. +/// A `Vec` containing all successfully parsed commands. +/// May be empty if no valid commands are found. +/// +/// # Parsing Process +/// +/// ```text +/// attrs.iter() +/// │ +/// ├─► filter(is "command") ──► Only #[command(...)] attrs +/// │ +/// ├─► filter_map(parse) ────► Parse each, skip errors +/// │ +/// └─► collect() ────────────► Vec +/// ``` /// /// # Syntax Examples /// /// ```text -/// #[command(Register)] // name only (create fields) -/// #[command(Register, source = "create")] // explicit source -/// #[command(UpdateEmail: email)] // specific fields -/// #[command(UpdateEmail: email, name)] // multiple fields -/// #[command(Deactivate, requires_id)] // id-only command -/// #[command(Deactivate, requires_id, kind = "delete")] // with kind hint -/// #[command(Transfer, payload = "TransferPayload")] // custom payload -/// #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] // custom result +/// // Basic command (uses create fields) +/// #[command(Register)] +/// +/// // Explicit source selection +/// #[command(Register, source = "create")] +/// +/// // Specific fields (colon syntax) +/// #[command(UpdateEmail: email)] +/// #[command(UpdateProfile: name, avatar, bio)] +/// +/// // ID-only command +/// #[command(Deactivate, requires_id)] +/// #[command(Delete, requires_id, kind = "delete")] +/// +/// // Custom payload +/// #[command(Transfer, payload = "TransferPayload")] +/// +/// // Custom result +/// #[command(Transfer, payload = "TransferPayload", result = "TransferResult")] +/// +/// // Security override +/// #[command(PublicList, security = "none")] +/// #[command(AdminDelete, requires_id, security = "admin")] /// ``` pub fn parse_command_attrs(attrs: &[Attribute]) -> Vec { attrs diff --git a/crates/entity-derive-impl/src/entity/parse/command/tests.rs b/crates/entity-derive-impl/src/entity/parse/command/tests.rs index 5a27187..afb1fc1 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/tests.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/tests.rs @@ -1,7 +1,49 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Tests for command parsing. +//! Tests for command attribute parsing. +//! +//! This module contains comprehensive tests for the `#[command(...)]` attribute +//! parser. Tests cover all syntax variations, edge cases, and error handling. +//! +//! # Test Categories +//! +//! | Category | Tests | Coverage | +//! |----------|-------|----------| +//! | Basic | `parse_simple_command` | Name-only syntax | +//! | Fields | `parse_command_with_fields`, `*_multiple_fields` | Colon syntax | +//! | Options | `parse_requires_id_*`, `parse_source_*` | Option parsing | +//! | Payload | `parse_custom_payload_*`, `parse_command_with_result` | Custom types | +//! | Kind | `parse_kind_*` | Kind hint validation | +//! | Security | `parse_security_*` | Security override | +//! | Naming | `struct_name_*`, `handler_method_name_*` | Name generation | +//! | Errors | `parse_invalid_*`, `parse_unknown_*` | Error handling | +//! +//! # Test Methodology +//! +//! Tests use `syn::parse_quote!` to create struct definitions with attributes, +//! then verify the parsed `CommandDef` fields match expectations: +//! +//! ```rust,ignore +//! let input: syn::DeriveInput = syn::parse_quote! { +//! #[command(Register)] +//! struct User {} +//! }; +//! let cmds = parse_command_attrs(&input.attrs); +//! assert_eq!(cmds[0].name.to_string(), "Register"); +//! ``` +//! +//! # Field Source Tests +//! +//! Tests verify correct source selection: +//! +//! | Input | Expected Source | +//! |-------|-----------------| +//! | `Register` | `Create` (default) | +//! | `source = "update"` | `Update` | +//! | `UpdateEmail: email` | `Fields(["email"])` | +//! | `payload = "T"` | `Custom(T)` | +//! | `requires_id` | `None` | use proc_macro2::Span; use syn::Ident; diff --git a/crates/entity-derive-impl/src/entity/parse/command/types.rs b/crates/entity-derive-impl/src/entity/parse/command/types.rs index 29986b5..f7efc73 100644 --- a/crates/entity-derive-impl/src/entity/parse/command/types.rs +++ b/crates/entity-derive-impl/src/entity/parse/command/types.rs @@ -1,14 +1,99 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Command types and definitions. +//! Command type definitions and data structures. +//! +//! This module defines the types used to represent parsed command definitions. +//! These types capture all configuration from `#[command(...)]` attributes +//! and are used by code generation to produce command structs, enums, and +//! handler traits. +//! +//! # Type Overview +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ Command Types │ +//! ├─────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ CommandDef │ +//! │ ├─► name: Ident # Command name (e.g., "Register") │ +//! │ ├─► source: CommandSource # Where to get fields │ +//! │ ├─► requires_id: bool # Needs entity ID? │ +//! │ ├─► result_type: Option # Custom result │ +//! │ ├─► kind: CommandKindHint # Categorization │ +//! │ └─► security: Option # Security override │ +//! │ │ +//! │ CommandSource │ +//! │ ├─► Create # Use #[field(create)] fields │ +//! │ ├─► Update # Use #[field(update)] fields │ +//! │ ├─► Fields # Use specific named fields │ +//! │ ├─► Custom # Use external payload struct │ +//! │ └─► None # No payload fields │ +//! │ │ +//! │ CommandKindHint │ +//! │ ├─► Create # Creates new entity │ +//! │ ├─► Update # Modifies existing entity │ +//! │ ├─► Delete # Removes entity │ +//! │ └─► Custom # Business-specific operation │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Field Selection +//! +//! `CommandSource` determines which entity fields appear in the command struct: +//! +//! | Source | Behavior | +//! |--------|----------| +//! | `Create` | Include fields with `#[field(create)]` | +//! | `Update` | Include fields with `#[field(update)]` | +//! | `Fields(vec)` | Include only the named fields | +//! | `Custom(ty)` | Use the specified type directly | +//! | `None` | No fields (ID-only or action commands) | +//! +//! # Naming Conventions +//! +//! Command names are transformed for generated code: +//! +//! | Method | Input | Output | +//! |--------|-------|--------| +//! | `struct_name("User")` | `Register` | `RegisterUser` | +//! | `handler_method_name()` | `UpdateEmail` | `handle_update_email` | use proc_macro2::Span; use syn::{Ident, Type}; -/// Source of fields for a command. +/// Determines the source of fields for a command payload. /// -/// Determines which entity fields are included in the command payload. +/// The source specifies which entity fields should be included in the +/// generated command struct. This enables flexible command definitions +/// that can share fields with CRUD DTOs or define custom payloads. +/// +/// # Variants +/// +/// ```text +/// CommandSource +/// │ +/// ├─► Create ──► Fields from #[field(create)] +/// │ +/// ├─► Update ──► Fields from #[field(update)] +/// │ +/// ├─► Fields ──► Explicitly listed fields +/// │ +/// ├─► Custom ──► External struct type +/// │ +/// └─► None ────► No payload (ID-only) +/// ``` +/// +/// # Examples +/// +/// | Attribute | Source | +/// |-----------|--------| +/// | `#[command(Register)]` | `Create` | +/// | `#[command(Modify, source = "update")]` | `Update` | +/// | `#[command(UpdateEmail: email)]` | `Fields(["email"])` | +/// | `#[command(Transfer, payload = "TransferPayload")]` | `Custom(TransferPayload)` | +/// | `#[command(Delete, requires_id)]` | `None` | #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum CommandSource { /// Use fields marked with `#[field(create)]`. diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index f030513..5d100d0 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -1,39 +1,140 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Entity-level attribute parsing. +//! Entity-level attribute parsing and definition. //! -//! This module handles parsing of entity-level attributes using darling, -//! and provides the main [`EntityDef`] structure used by all code generators. +//! This module is the heart of the entity-derive macro system. It parses +//! `#[entity(...)]` attributes and produces `EntityDef`, the central data +//! structure that drives all code generation. //! -//! # Module Structure +//! # Architecture //! //! ```text -//! entity/ -//! ├── mod.rs — Re-exports and module declarations -//! ├── def.rs — EntityDef struct definition -//! ├── constructor.rs — EntityDef::from_derive_input() -//! ├── accessors.rs — EntityDef accessor methods -//! ├── attrs.rs — EntityAttrs (darling parsing struct) -//! ├── helpers.rs — Helper parsing functions -//! ├── projection.rs — Projection definition and parsing -//! └── tests.rs — Unit tests +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Entity Parsing Pipeline │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Input Parsing Output │ +//! │ │ +//! │ #[entity( EntityDef:: │ +//! │ table = "users", from_derive_input() EntityDef │ +//! │ soft_delete, │ │ │ +//! │ events │ │ │ +//! │ )] ▼ │ │ +//! │ struct User { ┌─────────────┐ │ │ +//! │ #[id] │ EntityAttrs │ ◄── darling │ │ +//! │ id: Uuid, │ (entity-lvl)│ │ │ +//! │ #[field(create)] └─────────────┘ │ │ +//! │ name: String, │ │ │ +//! │ } │ ▼ │ +//! │ ┌─────────────┐ ┌───────────┐ │ +//! │ │ FieldDef │ ◄─────────│ EntityDef │ │ +//! │ │ (per field) │ │ + fields │ │ +//! │ └─────────────┘ └───────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ Code Generation │ +//! │ ├── SQL layer │ +//! │ ├── DTO structs │ +//! │ ├── Repository │ +//! │ └── API handlers │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ //! ``` //! -//! # Usage +//! # Module Structure +//! +//! | File | Purpose | +//! |------|---------| +//! | `def.rs` | `EntityDef` struct definition with all fields | +//! | `constructor.rs` | `from_derive_input()` implementation | +//! | `accessors.rs` | Accessor methods for fields and metadata | +//! | `attrs.rs` | `EntityAttrs` darling parsing struct | +//! | `helpers.rs` | Helper functions for parsing relations and API | +//! | `projection.rs` | Projection definition and parsing | +//! | `tests.rs` | Comprehensive unit tests | +//! +//! # Entity Attributes +//! +//! The `#[entity(...)]` attribute supports extensive configuration: +//! +//! ## Required Attributes +//! +//! | Attribute | Description | +//! |-----------|-------------| +//! | `table` | Database table name (e.g., `"users"`) | +//! +//! ## Optional Attributes +//! +//! | Attribute | Default | Description | +//! |-----------|---------|-------------| +//! | `schema` | `"public"` | Database schema | +//! | `sql` | `Full` | SQL generation level | +//! | `dialect` | `Postgres` | Database dialect | +//! | `uuid` | `V7` | UUID version for IDs | +//! | `error` | `sqlx::Error` | Custom error type | +//! | `returning` | `Full` | RETURNING clause mode | +//! +//! ## Feature Flags +//! +//! | Flag | Effect | +//! |------|--------| +//! | `soft_delete` | Enable soft delete with `deleted_at` field | +//! | `events` | Generate `{Entity}Event` enum | +//! | `hooks` | Generate `{Entity}Hooks` trait | +//! | `commands` | Enable CQRS command pattern | +//! | `policy` | Generate authorization policy trait | +//! | `streams` | Enable real-time LISTEN/NOTIFY streaming | +//! | `transactions` | Generate transaction support | +//! +//! # Usage Example //! //! ```rust,ignore //! use crate::entity::parse::EntityDef; //! +//! // Parse from derive input //! let entity = EntityDef::from_derive_input(&input)?; //! //! // Access entity metadata -//! let table = entity.full_table_name(); -//! let id_field = entity.id_field(); +//! let table = entity.full_table_name(); // "public.users" +//! let id = entity.id_field(); // FieldDef for #[id] field //! -//! // Access field categories -//! let create_fields = entity.create_fields(); -//! let update_fields = entity.update_fields(); +//! // Access field categories for DTO generation +//! let create_fields = entity.create_fields(); // #[field(create)] +//! let update_fields = entity.update_fields(); // #[field(update)] +//! let response_fields = entity.response_fields(); // #[field(response)] +//! +//! // Generate related type names +//! let row_ident = entity.ident_with("", "Row"); // UserRow +//! let repo_ident = entity.ident_with("", "Repository"); // UserRepository +//! ``` +//! +//! # Field Categories +//! +//! Fields are categorized based on `#[field(...)]` attributes: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────┐ +//! │ Field Categories │ +//! ├──────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ create_fields() ──► CreateUserRequest │ +//! │ ├─► #[field(create)] │ +//! │ ├─► NOT #[id] │ +//! │ └─► NOT #[auto] │ +//! │ │ +//! │ update_fields() ──► UpdateUserRequest │ +//! │ ├─► #[field(update)] │ +//! │ ├─► NOT #[id] │ +//! │ └─► NOT #[auto] │ +//! │ │ +//! │ response_fields() ──► UserResponse │ +//! │ └─► #[field(response)] OR #[id] │ +//! │ │ +//! │ all_fields() ──► UserRow, InsertableUser │ +//! │ └─► All fields (database layer) │ +//! │ │ +//! └──────────────────────────────────────────────────────────────┘ //! ``` mod accessors; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs b/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs index d94c9b1..fc8ab3a 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs @@ -2,6 +2,58 @@ // SPDX-License-Identifier: MIT //! Accessor methods for EntityDef. +//! +//! This module provides getter methods for accessing `EntityDef` fields and +//! computed values. Methods are organized by purpose: field access, naming +//! helpers, and feature flags. +//! +//! # Method Categories +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ EntityDef Accessors │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Field Access Naming Feature Checks │ +//! │ ├── id_field() ├── name() ├── is_soft_delete() │ +//! │ ├── create_fields() ├── name_str() ├── has_events() │ +//! │ ├── update_fields() ├── full_table_name() ├── has_hooks() │ +//! │ ├── response_fields() └── ident_with() ├── has_commands() │ +//! │ ├── all_fields() ├── has_policy() │ +//! │ ├── relation_fields() ├── has_streams() │ +//! │ └── filter_fields() ├── has_transactions()│ +//! │ ├── has_api() │ +//! │ Configuration └── has_filters() │ +//! │ ├── error_type() │ +//! │ ├── api_config() │ +//! │ ├── command_defs() │ +//! │ └── doc() │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Field Category Methods +//! +//! These methods return filtered field collections for DTO generation: +//! +//! | Method | Returns | Used For | +//! |--------|---------|----------| +//! | `id_field()` | Primary key field | All DTOs and queries | +//! | `create_fields()` | `#[field(create)]` fields | `CreateRequest` DTO | +//! | `update_fields()` | `#[field(update)]` fields | `UpdateRequest` DTO | +//! | `response_fields()` | `#[field(response)]` + ID | `Response` DTO | +//! | `all_fields()` | All fields | `Row`, `Insertable` | +//! | `relation_fields()` | `#[belongs_to]` fields | Relation methods | +//! | `filter_fields()` | `#[filter]` fields | Query struct | +//! +//! # Naming Methods +//! +//! | Method | Example | Result | +//! |--------|---------|--------| +//! | `name()` | `User` | `Ident("User")` | +//! | `name_str()` | `User` | `"User"` | +//! | `full_table_name()` | `public.users` | `"public.users"` | +//! | `ident_with("Create", "Request")` | `User` | `Ident("CreateUserRequest")` | use proc_macro2::Span; use syn::Ident; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs index 58dc666..37d5ddc 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -1,7 +1,59 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! EntityDef constructor (from_derive_input). +//! EntityDef constructor implementation. +//! +//! This module provides [`EntityDef::from_derive_input`], the main entry point +//! for parsing entity definitions from proc-macro input. +//! +//! # Parsing Pipeline +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ from_derive_input() Pipeline │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ DeriveInput │ +//! │ │ │ +//! │ ├─► EntityAttrs::from_derive_input() ──► Entity-level attrs │ +//! │ │ │ +//! │ ├─► Extract fields ──► FieldDef::from_field() ──► Vec│ +//! │ │ │ +//! │ ├─► parse_has_many_attrs() ──► Vec (relations) │ +//! │ │ │ +//! │ ├─► parse_projection_attrs() ──► Vec │ +//! │ │ │ +//! │ ├─► parse_command_attrs() ──► Vec │ +//! │ │ │ +//! │ ├─► parse_api_attr() ──► ApiConfig │ +//! │ │ │ +//! │ ├─► extract_doc_comments() ──► Option │ +//! │ │ │ +//! │ └─► Find #[id] field index ──► usize │ +//! │ │ +//! │ ▼ │ +//! │ EntityDef (combined result) │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Validation +//! +//! The constructor validates: +//! +//! | Check | Error | +//! |-------|-------| +//! | Must be struct | "Entity can only be derived for structs" | +//! | Must have named fields | "Entity requires named fields" | +//! | Must have `#[id]` field | "Entity must have exactly one field with #[id]" | +//! | Required attributes | darling errors for missing `table` | +//! +//! # Error Handling +//! +//! Returns `darling::Result` which provides: +//! - Accumulated errors (multiple errors reported at once) +//! - Span information for error messages +//! - Integration with proc-macro-error for nice diagnostics use darling::FromDeriveInput; use syn::DeriveInput; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/def.rs b/crates/entity-derive-impl/src/entity/parse/entity/def.rs index 9f807eb..f1c9ddb 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/def.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -2,6 +2,59 @@ // SPDX-License-Identifier: MIT //! EntityDef struct definition. +//! +//! This module defines [`EntityDef`], the central data structure for the entire +//! entity-derive macro system. All code generators receive an `EntityDef` and +//! use its fields to produce the appropriate Rust code. +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ EntityDef Structure │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Identity Configuration Feature Flags │ +//! │ ├── ident ├── table ├── soft_delete │ +//! │ ├── vis ├── schema ├── events │ +//! │ └── doc ├── sql ├── hooks │ +//! │ ├── dialect ├── commands │ +//! │ ├── uuid ├── policy │ +//! │ ├── error ├── streams │ +//! │ └── returning └── transactions │ +//! │ │ +//! │ Fields Relations API │ +//! │ ├── fields[] ├── has_many[] └── api_config │ +//! │ └── id_field_index └── projections[] ├── tag │ +//! │ ├── security │ +//! │ Commands └── handlers │ +//! │ └── command_defs[] │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Field Categories +//! +//! | Category | Accessor | Purpose | +//! |----------|----------|---------| +//! | Identity | `ident`, `vis` | Struct name and visibility | +//! | Table | `table`, `schema` | Database location | +//! | Behavior | `sql`, `dialect`, `uuid` | Code generation options | +//! | Features | `soft_delete`, `events`, etc. | Optional features | +//! | Fields | `fields`, `id_field_index` | Field definitions | +//! | Relations | `has_many`, `projections` | Entity relationships | +//! | Commands | `command_defs` | CQRS command definitions | +//! | API | `api_config` | HTTP handler configuration | +//! +//! # Lifetime +//! +//! `EntityDef` is created once during macro expansion and passed to all +//! generators. It owns all its data (no lifetimes) for simplicity. +//! +//! # Construction +//! +//! Use [`EntityDef::from_derive_input`] (in `constructor.rs`) to create +//! from a `syn::DeriveInput`. use syn::{Ident, Visibility}; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs index 426f4ff..c20382b 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs @@ -1,7 +1,60 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Helper functions for entity parsing. +//! Helper functions for entity attribute parsing. +//! +//! This module provides utility functions for parsing entity-level attributes +//! that don't fit naturally into darling's derive-based parsing. These helpers +//! handle manual attribute parsing for relations and nested configurations. +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Helper Parsing Functions │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Entity Attributes Helpers Output │ +//! │ │ +//! │ #[has_many(Post)] parse_has_many_attrs() Vec │ +//! │ #[has_many(Comment)] │ [Post, Comment] │ +//! │ │ │ │ +//! │ └────────────────────────┘ │ +//! │ │ +//! │ #[entity( parse_api_attr() ApiConfig │ +//! │ table = "users", │ ├── tag │ +//! │ api( │ ├── security │ +//! │ tag = "Users", │ └── handlers │ +//! │ security = "bearer" │ │ +//! │ ) │ │ +//! │ )] │ │ +//! │ │ │ │ +//! │ └────────────────────────┘ │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Functions +//! +//! | Function | Input | Output | +//! |----------|-------|--------| +//! | [`parse_has_many_attrs`] | `&[Attribute]` | `Vec` | +//! | [`parse_api_attr`] | `&[Attribute]` | `ApiConfig` | +//! +//! # Usage Context +//! +//! These functions are called from [`EntityDef::from_derive_input`] during +//! the entity parsing process. They complement darling's automatic parsing +//! by handling attributes with custom syntax. +//! +//! # Why Not Darling? +//! +//! Some attributes require manual parsing because: +//! +//! | Attribute | Reason | +//! |-----------|--------| +//! | `#[has_many(...)]` | Multiple instances, simple syntax | +//! | `api(...)` | Nested inside `#[entity(...)]`, complex structure | use syn::{Attribute, Ident}; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/projection.rs b/crates/entity-derive-impl/src/entity/parse/entity/projection.rs index ef441f5..b85a251 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/projection.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/projection.rs @@ -4,21 +4,58 @@ //! Projection definition and parsing. //! //! Projections define partial views of an entity, allowing optimized SELECT -//! queries that only fetch the needed columns. +//! queries that only fetch the needed columns. This is useful for APIs that +//! need different levels of detail for different use cases. //! -//! # Syntax +//! # Architecture //! -//! ```rust,ignore -//! #[projection(Public: id, name, avatar)] -//! #[projection(Admin: id, name, email, role)] +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Projection System │ +//! ├─────────────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ Attribute Syntax │ +//! │ │ +//! │ #[projection(Public: id, name, avatar)] │ +//! │ #[projection(Admin: id, name, email, role, created_at)] │ +//! │ │ │ └─ field list │ +//! │ │ └────── colon separator │ +//! │ └──────────────── projection name │ +//! │ │ +//! │ Generated Code │ +//! │ │ +//! │ ┌─────────────────┐ ┌─────────────────┐ │ +//! │ │ UserPublic │ │ UserAdmin │ │ +//! │ │ ├── id: Uuid │ │ ├── id: Uuid │ │ +//! │ │ ├── name: String│ │ ├── name: String│ │ +//! │ │ └── avatar: Url │ │ ├── email: String│ │ +//! │ └─────────────────┘ │ ├── role: Role │ │ +//! │ │ └── created_at │ │ +//! │ └─────────────────┘ │ +//! │ │ +//! │ Repository Methods │ +//! │ │ +//! │ repo.find_by_id_public(id) → UserPublic │ +//! │ repo.find_by_id_admin(id) → UserAdmin │ +//! │ │ +//! └─────────────────────────────────────────────────────────────────────┘ //! ``` //! +//! # Use Cases +//! +//! | Projection | Use Case | +//! |------------|----------| +//! | `Public` | User-facing API responses (no sensitive data) | +//! | `Admin` | Admin panel with full details | +//! | `List` | Minimal fields for list views | +//! | `Detail` | Extended fields for detail views | +//! //! # Generated Code //! //! Each projection generates: //! - A struct with the specified fields (e.g., `UserPublic`) -//! - A `From` implementation -//! - A `find_by_id_{name}` repository method +//! - A `From` implementation for conversion +//! - A `find_by_id_{name}` repository method with optimized SELECT use syn::{Attribute, Ident}; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/tests.rs b/crates/entity-derive-impl/src/entity/parse/entity/tests.rs index 711d5d5..bd079f9 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/tests.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/tests.rs @@ -2,6 +2,47 @@ // SPDX-License-Identifier: MIT //! Tests for entity parsing. +//! +//! This module contains comprehensive tests for `EntityDef` parsing from +//! `#[entity(...)]` attributes. Tests cover all configuration options, +//! error handling, and edge cases. +//! +//! # Test Categories +//! +//! | Category | Tests | Coverage | +//! |----------|-------|----------| +//! | Defaults | `default_error_type_is_sqlx_error` | Default values | +//! | Accessors | `entity_def_error_type_accessor` | Method correctness | +//! | API Config | `entity_def_with_api`, `*_full_api_config` | API parsing | +//! | Security | `entity_def_api_with_public_commands` | Security overrides | +//! | No API | `entity_def_without_api` | API disabled | +//! +//! # Test Methodology +//! +//! Tests use `syn::parse_quote!` to create struct definitions with attributes, +//! then verify the parsed `EntityDef` fields match expectations: +//! +//! ```rust,ignore +//! let input: DeriveInput = syn::parse_quote! { +//! #[entity(table = "users")] +//! pub struct User { +//! #[id] +//! pub id: Uuid, +//! } +//! }; +//! let entity = EntityDef::from_derive_input(&input).unwrap(); +//! assert!(!entity.has_api()); +//! ``` +//! +//! # API Configuration Tests +//! +//! Tests verify correct parsing of nested `api(...)` configuration: +//! +//! | Test | Configuration | Verified | +//! |------|---------------|----------| +//! | `entity_def_with_api` | `api(tag = "Users")` | Tag parsing | +//! | `entity_def_with_full_api_config` | All options | Full configuration | +//! | `entity_def_api_with_public_commands` | `public = [...]` | Security per command | use syn::DeriveInput; From 72f8b87bd749931a02752e0956f2a7c7460675c3 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 15:24:20 +0700 Subject: [PATCH 26/30] fmt --- .../src/entity/api/{crud/mod.rs => crud.rs} | 0 .../src/entity/api/{openapi/mod.rs => openapi.rs} | 0 .../src/entity/api/openapi/paths.rs | 7 ++++--- .../src/entity/api/openapi/schemas.rs | 6 +++--- .../src/entity/api/openapi/security.rs | 15 ++++++--------- .../src/entity/api/openapi/tests.rs | 9 +++++---- .../src/entity/parse/{api/mod.rs => api.rs} | 0 .../src/entity/parse/api/config.rs | 4 ++-- .../entity/parse/{command/mod.rs => command.rs} | 0 9 files changed, 20 insertions(+), 21 deletions(-) rename crates/entity-derive-impl/src/entity/api/{crud/mod.rs => crud.rs} (100%) rename crates/entity-derive-impl/src/entity/api/{openapi/mod.rs => openapi.rs} (100%) rename crates/entity-derive-impl/src/entity/parse/{api/mod.rs => api.rs} (100%) rename crates/entity-derive-impl/src/entity/parse/{command/mod.rs => command.rs} (100%) diff --git a/crates/entity-derive-impl/src/entity/api/crud/mod.rs b/crates/entity-derive-impl/src/entity/api/crud.rs similarity index 100% rename from crates/entity-derive-impl/src/entity/api/crud/mod.rs rename to crates/entity-derive-impl/src/entity/api/crud.rs diff --git a/crates/entity-derive-impl/src/entity/api/openapi/mod.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs similarity index 100% rename from crates/entity-derive-impl/src/entity/api/openapi/mod.rs rename to crates/entity-derive-impl/src/entity/api/openapi.rs diff --git a/crates/entity-derive-impl/src/entity/api/openapi/paths.rs b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs index 3fac9e7..e19e322 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/paths.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs @@ -122,7 +122,8 @@ use crate::entity::parse::{CommandDef, EntityDef}; /// /// # Returns /// -/// A `TokenStream` containing code to add paths via `openapi.paths.add_path_operation()`. +/// A `TokenStream` containing code to add paths via +/// `openapi.paths.add_path_operation()`. /// /// # Conditional Generation /// @@ -584,8 +585,8 @@ pub fn build_item_path(entity: &EntityDef) -> String { /// Generates the handler function name for a command. /// -/// Command handlers follow the naming pattern `{command}_{entity}` in snake_case, -/// consistent with the CRUD handler naming convention. +/// Command handlers follow the naming pattern `{command}_{entity}` in +/// snake_case, consistent with the CRUD handler naming convention. /// /// # Arguments /// diff --git a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs index 3f9f7bd..3b93c5d 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs @@ -3,9 +3,9 @@ //! OpenAPI schema generation for DTOs and common types. //! -//! This module generates schema registrations for the OpenAPI components section. -//! Schemas define the structure of request/response bodies and are referenced -//! throughout the API specification. +//! This module generates schema registrations for the OpenAPI components +//! section. Schemas define the structure of request/response bodies and are +//! referenced throughout the API specification. //! //! # OpenAPI Components/Schemas //! diff --git a/crates/entity-derive-impl/src/entity/api/openapi/security.rs b/crates/entity-derive-impl/src/entity/api/openapi/security.rs index e3a372e..15fed45 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/security.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/security.rs @@ -138,7 +138,8 @@ use quote::quote; /// /// # Arguments /// -/// * `security` - Optional security type string: `"bearer"`, `"cookie"`, or `"api_key"` +/// * `security` - Optional security type string: `"bearer"`, `"cookie"`, or +/// `"api_key"` /// /// # Returns /// @@ -247,15 +248,11 @@ pub fn generate_security_code(security: Option<&str>) -> TokenStream { /// /// The scheme name is used in two places: /// -/// 1. **Defining the scheme** (in components/securitySchemes): -/// ```rust,ignore -/// components.add_security_scheme("bearerAuth", scheme); -/// ``` +/// 1. **Defining the scheme** (in components/securitySchemes): ```rust,ignore +/// components.add_security_scheme("bearerAuth", scheme); ``` /// -/// 2. **Applying to operations** (in path operations): -/// ```rust,ignore -/// security::SecurityRequirement::new::<_, _, &str>("bearerAuth", []) -/// ``` +/// 2. **Applying to operations** (in path operations): ```rust,ignore +/// security::SecurityRequirement::new::<_, _, &str>("bearerAuth", []) ``` /// /// # Consistency /// diff --git a/crates/entity-derive-impl/src/entity/api/openapi/tests.rs b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs index c597fb8..70373b7 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/tests.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs @@ -3,9 +3,9 @@ //! Tests for OpenAPI generation. //! -//! This module contains unit tests for the OpenAPI code generation functionality. -//! Tests verify that the generated OpenAPI structs, modifiers, and schemas are -//! correct for various entity configurations. +//! This module contains unit tests for the OpenAPI code generation +//! functionality. Tests verify that the generated OpenAPI structs, modifiers, +//! and schemas are correct for various entity configurations. //! //! # Test Categories //! @@ -20,7 +20,8 @@ //! # Test Methodology //! //! Tests use `syn::parse_quote!` to create entity definitions from attribute -//! syntax, then verify the generated `TokenStream` contains expected identifiers. +//! syntax, then verify the generated `TokenStream` contains expected +//! identifiers. //! //! ```rust,ignore //! let input: syn::DeriveInput = syn::parse_quote! { diff --git a/crates/entity-derive-impl/src/entity/parse/api/mod.rs b/crates/entity-derive-impl/src/entity/parse/api.rs similarity index 100% rename from crates/entity-derive-impl/src/entity/parse/api/mod.rs rename to crates/entity-derive-impl/src/entity/parse/api.rs diff --git a/crates/entity-derive-impl/src/entity/parse/api/config.rs b/crates/entity-derive-impl/src/entity/parse/api/config.rs index 9d670dc..b4ea818 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/config.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/config.rs @@ -149,8 +149,8 @@ impl HandlerConfig { /// Complete API configuration parsed from `#[entity(api(...))]`. /// /// This struct holds all configuration options that control HTTP handler -/// generation and OpenAPI documentation. It is populated by [`parse_api_config`] -/// and consumed by code generation modules. +/// generation and OpenAPI documentation. It is populated by +/// [`parse_api_config`] and consumed by code generation modules. /// /// # Configuration Categories /// diff --git a/crates/entity-derive-impl/src/entity/parse/command/mod.rs b/crates/entity-derive-impl/src/entity/parse/command.rs similarity index 100% rename from crates/entity-derive-impl/src/entity/parse/command/mod.rs rename to crates/entity-derive-impl/src/entity/parse/command.rs From 612059f500f486f33973ecf3276b97b722ec6929 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 15:42:47 +0700 Subject: [PATCH 27/30] test: improve coverage to 94% --- crates/entity-core/src/transaction.rs | 91 +++++ .../src/entity/api/crud/helpers.rs | 178 ++++++++++ .../src/entity/api/handlers.rs | 310 +++++++++++++++++- .../src/entity/api/openapi/info.rs | 251 ++++++++++++++ .../src/entity/api/router.rs | 197 +++++++++++ 5 files changed, 1026 insertions(+), 1 deletion(-) diff --git a/crates/entity-core/src/transaction.rs b/crates/entity-core/src/transaction.rs index 279e979..9e40738 100644 --- a/crates/entity-core/src/transaction.rs +++ b/crates/entity-core/src/transaction.rs @@ -298,6 +298,8 @@ impl<'p> Transaction<'p, sqlx::PgPool> { #[cfg(test)] mod tests { + use std::error::Error; + use super::*; #[test] @@ -337,8 +339,23 @@ mod tests { let operation: TransactionError<&str> = TransactionError::Operation("e"); assert!(begin.is_begin()); + assert!(!begin.is_commit()); + assert!(!begin.is_rollback()); + assert!(!begin.is_operation()); + + assert!(!commit.is_begin()); assert!(commit.is_commit()); + assert!(!commit.is_rollback()); + assert!(!commit.is_operation()); + + assert!(!rollback.is_begin()); + assert!(!rollback.is_commit()); assert!(rollback.is_rollback()); + assert!(!rollback.is_operation()); + + assert!(!operation.is_begin()); + assert!(!operation.is_commit()); + assert!(!operation.is_rollback()); assert!(operation.is_operation()); } @@ -347,4 +364,78 @@ mod tests { let err: TransactionError<&str> = TransactionError::Operation("test"); assert_eq!(err.into_inner(), "test"); } + + #[test] + fn transaction_error_into_inner_begin() { + let err: TransactionError<&str> = TransactionError::Begin("begin_err"); + assert_eq!(err.into_inner(), "begin_err"); + } + + #[test] + fn transaction_error_into_inner_commit() { + let err: TransactionError<&str> = TransactionError::Commit("commit_err"); + assert_eq!(err.into_inner(), "commit_err"); + } + + #[test] + fn transaction_error_into_inner_rollback() { + let err: TransactionError<&str> = TransactionError::Rollback("rollback_err"); + assert_eq!(err.into_inner(), "rollback_err"); + } + + #[test] + fn transaction_error_source_begin() { + let err: TransactionError = + TransactionError::Begin(std::io::Error::other("src")); + assert!(err.source().is_some()); + } + + #[test] + fn transaction_error_source_commit() { + let err: TransactionError = + TransactionError::Commit(std::io::Error::other("src")); + assert!(err.source().is_some()); + } + + #[test] + fn transaction_error_source_rollback() { + let err: TransactionError = + TransactionError::Rollback(std::io::Error::other("src")); + assert!(err.source().is_some()); + } + + #[test] + fn transaction_error_source_operation() { + let err: TransactionError = + TransactionError::Operation(std::io::Error::other("src")); + assert!(err.source().is_some()); + } + + #[test] + fn transaction_builder_new() { + struct MockPool; + let pool = MockPool; + let tx = Transaction::new(&pool); + let _ = tx.pool(); + } + + #[test] + fn transaction_builder_pool_accessor() { + struct MockPool { + id: u32 + } + let pool = MockPool { + id: 42 + }; + let tx = Transaction::new(&pool); + assert_eq!(tx.pool().id, 42); + } + + #[test] + fn transaction_error_debug() { + let err: TransactionError<&str> = TransactionError::Begin("test"); + let debug_str = format!("{:?}", err); + assert!(debug_str.contains("Begin")); + assert!(debug_str.contains("test")); + } } diff --git a/crates/entity-derive-impl/src/entity/api/crud/helpers.rs b/crates/entity-derive-impl/src/entity/api/crud/helpers.rs index d6b424c..6637ae2 100644 --- a/crates/entity-derive-impl/src/entity/api/crud/helpers.rs +++ b/crates/entity-derive-impl/src/entity/api/crud/helpers.rs @@ -278,3 +278,181 @@ pub fn build_deprecated_attr(entity: &EntityDef) -> TokenStream { TokenStream::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collection_path_simple() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn collection_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", path_prefix = "/api/v1", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_collection_path(&entity); + assert_eq!(path, "/api/v1/users"); + } + + #[test] + fn collection_path_kebab_case() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "user_profiles", api(tag = "UserProfiles", handlers))] + pub struct UserProfile { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_collection_path(&entity); + assert_eq!(path, "/user-profiles"); + } + + #[test] + fn item_path_simple() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); + } + + #[test] + fn item_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", path_prefix = "/api/v2", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_item_path(&entity); + assert_eq!(path, "/api/v2/users/{id}"); + } + + #[test] + fn security_attr_bearer() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "bearer", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_security_attr(&entity); + let attr_str = attr.to_string(); + assert!(attr_str.contains("bearerAuth")); + } + + #[test] + fn security_attr_cookie() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_security_attr(&entity); + let attr_str = attr.to_string(); + assert!(attr_str.contains("cookieAuth")); + } + + #[test] + fn security_attr_api_key() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "api_key", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_security_attr(&entity); + let attr_str = attr.to_string(); + assert!(attr_str.contains("apiKey")); + } + + #[test] + fn security_attr_unknown_defaults_to_cookie() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", security = "custom", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_security_attr(&entity); + let attr_str = attr.to_string(); + assert!(attr_str.contains("cookieAuth")); + } + + #[test] + fn security_attr_none() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_security_attr(&entity); + assert!(attr.is_empty()); + } + + #[test] + fn deprecated_attr_present() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", deprecated_in = "2.0", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_deprecated_attr(&entity); + let attr_str = attr.to_string(); + assert!(attr_str.contains("deprecated = true")); + } + + #[test] + fn deprecated_attr_absent() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let attr = build_deprecated_attr(&entity); + assert!(attr.is_empty()); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/handlers.rs b/crates/entity-derive-impl/src/entity/api/handlers.rs index eb85b38..7a37ea9 100644 --- a/crates/entity-derive-impl/src/entity/api/handlers.rs +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -308,7 +308,7 @@ mod tests { use syn::Ident; use super::*; - use crate::entity::parse::{CommandDef, CommandSource}; + use crate::entity::parse::{CommandDef, CommandKindHint, CommandSource, EntityDef}; fn create_test_command(name: &str, requires_id: bool, kind: CommandKindHint) -> CommandDef { CommandDef { @@ -321,6 +321,37 @@ mod tests { } } + fn create_command_with_security( + name: &str, + requires_id: bool, + kind: CommandKindHint, + security: Option + ) -> CommandDef { + CommandDef { + name: Ident::new(name, Span::call_site()), + source: CommandSource::Create, + requires_id, + result_type: None, + kind, + security + } + } + + fn create_command_with_result( + name: &str, + kind: CommandKindHint, + result_type: syn::Type + ) -> CommandDef { + CommandDef { + name: Ident::new(name, Span::call_site()), + source: CommandSource::Create, + requires_id: false, + result_type: Some(result_type), + kind, + security: None + } + } + #[test] fn http_method_create() { let cmd = create_test_command("Register", false, CommandKindHint::Create); @@ -369,4 +400,281 @@ mod tests { fn security_scheme_unknown_defaults_to_bearer() { assert_eq!(security_scheme_name("unknown"), "bearer_auth"); } + + #[test] + fn handler_function_name_simple() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let name = handler_function_name(&entity, &cmd); + assert_eq!(name.to_string(), "register_user"); + } + + #[test] + fn handler_function_name_camel_case() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("UpdateEmail", true, CommandKindHint::Update); + let name = handler_function_name(&entity, &cmd); + assert_eq!(name.to_string(), "update_email_user"); + } + + #[test] + fn build_path_without_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let path = build_path(&entity, &cmd); + assert_eq!(path, "/user/register"); + } + + #[test] + fn build_path_with_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("UpdateEmail", true, CommandKindHint::Update); + let path = build_path(&entity, &cmd); + assert_eq!(path, "/user/{id}/update-email"); + } + + #[test] + fn build_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", path_prefix = "/api/v1"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let path = build_path(&entity, &cmd); + assert_eq!(path, "/api/v1/user/register"); + } + + #[test] + fn response_type_delete() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Delete, requires_id, kind = "delete")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Delete", true, CommandKindHint::Delete); + let (resp_type, _) = response_type_for_command(&entity, &cmd); + assert_eq!(resp_type.to_string(), "()"); + } + + #[test] + fn response_type_create() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let (resp_type, _) = response_type_for_command(&entity, &cmd); + assert_eq!(resp_type.to_string(), "User"); + } + + #[test] + fn response_type_custom_result() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let result_type: syn::Type = syn::parse_quote!(CustomResult); + let cmd = create_command_with_result("Transfer", CommandKindHint::Custom, result_type); + let (resp_type, _) = response_type_for_command(&entity, &cmd); + assert_eq!(resp_type.to_string(), "CustomResult"); + } + + #[test] + fn generate_empty_for_no_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_handler_without_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(output_str.contains("register_user")); + assert!(output_str.contains("UserCommandHandler")); + } + + #[test] + fn generate_handler_with_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("UpdateEmail", true, CommandKindHint::Update); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(output_str.contains("update_email_user")); + assert!(output_str.contains("Path")); + assert!(output_str.contains("cmd . id = id")); + } + + #[test] + fn generate_handler_with_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", security = "bearer"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(output_str.contains("security")); + assert!(output_str.contains("bearer_auth")); + } + + #[test] + fn generate_handler_public_command() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", security = "bearer"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_command_with_security( + "Register", + false, + CommandKindHint::Create, + Some("none".to_string()) + ); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(!output_str.contains("security")); + } + + #[test] + fn generate_handler_command_level_security() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(AdminDelete, requires_id, security = "admin")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_command_with_security( + "AdminDelete", + true, + CommandKindHint::Delete, + Some("admin".to_string()) + ); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(output_str.contains("admin_auth")); + } + + #[test] + fn generate_handler_deprecated() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", deprecated_in = "2.0"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let output = generate_handler(&entity, &cmd); + let output_str = output.to_string(); + assert!(output_str.contains("deprecated = true")); + } + + #[test] + fn generate_all_handlers() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("register_user")); + assert!(output_str.contains("update_email_user")); + } } diff --git a/crates/entity-derive-impl/src/entity/api/openapi/info.rs b/crates/entity-derive-impl/src/entity/api/openapi/info.rs index ec4babc..83d543f 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/info.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/info.rs @@ -268,3 +268,254 @@ pub fn generate_info_code(entity: &EntityDef) -> TokenStream { #deprecated_code } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_info_empty_config() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + assert!(output.is_empty() || output.to_string().is_empty()); + } + + #[test] + fn generate_info_with_title() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", title = "User API", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . title")); + assert!(output_str.contains("User API")); + } + + #[test] + fn generate_info_with_description() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", description = "Manage users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . description")); + assert!(output_str.contains("Manage users")); + } + + #[test] + fn generate_info_with_version() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", api_version = "2.0.0", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . version")); + assert!(output_str.contains("2.0.0")); + } + + #[test] + fn generate_info_with_license() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", license = "MIT", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . license")); + assert!(output_str.contains("MIT")); + } + + #[test] + fn generate_info_with_license_and_url() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api( + tag = "Users", + license = "MIT", + license_url = "https://opensource.org/licenses/MIT", + handlers + ))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("LicenseBuilder")); + assert!(output_str.contains("MIT")); + assert!(output_str.contains("opensource.org")); + } + + #[test] + fn generate_info_with_contact_name() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", contact_name = "API Team", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . contact")); + assert!(output_str.contains("ContactBuilder")); + assert!(output_str.contains("API Team")); + } + + #[test] + fn generate_info_with_contact_email() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api( + tag = "Users", + contact_name = "Support", + contact_email = "support@example.com", + handlers + ))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("email")); + assert!(output_str.contains("support@example.com")); + } + + #[test] + fn generate_info_with_contact_url() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api( + tag = "Users", + contact_name = "Support", + contact_url = "https://example.com/support", + handlers + ))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("url")); + assert!(output_str.contains("example.com/support")); + } + + #[test] + fn generate_info_with_full_contact() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api( + tag = "Users", + contact_name = "API Team", + contact_email = "api@example.com", + contact_url = "https://example.com", + handlers + ))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("ContactBuilder")); + assert!(output_str.contains("API Team")); + assert!(output_str.contains("api@example.com")); + assert!(output_str.contains("example.com")); + } + + #[test] + fn generate_info_deprecated() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", deprecated_in = "2.0", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("DEPRECATED")); + assert!(output_str.contains("2.0")); + } + + #[test] + fn generate_info_full_config() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api( + tag = "Users", + title = "User API", + description = "User management endpoints", + api_version = "1.0.0", + license = "MIT", + license_url = "https://opensource.org/licenses/MIT", + contact_name = "Dev Team", + contact_email = "dev@example.com", + contact_url = "https://example.com", + handlers + ))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("User API")); + assert!(output_str.contains("User management endpoints")); + assert!(output_str.contains("1.0.0")); + assert!(output_str.contains("MIT")); + assert!(output_str.contains("Dev Team")); + } + + #[test] + fn generate_info_uses_entity_doc_as_description() { + let input: syn::DeriveInput = syn::parse_quote! { + /// User entity for managing accounts. + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_info_code(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("openapi . info . description")); + assert!(output_str.contains("User entity")); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index 734e5ec..ac3a99b 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -267,7 +267,22 @@ fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { #[cfg(test)] mod tests { + use proc_macro2::Span; + use syn::Ident; + use super::*; + use crate::entity::parse::{CommandKindHint, CommandSource}; + + fn create_test_command(name: &str, requires_id: bool, kind: CommandKindHint) -> CommandDef { + CommandDef { + name: Ident::new(name, Span::call_site()), + source: CommandSource::Create, + requires_id, + result_type: None, + kind, + security: None + } + } #[test] fn crud_collection_path() { @@ -310,4 +325,186 @@ mod tests { let path = build_crud_collection_path(&entity); assert_eq!(path, "/api/v1/users"); } + + #[test] + fn command_path_without_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let path = build_command_path(&entity, &cmd); + assert_eq!(path, "/users/register"); + } + + #[test] + fn command_path_with_id() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("UpdateEmail", true, CommandKindHint::Update); + let path = build_command_path(&entity, &cmd); + assert_eq!(path, "/users/{id}/update-email"); + } + + #[test] + fn command_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", path_prefix = "/api/v2"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let path = build_command_path(&entity, &cmd); + assert_eq!(path, "/api/v2/users/register"); + } + + #[test] + fn command_handler_name_simple() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("Register", false, CommandKindHint::Create); + let name = command_handler_name(&entity, &cmd); + assert_eq!(name.to_string(), "register_user"); + } + + #[test] + fn command_handler_name_multi_word() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(UpdateEmail: email)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let cmd = create_test_command("UpdateEmail", true, CommandKindHint::Update); + let name = command_handler_name(&entity, &cmd); + assert_eq!(name.to_string(), "update_email_user"); + } + + #[test] + fn axum_method_create() { + let cmd = create_test_command("Register", false, CommandKindHint::Create); + assert_eq!(axum_method_for_command(&cmd).to_string(), "post"); + } + + #[test] + fn axum_method_update() { + let cmd = create_test_command("Update", true, CommandKindHint::Update); + assert_eq!(axum_method_for_command(&cmd).to_string(), "put"); + } + + #[test] + fn axum_method_delete() { + let cmd = create_test_command("Delete", true, CommandKindHint::Delete); + assert_eq!(axum_method_for_command(&cmd).to_string(), "delete"); + } + + #[test] + fn axum_method_custom() { + let cmd = create_test_command("Transfer", false, CommandKindHint::Custom); + assert_eq!(axum_method_for_command(&cmd).to_string(), "post"); + } + + #[test] + fn generate_no_handlers_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_crud_router(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_no_commands_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_commands_router(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_crud_router_produces_output() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_crud_router(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("user_router")); + assert!(output_str.contains("UserRepository")); + } + + #[test] + fn generate_commands_router_produces_output() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_commands_router(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("user_commands_router")); + assert!(output_str.contains("UserCommandHandler")); + } + + #[test] + fn generate_crud_routes_with_specific_handlers() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create, get)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let routes = generate_crud_routes(&entity); + let routes_str = routes.to_string(); + assert!(routes_str.contains("create_user")); + assert!(routes_str.contains("get_user")); + assert!(!routes_str.contains("delete_user")); + } } From a24b357c2be088c75bf08075e0977a0e08ed5ffd Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 15:58:41 +0700 Subject: [PATCH 28/30] test: increase coverage to 95%+ with comprehensive tests --- crates/entity-derive-impl/src/entity/api.rs | 90 ++++++++ .../src/entity/api/openapi/schemas.rs | 89 ++++++++ .../src/entity/api/openapi/security.rs | 66 ++++++ .../src/entity/parse/api/parser.rs | 209 ++++++++++++++++++ crates/entity-derive-impl/src/error.rs | 116 ++++++++++ 5 files changed, 570 insertions(+) diff --git a/crates/entity-derive-impl/src/entity/api.rs b/crates/entity-derive-impl/src/entity/api.rs index 3fe4b4f..56348b5 100644 --- a/crates/entity-derive-impl/src/entity/api.rs +++ b/crates/entity-derive-impl/src/entity/api.rs @@ -99,3 +99,93 @@ pub fn generate(entity: &EntityDef) -> TokenStream { #openapi } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entity::parse::EntityDef; + + #[test] + fn generate_no_api_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_api_no_handlers_no_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_with_handlers() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(!output.is_empty()); + let output_str = output.to_string(); + assert!(output_str.contains("user_router")); + assert!(output_str.contains("UserApi")); + } + + #[test] + fn generate_with_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Register)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(!output.is_empty()); + } + + #[test] + fn generate_with_both_handlers_and_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users", handlers))] + #[command(Activate)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(!output.is_empty()); + let output_str = output.to_string(); + assert!(output_str.contains("user_router")); + assert!(output_str.contains("UserApi")); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs index 3b93c5d..5d7416c 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs @@ -289,3 +289,92 @@ pub fn generate_common_schemas_code() -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entity::parse::EntityDef; + + #[test] + fn schema_types_no_handlers() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let types = generate_all_schema_types(&entity); + assert!(types.is_empty()); + } + + #[test] + fn schema_types_with_all_handlers() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let types = generate_all_schema_types(&entity); + let types_str = types.to_string(); + assert!(types_str.contains("UserResponse")); + assert!(types_str.contains("CreateUserRequest")); + assert!(types_str.contains("UpdateUserRequest")); + } + + #[test] + fn schema_types_create_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let types = generate_all_schema_types(&entity); + let types_str = types.to_string(); + assert!(types_str.contains("UserResponse")); + assert!(types_str.contains("CreateUserRequest")); + assert!(!types_str.contains("UpdateUserRequest")); + } + + #[test] + fn schema_types_with_commands() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", commands, api(tag = "Users"))] + #[command(Ban)] + #[command(Activate)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let types = generate_all_schema_types(&entity); + let types_str = types.to_string(); + assert!(types_str.contains("BanUser")); + assert!(types_str.contains("ActivateUser")); + } + + #[test] + fn common_schemas_code_generated() { + let code = generate_common_schemas_code(); + let code_str = code.to_string(); + assert!(code_str.contains("ErrorResponse")); + assert!(code_str.contains("PaginationQuery")); + assert!(code_str.contains("RFC 7807")); + assert!(code_str.contains("limit")); + assert!(code_str.contains("offset")); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi/security.rs b/crates/entity-derive-impl/src/entity/api/openapi/security.rs index 15fed45..3d38b8f 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi/security.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi/security.rs @@ -266,3 +266,69 @@ pub fn security_scheme_name(security: &str) -> &'static str { _ => "cookieAuth" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn security_code_none() { + let code = generate_security_code(None); + assert!(code.is_empty()); + } + + #[test] + fn security_code_cookie() { + let code = generate_security_code(Some("cookie")); + let code_str = code.to_string(); + assert!(code_str.contains("cookieAuth")); + assert!(code_str.contains("Cookie")); + assert!(code_str.contains("token")); + } + + #[test] + fn security_code_bearer() { + let code = generate_security_code(Some("bearer")); + let code_str = code.to_string(); + assert!(code_str.contains("bearerAuth")); + assert!(code_str.contains("Bearer")); + assert!(code_str.contains("JWT")); + } + + #[test] + fn security_code_api_key() { + let code = generate_security_code(Some("api_key")); + let code_str = code.to_string(); + assert!(code_str.contains("apiKey")); + assert!(code_str.contains("Header")); + assert!(code_str.contains("X-API-Key")); + } + + #[test] + fn security_code_unknown_returns_empty() { + let code = generate_security_code(Some("unknown")); + assert!(code.is_empty()); + } + + #[test] + fn scheme_name_cookie() { + assert_eq!(security_scheme_name("cookie"), "cookieAuth"); + } + + #[test] + fn scheme_name_bearer() { + assert_eq!(security_scheme_name("bearer"), "bearerAuth"); + } + + #[test] + fn scheme_name_api_key() { + assert_eq!(security_scheme_name("api_key"), "apiKey"); + } + + #[test] + fn scheme_name_unknown_defaults_to_cookie() { + assert_eq!(security_scheme_name("unknown"), "cookieAuth"); + assert_eq!(security_scheme_name(""), "cookieAuth"); + assert_eq!(security_scheme_name("jwt"), "cookieAuth"); + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/api/parser.rs b/crates/entity-derive-impl/src/entity/parse/api/parser.rs index 869b0a4..6b9dd41 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/parser.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/parser.rs @@ -297,3 +297,212 @@ pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { Ok(config) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_path_only_fails() { + let attr: syn::Attribute = syn::parse_quote!(#[api]); + let result = parse_api_config(&attr.meta); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("requires parameters")); + } + + #[test] + fn parse_name_value_fails() { + let attr: syn::Attribute = syn::parse_quote!(#[api = "value"]); + let result = parse_api_config(&attr.meta); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("parentheses")); + } + + #[test] + fn parse_tag() { + let attr: syn::Attribute = syn::parse_quote!(#[api(tag = "Users")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.tag, Some("Users".to_string())); + } + + #[test] + fn parse_tag_description() { + let attr: syn::Attribute = syn::parse_quote!(#[api(tag_description = "User management")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.tag_description, Some("User management".to_string())); + } + + #[test] + fn parse_path_prefix() { + let attr: syn::Attribute = syn::parse_quote!(#[api(path_prefix = "/api/v1")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + } + + #[test] + fn parse_security() { + let attr: syn::Attribute = syn::parse_quote!(#[api(security = "bearer")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.security, Some("bearer".to_string())); + } + + #[test] + fn parse_version() { + let attr: syn::Attribute = syn::parse_quote!(#[api(version = "v2")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.version, Some("v2".to_string())); + } + + #[test] + fn parse_deprecated_in() { + let attr: syn::Attribute = syn::parse_quote!(#[api(deprecated_in = "2.0")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.deprecated_in, Some("2.0".to_string())); + } + + #[test] + fn parse_handlers_flag() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers)]); + let config = parse_api_config(&attr.meta).unwrap(); + assert!(config.handlers.any()); + assert!(config.handlers.create); + assert!(config.handlers.get); + assert!(config.handlers.update); + assert!(config.handlers.delete); + assert!(config.handlers.list); + } + + #[test] + fn parse_handlers_true() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers = true)]); + let config = parse_api_config(&attr.meta).unwrap(); + assert!(config.handlers.any()); + } + + #[test] + fn parse_handlers_false() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers = false)]); + let config = parse_api_config(&attr.meta).unwrap(); + assert!(!config.handlers.any()); + } + + #[test] + fn parse_handlers_selective() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers(create, get))]); + let config = parse_api_config(&attr.meta).unwrap(); + assert!(config.handlers.create); + assert!(config.handlers.get); + assert!(!config.handlers.update); + assert!(!config.handlers.delete); + assert!(!config.handlers.list); + } + + #[test] + fn parse_handlers_all_selective() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers(create, get, update, delete, list))]); + let config = parse_api_config(&attr.meta).unwrap(); + assert!(config.handlers.create); + assert!(config.handlers.get); + assert!(config.handlers.update); + assert!(config.handlers.delete); + assert!(config.handlers.list); + } + + #[test] + fn parse_handlers_invalid() { + let attr: syn::Attribute = syn::parse_quote!(#[api(handlers(invalid))]); + let result = parse_api_config(&attr.meta); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown handler")); + } + + #[test] + fn parse_title() { + let attr: syn::Attribute = syn::parse_quote!(#[api(title = "My API")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.title, Some("My API".to_string())); + } + + #[test] + fn parse_description() { + let attr: syn::Attribute = syn::parse_quote!(#[api(description = "API description")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.description, Some("API description".to_string())); + } + + #[test] + fn parse_api_version() { + let attr: syn::Attribute = syn::parse_quote!(#[api(api_version = "1.0.0")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.api_version, Some("1.0.0".to_string())); + } + + #[test] + fn parse_license() { + let attr: syn::Attribute = syn::parse_quote!(#[api(license = "MIT")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.license, Some("MIT".to_string())); + } + + #[test] + fn parse_license_url() { + let attr: syn::Attribute = syn::parse_quote!(#[api(license_url = "https://mit.edu/license")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.license_url, Some("https://mit.edu/license".to_string())); + } + + #[test] + fn parse_contact_name() { + let attr: syn::Attribute = syn::parse_quote!(#[api(contact_name = "John Doe")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.contact_name, Some("John Doe".to_string())); + } + + #[test] + fn parse_contact_email() { + let attr: syn::Attribute = syn::parse_quote!(#[api(contact_email = "john@example.com")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.contact_email, Some("john@example.com".to_string())); + } + + #[test] + fn parse_contact_url() { + let attr: syn::Attribute = syn::parse_quote!(#[api(contact_url = "https://example.com")]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.contact_url, Some("https://example.com".to_string())); + } + + #[test] + fn parse_unknown_option() { + let attr: syn::Attribute = syn::parse_quote!(#[api(unknown_option = "value")]); + let result = parse_api_config(&attr.meta); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown api option")); + } + + #[test] + fn parse_multiple_options() { + let attr: syn::Attribute = syn::parse_quote!(#[api( + tag = "Users", + path_prefix = "/api/v1", + security = "bearer", + handlers(create, get) + )]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.tag, Some("Users".to_string())); + assert_eq!(config.path_prefix, Some("/api/v1".to_string())); + assert_eq!(config.security, Some("bearer".to_string())); + assert!(config.handlers.create); + assert!(config.handlers.get); + assert!(!config.handlers.update); + } + + #[test] + fn parse_public_commands() { + let attr: syn::Attribute = syn::parse_quote!(#[api(public = [Login, Register])]); + let config = parse_api_config(&attr.meta).unwrap(); + assert_eq!(config.public_commands.len(), 2); + assert!(config.public_commands.iter().any(|i| i == "Login")); + assert!(config.public_commands.iter().any(|i| i == "Register")); + } +} diff --git a/crates/entity-derive-impl/src/error.rs b/crates/entity-derive-impl/src/error.rs index fcf8bbb..f187903 100644 --- a/crates/entity-derive-impl/src/error.rs +++ b/crates/entity-derive-impl/src/error.rs @@ -213,4 +213,120 @@ mod tests { let result = generate(&input); assert!(result.is_err()); } + + #[test] + fn generate_empty_variants_returns_empty() { + let input: DeriveInput = syn::parse_quote! { + enum EmptyError { + NoStatus, + } + }; + + let result = generate(&input); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn generate_multiple_variants() { + let input: DeriveInput = syn::parse_quote! { + enum UserError { + /// User not found + #[status(404)] + NotFound, + /// Already exists + #[status(409)] + AlreadyExists, + /// Internal error + #[status(500)] + Internal, + } + }; + + let result = generate(&input); + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("UserErrorResponses")); + assert!(output.contains("status_codes")); + assert!(output.contains("descriptions")); + assert!(output.contains("utoipa_responses")); + assert!(output.contains("404")); + assert!(output.contains("409")); + assert!(output.contains("500")); + } + + #[test] + fn parse_variant_without_doc_uses_default() { + let input: DeriveInput = syn::parse_quote! { + enum Error { + #[status(400)] + BadRequest, + } + }; + + if let syn::Data::Enum(data) = &input.data { + let variant = &data.variants[0]; + let parsed = parse_error_variant(variant).unwrap(); + assert_eq!(parsed.status, 400); + assert!(parsed.description.contains("BadRequest")); + } + } + + #[test] + fn generate_public_visibility() { + let input: DeriveInput = syn::parse_quote! { + pub enum ApiError { + /// Not found + #[status(404)] + NotFound, + } + }; + + let result = generate(&input); + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("pub struct ApiErrorResponses")); + } + + #[test] + fn generate_private_visibility() { + let input: DeriveInput = syn::parse_quote! { + enum PrivateError { + /// Error + #[status(500)] + Internal, + } + }; + + let result = generate(&input); + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("struct PrivateErrorResponses")); + assert!(!output.contains("pub struct PrivateErrorResponses")); + } + + #[test] + fn status_code_parsing_various_codes() { + let codes = [200_u16, 201, 400, 401, 403, 404, 409, 422, 500, 502, 503]; + for code in codes { + let code_str = code.to_string(); + let input: DeriveInput = syn::parse_quote! { + enum Error { + /// Test + #[status(#code)] + Test, + } + }; + + if let syn::Data::Enum(data) = &input.data { + let variant = &data.variants[0]; + let result = parse_status_attr(&variant.attrs); + assert!( + result.is_ok(), + "Should parse status code {}", + code_str + ); + } + } + } } From 4d4e6dc728945d3f0a98f286679ac11c06ee0169 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 16:05:22 +0700 Subject: [PATCH 29/30] test: add comprehensive tests for streams, field, and query modules --- .../src/entity/parse/field.rs | 143 ++++++++++++++++++ .../src/entity/sql/postgres/query.rs | 123 +++++++++++++++ .../entity-derive-impl/src/entity/streams.rs | 51 +++++++ .../src/entity/streams/subscriber.rs | 84 ++++++++++ 4 files changed, 401 insertions(+) diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 4902165..916080d 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -282,3 +282,146 @@ impl FieldDef { self.example.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + fn parse_field(tokens: proc_macro2::TokenStream) -> FieldDef { + let field: Field = parse_quote!(#tokens); + FieldDef::from_field(&field).unwrap() + } + + #[test] + fn field_basic_parsing() { + let field = parse_field(quote::quote! { pub name: String }); + assert_eq!(field.name_str(), "name"); + assert!(!field.is_id()); + assert!(!field.is_auto()); + } + + #[test] + fn field_id_attribute() { + let field = parse_field(quote::quote! { + #[id] + pub id: uuid::Uuid + }); + assert!(field.is_id()); + assert!(field.in_response()); + } + + #[test] + fn field_auto_attribute() { + let field = parse_field(quote::quote! { + #[auto] + pub created_at: chrono::DateTime + }); + assert!(field.is_auto()); + } + + #[test] + fn field_expose_config() { + let field = parse_field(quote::quote! { + #[field(create, update, response)] + pub name: String + }); + assert!(field.in_create()); + assert!(field.in_update()); + assert!(field.in_response()); + } + + #[test] + fn field_expose_skip() { + let field = parse_field(quote::quote! { + #[field(skip)] + pub password: String + }); + assert!(!field.in_create()); + assert!(!field.in_update()); + assert!(!field.in_response()); + } + + #[test] + fn field_belongs_to() { + let field = parse_field(quote::quote! { + #[belongs_to(User)] + pub user_id: uuid::Uuid + }); + assert!(field.is_relation()); + assert!(field.belongs_to().is_some()); + assert_eq!(field.belongs_to().unwrap().to_string(), "User"); + } + + #[test] + fn field_filter_attribute() { + let field = parse_field(quote::quote! { + #[filter] + pub status: String + }); + assert!(field.has_filter()); + } + + #[test] + fn field_is_option() { + let field = parse_field(quote::quote! { pub avatar: Option }); + assert!(field.is_option()); + + let field2 = parse_field(quote::quote! { pub name: String }); + assert!(!field2.is_option()); + } + + #[test] + fn field_ty_accessor() { + let field = parse_field(quote::quote! { pub count: i32 }); + let ty = field.ty(); + let ty_str = quote::quote!(#ty).to_string(); + assert!(ty_str.contains("i32")); + } + + #[test] + fn field_doc_comment() { + let field = parse_field(quote::quote! { + /// User's display name + pub name: String + }); + assert!(field.doc().is_some()); + assert!(field.doc().unwrap().contains("display name")); + } + + #[test] + fn field_no_doc_comment() { + let field = parse_field(quote::quote! { pub name: String }); + assert!(field.doc().is_none()); + } + + #[test] + fn field_validation_accessor() { + let field = parse_field(quote::quote! { pub name: String }); + let _validation = field.validation(); + assert!(!field.has_validation()); + } + + #[test] + fn field_example_accessor() { + let field = parse_field(quote::quote! { pub name: String }); + assert!(field.example().is_none()); + assert!(!field.has_example()); + } + + #[test] + fn field_filter_accessor() { + let field = parse_field(quote::quote! { + #[filter(like)] + pub name: String + }); + let filter = field.filter(); + assert!(filter.has_filter()); + } + + #[test] + fn field_name_accessor() { + let field = parse_field(quote::quote! { pub email: String }); + assert_eq!(field.name().to_string(), "email"); + } +} diff --git a/crates/entity-derive-impl/src/entity/sql/postgres/query.rs b/crates/entity-derive-impl/src/entity/sql/postgres/query.rs index 209b985..4a2c53f 100644 --- a/crates/entity-derive-impl/src/entity/sql/postgres/query.rs +++ b/crates/entity-derive-impl/src/entity/sql/postgres/query.rs @@ -193,3 +193,126 @@ impl Context<'_> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entity::parse::EntityDef; + + #[test] + fn query_method_no_filters_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.query_method(); + assert!(method.is_empty()); + } + + #[test] + fn query_method_with_filter() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[filter] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.query_method(); + let method_str = method.to_string(); + assert!(method_str.contains("async fn query")); + assert!(method_str.contains("UserQuery")); + assert!(method_str.contains("conditions")); + assert!(method_str.contains("where_clause")); + } + + #[test] + fn query_method_with_soft_delete() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", soft_delete)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[filter] + pub name: String, + #[field(response)] + #[auto] + pub deleted_at: Option>, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.query_method(); + let method_str = method.to_string(); + assert!(method_str.contains("deleted_at")); + } + + #[test] + fn stream_filtered_no_streams_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[filter] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.stream_filtered_method(); + assert!(method.is_empty()); + } + + #[test] + fn stream_filtered_no_filters_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.stream_filtered_method(); + assert!(method.is_empty()); + } + + #[test] + fn stream_filtered_with_streams_and_filters() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + #[filter] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let ctx = Context::new(&entity); + let method = ctx.stream_filtered_method(); + let method_str = method.to_string(); + assert!(method_str.contains("stream_filtered")); + assert!(method_str.contains("UserFilter")); + assert!(method_str.contains("futures")); + } +} diff --git a/crates/entity-derive-impl/src/entity/streams.rs b/crates/entity-derive-impl/src/entity/streams.rs index f9dea44..7e71ff7 100644 --- a/crates/entity-derive-impl/src/entity/streams.rs +++ b/crates/entity-derive-impl/src/entity/streams.rs @@ -48,3 +48,54 @@ fn generate_channel_const(entity: &EntityDef) -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_no_streams_returns_empty() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + assert!(output.is_empty()); + } + + #[test] + fn generate_with_streams() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("CHANNEL")); + assert!(output_str.contains("entity_users")); + assert!(output_str.contains("UserSubscriber")); + } + + #[test] + fn channel_const_format() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "blog_posts", streams)] + pub struct BlogPost { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate_channel_const(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("entity_blog_posts")); + } +} diff --git a/crates/entity-derive-impl/src/entity/streams/subscriber.rs b/crates/entity-derive-impl/src/entity/streams/subscriber.rs index d7b961b..a57f423 100644 --- a/crates/entity-derive-impl/src/entity/streams/subscriber.rs +++ b/crates/entity-derive-impl/src/entity/streams/subscriber.rs @@ -77,3 +77,87 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subscriber_struct_generated() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("UserSubscriber")); + assert!(output_str.contains("PgListener")); + } + + #[test] + fn subscriber_has_new_method() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("async fn new")); + assert!(output_str.contains("PgPool")); + } + + #[test] + fn subscriber_has_recv_method() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("async fn recv")); + assert!(output_str.contains("UserEvent")); + } + + #[test] + fn subscriber_has_try_recv_method() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("async fn try_recv")); + assert!(output_str.contains("Option")); + } + + #[test] + fn subscriber_respects_visibility() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", streams)] + pub(crate) struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let output = generate(&entity); + let output_str = output.to_string(); + assert!(output_str.contains("pub (crate) struct UserSubscriber")); + } +} From e248d5e53d9026ae0b5412bb03d03e2b1ab17657 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 16:09:46 +0700 Subject: [PATCH 30/30] style: fix formatting --- .../src/entity/parse/api/parser.rs | 25 +++++++++++++++---- .../src/entity/parse/field.rs | 3 ++- crates/entity-derive-impl/src/error.rs | 6 +---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/parse/api/parser.rs b/crates/entity-derive-impl/src/entity/parse/api/parser.rs index 6b9dd41..ddd8796 100644 --- a/crates/entity-derive-impl/src/entity/parse/api/parser.rs +++ b/crates/entity-derive-impl/src/entity/parse/api/parser.rs @@ -307,7 +307,12 @@ mod tests { let attr: syn::Attribute = syn::parse_quote!(#[api]); let result = parse_api_config(&attr.meta); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("requires parameters")); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires parameters") + ); } #[test] @@ -399,7 +404,8 @@ mod tests { #[test] fn parse_handlers_all_selective() { - let attr: syn::Attribute = syn::parse_quote!(#[api(handlers(create, get, update, delete, list))]); + let attr: syn::Attribute = + syn::parse_quote!(#[api(handlers(create, get, update, delete, list))]); let config = parse_api_config(&attr.meta).unwrap(); assert!(config.handlers.create); assert!(config.handlers.get); @@ -446,9 +452,13 @@ mod tests { #[test] fn parse_license_url() { - let attr: syn::Attribute = syn::parse_quote!(#[api(license_url = "https://mit.edu/license")]); + let attr: syn::Attribute = + syn::parse_quote!(#[api(license_url = "https://mit.edu/license")]); let config = parse_api_config(&attr.meta).unwrap(); - assert_eq!(config.license_url, Some("https://mit.edu/license".to_string())); + assert_eq!( + config.license_url, + Some("https://mit.edu/license".to_string()) + ); } #[test] @@ -477,7 +487,12 @@ mod tests { let attr: syn::Attribute = syn::parse_quote!(#[api(unknown_option = "value")]); let result = parse_api_config(&attr.meta); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("unknown api option")); + assert!( + result + .unwrap_err() + .to_string() + .contains("unknown api option") + ); } #[test] diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 916080d..73de35d 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -285,9 +285,10 @@ impl FieldDef { #[cfg(test)] mod tests { - use super::*; use syn::parse_quote; + use super::*; + fn parse_field(tokens: proc_macro2::TokenStream) -> FieldDef { let field: Field = parse_quote!(#tokens); FieldDef::from_field(&field).unwrap() diff --git a/crates/entity-derive-impl/src/error.rs b/crates/entity-derive-impl/src/error.rs index f187903..c190f09 100644 --- a/crates/entity-derive-impl/src/error.rs +++ b/crates/entity-derive-impl/src/error.rs @@ -321,11 +321,7 @@ mod tests { if let syn::Data::Enum(data) = &input.data { let variant = &data.variants[0]; let result = parse_status_attr(&variant.attrs); - assert!( - result.is_ok(), - "Should parse status code {}", - code_str - ); + assert!(result.is_ok(), "Should parse status code {}", code_str); } } }