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/.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..e209c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,21 @@ [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" +version = "0.4.0" edition = "2024" rust-version = "1.92" authors = ["RAprogramm "] @@ -14,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/README.md b/README.md index 94bd40c..cd83d0e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ REUSE Compliant + + Wiki +

--- @@ -74,7 +77,7 @@ pub struct User { ```toml [dependencies] -entity-derive = { version = "0.3", features = ["postgres"] } +entity-derive = { version = "0.4", features = ["postgres", "api"] } ``` --- @@ -85,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. | @@ -131,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", + ), )] ``` 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-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..9e40738 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,38 @@ //! } //! ``` -use std::{error::Error as StdError, fmt, future::Future, marker::PhantomData}; +#[cfg(feature = "postgres")] +use std::future::Future; +use std::{error::Error as StdError, fmt}; -/// 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 +82,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 +/// +/// Each entity with `#[entity(transactions)]` generates an extension trait +/// that adds an accessor method: /// -/// - `'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 ()> +/// ```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,133 +221,85 @@ 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 { + use std::error::Error; + use super::*; #[test] @@ -351,25 +313,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 +336,106 @@ 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!(commit.is_commit()); + assert!(!commit.is_rollback()); + assert!(!commit.is_operation()); - assert!(rollback.is_rollback()); assert!(!rollback.is_begin()); + assert!(!rollback.is_commit()); + assert!(rollback.is_rollback()); + assert!(!rollback.is_operation()); - assert!(op.is_operation()); - assert!(!op.is_begin()); + assert!(!operation.is_begin()); + assert!(!operation.is_commit()); + assert!(!operation.is_rollback()); + assert!(operation.is_operation()); } #[test] fn transaction_error_into_inner() { - let err: TransactionError<&str> = TransactionError::Operation("inner"); - assert_eq!(err.into_inner(), "inner"); + let err: TransactionError<&str> = TransactionError::Operation("test"); + assert_eq!(err.into_inner(), "test"); } #[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"); + 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_source() { - let inner = std::io::Error::other("source_err"); - let err: TransactionError = TransactionError::Begin(inner); - assert!(err.source().is_some()); + fn transaction_error_into_inner_commit() { + let err: TransactionError<&str> = TransactionError::Commit("commit_err"); + assert_eq!(err.into_inner(), "commit_err"); + } - let commit_err: TransactionError = - TransactionError::Commit(std::io::Error::other("c")); - assert!(commit_err.source().is_some()); + #[test] + fn transaction_error_into_inner_rollback() { + let err: TransactionError<&str> = TransactionError::Rollback("rollback_err"); + assert_eq!(err.into_inner(), "rollback_err"); + } - let rollback_err: TransactionError = - TransactionError::Rollback(std::io::Error::other("r")); - assert!(rollback_err.source().is_some()); + #[test] + fn transaction_error_source_begin() { + let err: TransactionError = + TransactionError::Begin(std::io::Error::other("src")); + assert!(err.source().is_some()); + } - let op_err: TransactionError = - TransactionError::Operation(std::io::Error::other("o")); - assert!(op_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_builder_new() { - struct MockPool; - let pool = MockPool; - let tx: Transaction<'_, MockPool, ()> = Transaction::new(&pool); - let _ = tx.pool(); + fn transaction_error_source_rollback() { + let err: TransactionError = + TransactionError::Rollback(std::io::Error::other("src")); + assert!(err.source().is_some()); } #[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(); + fn transaction_error_source_operation() { + let err: TransactionError = + TransactionError::Operation(std::io::Error::other("src")); + assert!(err.source().is_some()); } #[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)); + fn transaction_builder_new() { + struct MockPool; + let pool = MockPool; + let tx = Transaction::new(&pool); + let _ = tx.pool(); } #[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"); + 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_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]); + 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/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-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..56348b5 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api.rs @@ -0,0 +1,191 @@ +// 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) +//! ├── 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 +//! ``` +//! +//! # 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 crud; +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. +/// 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(); + } + + 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 crud_handlers = crud::generate(entity); + let command_handlers = handlers::generate(entity); + let router = router::generate(entity); + let openapi = openapi::generate(entity); + + quote! { + #crud_handlers + #command_handlers + #router + #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/crud.rs b/crates/entity-derive-impl/src/entity/api/crud.rs new file mode 100644 index 0000000..161229c --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud.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/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..a9269d0 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/delete.rs @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// 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(); + 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..a494bce --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/get.rs @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// 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(); + 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..6637ae2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/helpers.rs @@ -0,0 +1,458 @@ +// 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() + } +} + +#[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/crud/list.rs b/crates/entity-derive-impl/src/entity/api/crud/list.rs new file mode 100644 index 0000000..29232cc --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/list.rs @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; +use quote::{format_ident, quote}; + +use super::helpers::{build_collection_path, build_deprecated_attr, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// 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(); + 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 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] + #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/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..e46293a --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud/update.rs @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; +use quote::{format_ident, quote}; + +use super::helpers::{build_deprecated_attr, build_item_path, build_security_attr}; +use crate::entity::parse::EntityDef; + +/// 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(); + 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 new file mode 100644 index 0000000..7a37ea9 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/handlers.rs @@ -0,0 +1,680 @@ +// 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(); + + let handler_name = handler_function_name(entity, cmd); + let handler_method = cmd.handler_method_name(); + let command_struct = cmd.struct_name(&entity_name_str); + let handler_trait = format_ident!("{}CommandHandler", entity_name); + let path = build_path(entity, cmd); + let http_method = http_method_for_command(cmd); + let http_method_ident = format_ident!("{}", http_method); + let tag = api_config.tag_or_default(&entity_name_str); + + let security_attr = if cmd.is_public() { + quote! {} + } else if let Some(cmd_security) = cmd.security() { + let security_name = security_scheme_name(cmd_security); + quote! { security(#security_name = []) } + } else if api_config.is_public_command(&cmd.name.to_string()) { + quote! {} + } else if let Some(security) = &api_config.security { + let security_name = security_scheme_name(security); + quote! { security(#security_name = []) } + } else { + quote! {} + }; + + let (response_type, response_body) = response_type_for_command(entity, cmd); + + let deprecated_attr = if api_config.is_deprecated() { + quote! { , deprecated = true } + } else { + quote! {} + }; + + 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") + ) + #deprecated_attr + )] + } + } 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 + #deprecated_attr + )] + } + }; + + 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" + } +} + +/// 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(); + + 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, CommandKindHint, CommandSource, EntityDef}; + + 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 + } + } + + 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); + 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"); + } + + #[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"); + } + + #[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.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs new file mode 100644 index 0000000..14e4cbc --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI struct generation for utoipa 5.x. +//! +//! 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 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) { +//! 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)), +//! modifiers(&UserApiModifier), +//! tags((name = "Users", description = "User management")) +//! )] +//! 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; +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; + +/// 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(); + + 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; + } +} + +/// 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::*; +/// +/// // 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(); + + 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/info.rs b/crates/entity-derive-impl/src/entity/api/openapi/info.rs new file mode 100644 index 0000000..83d543f --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/info.rs @@ -0,0 +1,521 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI Info section generation. +//! +//! 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; + +/// 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() +/// ``` +/// +/// This ensures type safety and proper optional field handling. +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 + } +} + +#[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/openapi/paths.rs b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs new file mode 100644 index 0000000..e19e322 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/paths.rs @@ -0,0 +1,637 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI path operations generation. +//! +//! 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; +use quote::{format_ident, quote}; + +use super::security::security_scheme_name; +use crate::entity::parse::{CommandDef, EntityDef}; + +/// 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. +/// +/// # 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(); + 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 + } +} + +/// 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(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// 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) +} + +/// 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); + 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..5d7416c --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/schemas.rs @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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. +//! +//! # 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; + +/// 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 +/// +/// 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(); + + 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),* } +} + +/// 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() { + 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()); + } + } +} + +#[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 new file mode 100644 index 0000000..3d38b8f --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/security.rs @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! OpenAPI security scheme generation. +//! +//! 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; + +/// 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(); + }; + + 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); + } + } +} + +/// 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", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "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/api/openapi/tests.rs b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs new file mode 100644 index 0000000..70373b7 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/openapi/tests.rs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// 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::*; + +#[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 new file mode 100644 index 0000000..ac3a99b --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Router factory generation. +//! +//! Generates functions that create axum Routers for entity endpoints. +//! +//! # Generated Routers +//! +//! | 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 +//! // 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("/users/register", post(register_user::)) +//! } +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::{CommandDef, CommandKindHint, EntityDef}; + +/// Generate all router factory functions. +pub fn generate(entity: &EntityDef) -> TokenStream { + 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(); + } + + 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!("{}_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); + + 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::) }); + } + + 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::) }); + } + + 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("//", "/") +} + +/// 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); + + let routes = generate_command_routes(entity, commands); + + let doc = format!( + "Create axum router for {} command 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 command route definitions. +fn generate_command_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { + let routes: Vec = commands + .iter() + .map(|cmd| generate_command_route(entity, cmd)) + .collect(); + + quote! { #(#routes)* } +} + +/// 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! { + .route(#path, axum::routing::#method(#handler_name::)) + } +} + +/// 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) + } else { + format!("{}/{}s/{}", prefix, entity_path, cmd_path) + }; + + path.replace("//", "/") +} + +/// 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 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") + } +} + +#[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() { + 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"); + } + + #[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")); + } +} diff --git a/crates/entity-derive-impl/src/entity/parse.rs b/crates/entity-derive-impl/src/entity/parse.rs index f70d2d1..5489b00 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,9 +115,14 @@ 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}; +#[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/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs new file mode 100644 index 0000000..cb4a1a2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api.rs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +#![allow(dead_code)] + +//! API configuration parsing for OpenAPI/utoipa integration. +//! +//! 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. +//! +//! # 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( +//! 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, +//! } +//! ``` +//! +//! # Module Structure +//! +//! | Module | Purpose | +//! |--------|---------| +//! | [`config`] | Type definitions for `ApiConfig` and `HandlerConfig` | +//! | [`parser`] | Attribute parsing logic for `api(...)` | + +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/config.rs b/crates/entity-derive-impl/src/entity/parse/api/config.rs new file mode 100644 index 0000000..b4ea818 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/config.rs @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; + +/// Configuration for selective CRUD handler generation. +/// +/// Controls which of the five standard CRUD handlers are generated: +/// create, get, update, delete, and list. +/// +/// # 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). + 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 + } +} + +/// 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 +/// +/// 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. + /// + /// 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/parser.rs b/crates/entity-derive-impl/src/entity/parse/api/parser.rs new file mode 100644 index 0000000..ddd8796 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/parser.rs @@ -0,0 +1,523 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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}; + +/// Parses the `#[entity(api(...))]` attribute into an [`ApiConfig`]. +/// +/// 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 `syn::Meta` representing the `api(...)` attribute +/// +/// # Returns +/// +/// - `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(); + + 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) +} + +#[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/entity/parse/api/tests.rs b/crates/entity-derive-impl/src/entity/parse/api/tests.rs new file mode 100644 index 0000000..90bf54a --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/api/tests.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// 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::*; + +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 index 17216f6..8f2cc1a 100644 --- a/crates/entity-derive-impl/src/entity/parse/command.rs +++ b/crates/entity-derive-impl/src/entity/parse/command.rs @@ -1,558 +1,147 @@ // 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 - -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 -} - -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() - } - } - - /// 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()) - } -} - -/// 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(); +//! 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` | - 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\"" - )); - } - } - } - _ => { - return Err(syn::Error::new( - option_name.span(), - format!( - "unknown command option '{}', expected: requires_id, source, \ - payload, result, kind", - option_str - ) - )); - } - } - } +mod parser; +mod types; - Ok(cmd) - }) -} +pub use parser::parse_command_attrs; +pub use types::{CommandDef, CommandKindHint, CommandSource}; #[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()); - } -} +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..bb3aa85 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/parser.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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}; + +/// Parses all `#[command(...)]` attributes from a struct. +/// +/// 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::Attribute` from the struct definition +/// +/// # Returns +/// +/// 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 +/// // 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 + .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..afb1fc1 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/tests.rs @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; + +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..f7efc73 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/command/types.rs @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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}; + +/// Determines the source of fields for a 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)]`. + /// + /// 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 9b857a6..5d100d0 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -1,613 +1,152 @@ // 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 — Main EntityDef definition and parsing -//! ├── attrs.rs — EntityAttrs (darling parsing struct) -//! └── projection.rs — Projection definition and parsing +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ 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; 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::{ - command::{CommandDef, parse_command_attrs}, - dialect::DatabaseDialect, - field::FieldDef, - returning::ReturningMode, - sql_level::SqlLevel, - uuid_version::UuidVersion -}; - -/// 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() -} - -/// 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 -} - -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 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 - }) - } - - /// 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 - } -} #[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")); - } -} +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..fc8ab3a --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/accessors.rs @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// 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; + +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/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, 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..37d5ddc --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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; + +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..f1c9ddb --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// 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}; + +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..c20382b --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/helpers.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! 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}; + +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/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 new file mode 100644 index 0000000..bd079f9 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/tests.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// 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; + +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")); +} diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index b57f60a..73de35d 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -26,14 +26,20 @@ //! 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; use syn::{Attribute, Field, Ident, Type}; +pub use validation::ValidationConfig; + +use crate::utils::docs::extract_doc_comments; /// Parse `#[belongs_to(EntityName)]` attribute. /// @@ -75,7 +81,25 @@ 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, + + /// 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, + + /// 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 { @@ -92,6 +116,9 @@ 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 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(); @@ -116,7 +143,10 @@ impl FieldDef { ty, expose, storage, - filter + filter, + doc, + validation, + example }) } @@ -210,4 +240,189 @@ 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() + } + + /// 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() + } + + /// 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() + } +} + +#[cfg(test)] +mod tests { + 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() + } + + #[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/parse/field/example.rs b/crates/entity-derive-impl/src/entity/parse/field/example.rs new file mode 100644 index 0000000..8f8afd5 --- /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 = 99.99] + price: f64, + } + "# + ); + let example = parse_example_attr(&attrs); + assert!(matches!(example, Some(ExampleValue::Float(f)) if (f - 99.99).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")); + } +} 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")); + } +} 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..4a2c53f 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,198 @@ 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)) + } + } + } +} + +#[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")); + } +} 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/crates/entity-derive-impl/src/error.rs b/crates/entity-derive-impl/src/error.rs new file mode 100644 index 0000000..c190f09 --- /dev/null +++ b/crates/entity-derive-impl/src/error.rs @@ -0,0 +1,328 @@ +// 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()); + } + + #[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); + } + } + } +} 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) +} 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())); + } +} 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/axum-crud/src/main.rs b/examples/axum-crud/src/main.rs deleted file mode 100644 index 3aa0fc6..0000000 --- a/examples/axum-crud/src/main.rs +++ /dev/null @@ -1,247 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 RAprogramm -// SPDX-License-Identifier: MIT - -//! Axum CRUD Example with entity-derive -//! -//! Demonstrates full CRUD operations using: -//! - entity-derive for code generation -//! - Axum for HTTP routing -//! - sqlx for PostgreSQL access -//! - utoipa for OpenAPI docs - -use std::sync::Arc; - -use axum::{ - Json, Router, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{delete, get, patch, post}, -}; -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 -// ============================================================================ - -/// User entity with full CRUD support. -#[derive(Debug, Clone, Entity)] -#[entity(table = "users", schema = "public")] -pub struct User { - /// Unique identifier (UUID v7). - #[id] - pub id: Uuid, - - /// User's display name. - #[field(create, update, response)] - pub name: String, - - /// User's email address. - #[field(create, update, response)] - pub email: String, - - /// Hashed password (never exposed in API). - #[field(create, skip)] - pub password_hash: String, - - /// Account creation timestamp. - #[field(response)] - #[auto] - pub created_at: DateTime, - - /// Last update timestamp. - #[field(response)] - #[auto] - 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. -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. -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. -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. -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. -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 { - 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) -} - -// ============================================================================ -// Main -// ============================================================================ - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt() - .with_env_filter("axum_crud_example=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 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::new(pool); - let app = app(state); - - 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"); - - axum::serve(listener, app).await.unwrap(); -} diff --git a/examples/axum-crud/Cargo.toml b/examples/basic/Cargo.toml similarity index 71% rename from examples/axum-crud/Cargo.toml rename to examples/basic/Cargo.toml index eaa095c..5fb2d38 100644 --- a/examples/axum-crud/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -2,13 +2,21 @@ # 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] [dependencies] -entity-derive = { path = "../..", features = ["postgres", "api"] } +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/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/basic/src/main.rs b/examples/basic/src/main.rs new file mode 100644 index 0000000..f71c5db --- /dev/null +++ b/examples/basic/src/main.rs @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Basic CRUD Example with Generated Handlers +//! +//! Demonstrates full CRUD operations using: +//! - 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::Router; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; +use uuid::Uuid; + +// ============================================================================ +// Entity Definition with Generated API +// ============================================================================ + +/// 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", + 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] + pub id: Uuid, + + /// User's display name. + #[field(create, update, response)] + pub name: String, + + /// User's email address. + #[field(create, update, response)] + pub email: String, + + /// Hashed password (never exposed in API). + #[field(create, skip)] + pub password_hash: String, + + /// Account creation timestamp. + #[field(response)] + #[auto] + pub created_at: DateTime, + + /// Last update timestamp. + #[field(response)] + #[auto] + pub updated_at: DateTime, +} + +// ============================================================================ +// Router Setup +// ============================================================================ + +/// 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() + // 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) +} + +// ============================================================================ +// Main +// ============================================================================ + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .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".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 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(); +} diff --git a/examples/commands/Cargo.toml b/examples/commands/Cargo.toml new file mode 100644 index 0000000..74159a8 --- /dev/null +++ b/examples/commands/Cargo.toml @@ -0,0 +1,30 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +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"] } +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/commands/migrations/20240101000000_create_accounts.sql b/examples/commands/migrations/20240101000000_create_accounts.sql new file mode 100644 index 0000000..118b7d2 --- /dev/null +++ b/examples/commands/migrations/20240101000000_create_accounts.sql @@ -0,0 +1,12 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..f66095d --- /dev/null +++ b/examples/commands/src/main.rs @@ -0,0 +1,284 @@ +// 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 std::sync::Arc; + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::post +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +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)] +#[allow(clippy::duplicated_attributes)] +#[command(Deactivate, requires_id)] +#[command(UpdateEmail, source = "update")] +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 MyAccountHandler { + pool: Arc +} + +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 + }; + + AccountRepository::create(&*self.pool, 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) + }; + + AccountRepository::update(&*self.pool, 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) + }; + + AccountRepository::update(&*self.pool, 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 + }; + + AccountRepository::update(&*self.pool, cmd.id, dto) + .await + .map_err(|e| CommandError(e.to_string())) + } +} + +// ============================================================================ +// Application State +// ============================================================================ + +#[derive(Clone)] +struct AppState { + 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(input): Json +) -> Result { + let cmd = RegisterAccount { + email: input.email, + name: input.name + }; + 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(input): Json +) -> Result { + let cmd = UpdateEmailAccount { + id, + email: input.email, + name: input.name, + active: input.active + }; + 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 = MyAccountHandler { + 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..07182b1 --- /dev/null +++ b/examples/events/Cargo.toml @@ -0,0 +1,30 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +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", "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" +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 new file mode 100644 index 0000000..14ab784 --- /dev/null +++ b/examples/events/migrations/20240101000000_create_orders.sql @@ -0,0 +1,20 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..c9a94ae --- /dev/null +++ b/examples/events/src/main.rs @@ -0,0 +1,267 @@ +// 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}, +}; +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, PartialEq, 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 { old, new } => { + let mut changed = Vec::new(); + if old.customer_name != new.customer_name { + changed.push("customer_name"); + } + if old.product != new.product { + changed.push("product"); + } + if old.quantity != new.quantity { + changed.push("quantity"); + } + if old.status != new.status { + changed.push("status"); + } + format!("[AUDIT] Order updated: id={}, changed={:?}", new.id, changed) + } + OrderEvent::HardDeleted { 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 { + // 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 { + old: old.clone(), + new: new.clone(), + }; + let log = handle_event(&event); + tracing::info!("{}", log); + let _ = state.events.send(log); + + Ok(Json(OrderResponse::from(new))) +} + +async fn delete_order( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = OrderRepository::delete(&*state.pool, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + // Emit event + let event = OrderEvent::HardDeleted { 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..84c42c3 --- /dev/null +++ b/examples/filtering/Cargo.toml @@ -0,0 +1,29 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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"] } +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 new file mode 100644 index 0000000..5e54fe6 --- /dev/null +++ b/examples/filtering/migrations/20240101000000_create_products.sql @@ -0,0 +1,30 @@ +-- 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, + 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..74b8be4 --- /dev/null +++ b/examples/filtering/src/main.rs @@ -0,0 +1,218 @@ +// 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_from: p.price_min, + price_to: p.price_max, + stock_from: p.stock_min, + stock_to: None, + active: p.active, + created_at_from: None, + created_at_to: None, + limit: Some(p.limit), + offset: Some(p.offset), + } + } +} + +// ============================================================================ +// 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 { + // 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 + .query(query) + .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..77d0ddd --- /dev/null +++ b/examples/full-app/Cargo.toml @@ -0,0 +1,35 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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" +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..aa8c1d0 --- /dev/null +++ b/examples/full-app/migrations/20240101000000_create_schema.sql @@ -0,0 +1,130 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..6ac2061 --- /dev/null +++ b/examples/full-app/src/main.rs @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Full Application Example with entity-derive +//! +//! 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)]`) +//! - Streams (`#[entity(streams)]`) +//! - Filtering (`#[filter]`, `#[filter(like)]`, `#[filter(range)]`) + +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 utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; +use uuid::Uuid; + +// ============================================================================ +// Entity Definitions +// ============================================================================ + +/// 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, + api(tag = "Users", handlers, title = "E-Commerce API", api_version = "1.0.0") +)] +#[has_many(Order)] +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>, +} + +/// Category entity (basic CRUD without auto-handlers). +#[derive(Debug, Clone, Entity)] +#[entity(table = "categories")] +#[has_many(Product)] +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, +} + +/// 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)] + #[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>, +} + +/// Order entity with transactions and events. +#[derive(Debug, Clone, PartialEq, Entity)] +#[entity(table = "orders", transactions, events)] +#[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)] + #[filter] + pub status: String, + + #[field(update, response)] + pub total: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, + + #[field(response)] + #[auto] + pub updated_at: DateTime, +} + +/// Order item entity (line items). +#[derive(Debug, Clone, Entity)] +#[entity(table = "order_items", transactions)] +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)] + pub quantity: i32, + + #[field(create, response)] + pub unit_price: i64, + + #[field(response)] + #[auto] + pub created_at: DateTime, +} + +/// Audit log for streaming. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Entity)] +#[entity(table = "audit_logs", streams, events)] +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, +} + +// ============================================================================ +// Custom Order Placement (Transaction Example) +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct PlaceOrderRequest { + user_id: Uuid, + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct OrderItemInput { + product_id: Uuid, + quantity: i32, +} + +#[derive(Debug, Serialize)] +struct PlaceOrderResponse { + order: OrderResponse, + items: Vec, + total_formatted: String, +} + +/// Place an order atomically using transactions. +async fn place_order( + 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(&*pool) + .with_orders() + .with_order_items() + .with_products() + .run(|mut ctx| async move { + // Create order + let order = ctx + .orders() + .create(CreateOrderRequest { + user_id: req.user_id, + status: "pending".to_string(), + }) + .await?; + + let mut total: i64 = 0; + let mut created_items = Vec::new(); + + // Process each item + for item in &req.items { + 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 + ))); + } + + 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; + + 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?; + } + + // Update order total + let final_order = ctx + .orders() + .update( + order.id, + UpdateOrderRequest { + status: None, + total: Some(total), + }, + ) + .await?; + + Ok((final_order, created_items, total)) + }) + .await; + + match result { + Ok((order, items, total)) => { + tracing::info!("Order {} placed successfully", order.id); + let response = PlaceOrderResponse { + order: OrderResponse::from(order), + items: items.into_iter().map(OrderItemResponse::from).collect(), + total_formatted: format!("${:.2}", total as f64 / 100.0), + }; + Ok((StatusCode::CREATED, Json(response))) + } + Err(e) => { + tracing::error!("Order placement failed: {}", e); + Err((StatusCode::BAD_REQUEST, e.to_string())) + } + } +} + +// ============================================================================ +// Audit Log Streaming Example +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct AuditQuery { + entity_type: Option, + action: Option, + limit: Option, +} + +async fn stream_audit_logs( + State(pool): State>, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + let filter = AuditLogFilter { + entity_type: query.entity_type, + action: query.action, + created_at_from: None, + created_at_to: None, + limit: None, + offset: None, + }; + + let mut stream = 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(pool: Arc) -> Router { + Router::new() + // 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) +} + +// ============================================================================ +// 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 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!(" - 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!(" - Streams: Audit log processing"); + tracing::info!(""); + tracing::info!("Endpoints:"); + 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(Arc::new(pool))).await.unwrap(); +} diff --git a/examples/hooks/Cargo.toml b/examples/hooks/Cargo.toml new file mode 100644 index 0000000..9749ab1 --- /dev/null +++ b/examples/hooks/Cargo.toml @@ -0,0 +1,29 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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"] } +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/hooks/migrations/20240101000000_create_users.sql b/examples/hooks/migrations/20240101000000_create_users.sql new file mode 100644 index 0000000..f8100ef --- /dev/null +++ b/examples/hooks/migrations/20240101000000_create_users.sql @@ -0,0 +1,12 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..7984409 --- /dev/null +++ b/examples/hooks/src/main.rs @@ -0,0 +1,291 @@ +// 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 std::sync::Arc; + +use async_trait::async_trait; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{patch, post} +}; +use chrono::{DateTime, Utc}; +use entity_derive::Entity; +use sqlx::PgPool; +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..9d86cb5 --- /dev/null +++ b/examples/relations/Cargo.toml @@ -0,0 +1,29 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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"] } +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 new file mode 100644 index 0000000..22d55d5 --- /dev/null +++ b/examples/relations/migrations/20240101000000_create_tables.sql @@ -0,0 +1,46 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..9cf5cda --- /dev/null +++ b/examples/relations/src/main.rs @@ -0,0 +1,212 @@ +// 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 { + // 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 (from has_many) + let posts = AuthorRepository::find_posts(&*state.pool, 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 { + // 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 = 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 = PostRepository::find_comments(&*state.pool, 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 { + // 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)?; + + 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..da48585 --- /dev/null +++ b/examples/soft-delete/Cargo.toml @@ -0,0 +1,29 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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"] } +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 new file mode 100644 index 0000000..d8e9373 --- /dev/null +++ b/examples/soft-delete/migrations/20240101000000_create_documents.sql @@ -0,0 +1,22 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..09068fb --- /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}, +}; +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..3b44716 --- /dev/null +++ b/examples/streams/Cargo.toml @@ -0,0 +1,32 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +validate = [] + +[dependencies] +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" +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" +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 new file mode 100644 index 0000000..0096238 --- /dev/null +++ b/examples/streams/migrations/20240101000000_create_logs.sql @@ -0,0 +1,27 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..e52b43d --- /dev/null +++ b/examples/streams/src/main.rs @@ -0,0 +1,287 @@ +// 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, serde::Serialize, serde::Deserialize, Entity)] +#[entity(table = "audit_logs", streams, events)] +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_from: None, + created_at_to: None, + limit: None, + offset: 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_from: None, + created_at_to: None, + limit: None, + offset: 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_from: None, + created_at_to: None, + limit: None, + offset: 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..ecf1742 --- /dev/null +++ b/examples/transactions/Cargo.toml @@ -0,0 +1,30 @@ +# 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" + +[features] +default = ["postgres", "api"] +postgres = [] +api = [] +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"] } +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 new file mode 100644 index 0000000..d1be580 --- /dev/null +++ b/examples/transactions/migrations/20240101000000_create_tables.sql @@ -0,0 +1,29 @@ +-- SPDX-FileCopyrightText: 2025-2026 RAprogramm +-- SPDX-License-Identifier: MIT + +-- 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..517cdc2 --- /dev/null +++ b/examples/transactions/src/main.rs @@ -0,0 +1,278 @@ +// 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 = BankAccountRepository::list(&*state.pool, 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 = BankAccountRepository::find_by_id(&*state.pool, 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 = BankAccountRepository::create(&*state.pool, 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 = TransferLogRepository::list(&*state.pool, 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(); +}