From dda37f8fcedfaabc26bd752d1fbdeb714124733c Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 12:01:06 +0700 Subject: [PATCH 1/4] feat(api): add CRUD handler generation Add `api(handlers)` option to generate REST handlers automatically: - create_{entity} - POST handler - get_{entity} - GET by ID handler - update_{entity} - PATCH handler - delete_{entity} - DELETE handler - list_{entity} - GET list handler Also adds separate routers: - {entity}_router() for CRUD (Repository trait) - {entity}_commands_router() for commands (CommandHandler trait) Part of #67: fix examples with generated handlers --- crates/entity-derive-impl/src/entity/api.rs | 22 +- .../entity-derive-impl/src/entity/api/crud.rs | 573 ++++++++++++++++++ .../src/entity/api/openapi.rs | 136 ++++- .../src/entity/api/router.rs | 199 +++++- .../src/entity/parse/api.rs | 53 +- 5 files changed, 915 insertions(+), 68 deletions(-) create mode 100644 crates/entity-derive-impl/src/entity/api/crud.rs diff --git a/crates/entity-derive-impl/src/entity/api.rs b/crates/entity-derive-impl/src/entity/api.rs index 91b3be0..3fe4b4f 100644 --- a/crates/entity-derive-impl/src/entity/api.rs +++ b/crates/entity-derive-impl/src/entity/api.rs @@ -11,7 +11,8 @@ //! ```text //! api/ //! ├── mod.rs — Orchestrator (this file) -//! ├── handlers.rs — Axum handler functions with #[utoipa::path] +//! ├── crud.rs — CRUD handler functions (create, get, update, delete, list) +//! ├── handlers.rs — Command handler functions with #[utoipa::path] //! ├── router.rs — Router factory function //! └── openapi.rs — OpenApi struct for Swagger UI //! ``` @@ -58,6 +59,7 @@ //! let openapi = UserApi::openapi(); //! ``` +mod crud; mod handlers; mod openapi; mod router; @@ -69,24 +71,30 @@ use super::parse::EntityDef; /// Main entry point for API code generation. /// -/// Returns empty `TokenStream` if `api(...)` is not configured -/// or no commands are defined. +/// Returns empty `TokenStream` if `api(...)` is not configured. +/// Generates CRUD handlers if `handlers` is enabled, and command handlers +/// if commands are defined. pub fn generate(entity: &EntityDef) -> TokenStream { if !entity.has_api() { return TokenStream::new(); } - // API generation requires commands to be enabled - if !entity.has_commands() || entity.command_defs().is_empty() { + let has_crud = entity.api_config().has_handlers(); + let has_commands = entity.has_commands() && !entity.command_defs().is_empty(); + + // Need at least one type of handler to generate API + if !has_crud && !has_commands { return TokenStream::new(); } - let handlers = handlers::generate(entity); + let crud_handlers = crud::generate(entity); + let command_handlers = handlers::generate(entity); let router = router::generate(entity); let openapi = openapi::generate(entity); quote! { - #handlers + #crud_handlers + #command_handlers #router #openapi } diff --git a/crates/entity-derive-impl/src/entity/api/crud.rs b/crates/entity-derive-impl/src/entity/api/crud.rs new file mode 100644 index 0000000..ce6f5b3 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/api/crud.rs @@ -0,0 +1,573 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! CRUD handler generation with utoipa annotations. +//! +//! Generates REST handlers for CRUD operations on entities. +//! Each handler includes `#[utoipa::path]` annotations for OpenAPI +//! documentation. +//! +//! # Generated Handlers +//! +//! | Operation | HTTP Method | Path Pattern | Handler | +//! |-----------|-------------|--------------|---------| +//! | Create | POST | `/{prefix}/{entities}` | `create_{entity}` | +//! | Get | GET | `/{prefix}/{entities}/{id}` | `get_{entity}` | +//! | Update | PATCH | `/{prefix}/{entities}/{id}` | `update_{entity}` | +//! | Delete | DELETE | `/{prefix}/{entities}/{id}` | `delete_{entity}` | +//! | List | GET | `/{prefix}/{entities}` | `list_{entity}` | +//! +//! # Example +//! +//! For `User` entity with `api(tag = "Users", handlers)`: +//! +//! ```rust,ignore +//! #[utoipa::path( +//! post, +//! path = "/api/v1/users", +//! tag = "Users", +//! request_body = CreateUserRequest, +//! responses( +//! (status = 201, body = UserResponse), +//! (status = 400, description = "Validation error"), +//! (status = 500, description = "Internal server error") +//! ) +//! )] +//! pub async fn create_user( +//! State(pool): State>, +//! Json(dto): Json, +//! ) -> Result<(StatusCode, Json), StatusCode> { +//! let entity = pool.create(dto).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; +//! Ok((StatusCode::CREATED, Json(UserResponse::from(entity)))) +//! } +//! ``` + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::entity::parse::EntityDef; + +/// Generate all CRUD handler functions for the entity. +pub fn generate(entity: &EntityDef) -> TokenStream { + if !entity.api_config().has_handlers() { + return TokenStream::new(); + } + + let create = generate_create_handler(entity); + let get = generate_get_handler(entity); + let update = generate_update_handler(entity); + let delete = generate_delete_handler(entity); + let list = generate_list_handler(entity); + + quote! { + #create + #get + #update + #delete + #list + } +} + +/// Generate the create handler. +fn generate_create_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + + 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, "create"); + let deprecated_attr = build_deprecated_attr(entity); + + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body = #create_dto, + responses( + (status = 201, body = #response_dto, description = "Created"), + (status = 400, description = "Validation error"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + post, + path = #path, + tag = #tag, + request_body = #create_dto, + responses( + (status = 201, body = #response_dto, description = "Created"), + (status = 400, description = "Validation error"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + }; + + let doc = format!( + "Create a new {} entity.\n\n\ + Generated by entity-derive.", + entity_name + ); + + 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>, + ) -> Result<(axum::http::StatusCode, axum::response::Json<#response_dto>), axum::http::StatusCode> + where + R: #entity_name Repository + 'static, + { + let entity = repo.create(dto).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + Ok((axum::http::StatusCode::CREATED, axum::response::Json(#response_dto::from(entity)))) + } + } +} + +/// Generate the get handler. +fn generate_get_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + 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, "get"); + let deprecated_attr = build_deprecated_attr(entity); + + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + responses( + (status = 200, body = #response_dto, description = "Found"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + responses( + (status = 200, body = #response_dto, description = "Found"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + }; + + let doc = format!( + "Get {} entity by ID.\n\n\ + Generated by entity-derive.", + 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>, + ) -> Result, axum::http::StatusCode> + where + R: #entity_name Repository + 'static, + { + let entity = repo + .find_by_id(id) + .await + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} + +/// Generate the update handler. +fn generate_update_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + 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, "update"); + let deprecated_attr = build_deprecated_attr(entity); + + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + request_body = #update_dto, + responses( + (status = 200, body = #response_dto, description = "Updated"), + (status = 400, description = "Validation error"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + patch, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + request_body = #update_dto, + responses( + (status = 200, body = #response_dto, description = "Updated"), + (status = 400, description = "Validation error"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + }; + + let doc = format!( + "Update {} entity by ID.\n\n\ + Generated by entity-derive.", + 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>, + ) -> Result, axum::http::StatusCode> + where + R: #entity_name Repository + 'static, + { + let entity = repo + .update(id, dto) + .await + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(axum::response::Json(#response_dto::from(entity))) + } + } +} + +/// Generate the delete handler. +fn generate_delete_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + 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, "delete"); + let deprecated_attr = build_deprecated_attr(entity); + + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + delete, + path = #path, + tag = #tag, + params(("id" = #id_type, Path, description = "Entity ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + }; + + let doc = format!( + "Delete {} entity by ID.\n\n\ + Generated by entity-derive.", + 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>, + ) -> Result + where + R: #entity_name Repository + 'static, + { + let deleted = repo + .delete(id) + .await + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + if deleted { + Ok(axum::http::StatusCode::NO_CONTENT) + } else { + Err(axum::http::StatusCode::NOT_FOUND) + } + } + } +} + +/// Generate the list handler. +fn generate_list_handler(entity: &EntityDef) -> TokenStream { + let vis = &entity.vis; + let entity_name = entity.name(); + let api_config = entity.api_config(); + + 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, "list"); + let deprecated_attr = build_deprecated_attr(entity); + + let utoipa_attr = if security_attr.is_empty() { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Max items to return"), + ("offset" = Option, Query, description = "Items to skip") + ), + responses( + (status = 200, body = Vec<#response_dto>, description = "List of entities"), + (status = 500, description = "Internal server error") + ) + #deprecated_attr + )] + } + } else { + quote! { + #[utoipa::path( + get, + path = #path, + tag = #tag, + params( + ("limit" = Option, Query, description = "Max items to return"), + ("offset" = Option, Query, description = "Items to skip") + ), + responses( + (status = 200, body = Vec<#response_dto>, description = "List of entities"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + #security_attr + #deprecated_attr + )] + } + }; + + let doc = format!( + "List {} entities with pagination.\n\n\ + Generated by entity-derive.", + entity_name + ); + + quote! { + /// Pagination query parameters. + #[derive(Debug, Clone, serde::Deserialize)] + #vis struct PaginationQuery { + /// Maximum number of items to return. + #[serde(default = "default_limit")] + pub limit: i64, + /// Number of items to skip. + #[serde(default)] + pub offset: i64, + } + + fn default_limit() -> i64 { 100 } + + #[doc = #doc] + #utoipa_attr + #vis async fn #handler_name( + axum::extract::State(repo): axum::extract::State>, + axum::extract::Query(pagination): axum::extract::Query, + ) -> Result>, axum::http::StatusCode> + where + R: #entity_name Repository + 'static, + { + let entities = repo + .list(pagination.limit, pagination.offset) + .await + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + let responses: Vec<#response_dto> = entities.into_iter().map(#response_dto::from).collect(); + Ok(axum::response::Json(responses)) + } + } +} + +/// Build the collection path (e.g., `/api/v1/users`). +fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Build the item path (e.g., `/api/v1/users/{id}`). +fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + +/// Build security attribute for a handler. +fn build_security_attr(entity: &EntityDef, _operation: &str) -> TokenStream { + let api_config = entity.api_config(); + + if let Some(security) = &api_config.security { + let security_name = match security.as_str() { + "bearer" => "bearer_auth", + "api_key" => "api_key", + "admin" => "admin_auth", + "oauth2" => "oauth2", + _ => "bearer_auth" + }; + quote! { security(#security_name = []) } + } else { + TokenStream::new() + } +} + +/// Build deprecated attribute if API is deprecated. +fn build_deprecated_attr(entity: &EntityDef) -> TokenStream { + if entity.api_config().is_deprecated() { + quote! { , deprecated = true } + } else { + TokenStream::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_entity() -> EntityDef { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + EntityDef::from_derive_input(&input).unwrap() + } + + #[test] + fn collection_path_format() { + let entity = create_test_entity(); + let path = build_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn item_path_format() { + let entity = create_test_entity(); + let path = build_item_path(&entity); + assert_eq!(path, "/users/{id}"); + } + + #[test] + fn generates_handlers_when_enabled() { + let entity = create_test_entity(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("create_user")); + assert!(output.contains("get_user")); + assert!(output.contains("update_user")); + assert!(output.contains("delete_user")); + assert!(output.contains("list_user")); + } + + #[test] + fn no_handlers_when_disabled() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users"))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + assert!(tokens.is_empty()); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index 9133cb4..ce88f08 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -8,14 +8,20 @@ //! //! # Generated Code //! -//! For `User` entity: +//! For `User` entity with handlers and commands: //! //! ```rust,ignore //! /// OpenAPI documentation for User entity endpoints. //! #[derive(utoipa::OpenApi)] //! #[openapi( -//! paths(register_user, update_email_user), -//! components(schemas(User, RegisterUser, UpdateEmailUser)), +//! paths( +//! create_user, get_user, update_user, delete_user, list_user, +//! register_user, update_email_user +//! ), +//! components(schemas( +//! User, UserResponse, CreateUserRequest, UpdateUserRequest, +//! RegisterUser, UpdateEmailUser +//! )), //! tags((name = "Users", description = "User management")) //! )] //! pub struct UserApi; @@ -29,8 +35,10 @@ use crate::entity::parse::{CommandDef, EntityDef}; /// Generate the OpenAPI struct. pub fn generate(entity: &EntityDef) -> TokenStream { - let commands = entity.command_defs(); - if commands.is_empty() { + let has_crud = entity.api_config().has_handlers(); + let has_commands = !entity.command_defs().is_empty(); + + if !has_crud && !has_commands { return TokenStream::new(); } @@ -38,26 +46,17 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let entity_name = entity.name(); let api_config = entity.api_config(); - // OpenApi struct name: UserApi let api_struct = format_ident!("{}Api", entity_name); - // Tag for OpenAPI grouping let tag = api_config.tag_or_default(&entity.name_str()); - - // Tag description: explicit > entity doc comment > default let tag_description = api_config .tag_description .clone() .or_else(|| entity.doc().map(String::from)) .unwrap_or_else(|| format!("{} management", entity_name)); - // Handler function names for paths - let handler_names = generate_handler_names(entity, commands); - - // Schema types (entity + all command structs) - let schema_types = generate_schema_types(entity, commands); - - // Security schemes + let handler_names = generate_all_handler_names(entity); + let schema_types = generate_all_schema_types(entity); let security_schemes = generate_security_schemes(api_config.security.as_deref()); let doc = format!( @@ -98,27 +97,47 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } } -/// Generate comma-separated handler function names. -fn generate_handler_names(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { - let names: Vec = commands - .iter() - .map(|cmd| handler_function_name(entity, cmd)) - .collect(); +/// Generate all handler names (CRUD + commands). +fn generate_all_handler_names(entity: &EntityDef) -> TokenStream { + let mut names: Vec = Vec::new(); + + // CRUD handlers + if entity.api_config().has_handlers() { + let snake = entity.name_str().to_case(Case::Snake); + names.push(format_ident!("create_{}", snake)); + names.push(format_ident!("get_{}", snake)); + names.push(format_ident!("update_{}", snake)); + names.push(format_ident!("delete_{}", snake)); + names.push(format_ident!("list_{}", snake)); + } + + // Command handlers + for cmd in entity.command_defs() { + names.push(command_handler_name(entity, cmd)); + } quote! { #(#names),* } } -/// Generate comma-separated schema types. -fn generate_schema_types(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { +/// Generate all schema types (entity, DTOs, commands). +fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { let entity_name = entity.name(); let entity_name_str = entity.name_str(); + let mut types: Vec = vec![entity_name.clone()]; - let command_structs: Vec = commands - .iter() - .map(|cmd| cmd.struct_name(&entity_name_str)) - .collect(); + // CRUD DTOs + if entity.api_config().has_handlers() { + types.push(entity.ident_with("", "Response")); + types.push(entity.ident_with("Create", "Request")); + types.push(entity.ident_with("Update", "Request")); + } + + // Command structs + for cmd in entity.command_defs() { + types.push(cmd.struct_name(&entity_name_str)); + } - quote! { #entity_name, #(#command_structs),* } + quote! { #(#types),* } } /// Generate security schemes if configured. @@ -150,9 +169,64 @@ fn generate_security_schemes(security: Option<&str>) -> TokenStream { } } -/// Get the handler function name. -fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { +/// Get command handler function name. +fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); let cmd_snake = cmd.name.to_string().to_case(Case::Snake); format_ident!("{}_{}", cmd_snake, entity_snake) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_crud_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + assert!(output.contains("UserApi")); + assert!(output.contains("create_user")); + assert!(output.contains("get_user")); + 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("bearer_auth")); + } + + #[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()); + } +} diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index 05b4eb2..bfa19c8 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -3,22 +3,38 @@ //! Router factory generation. //! -//! Generates a function that creates an axum Router with all entity endpoints. +//! Generates functions that create axum Routers for entity endpoints. //! -//! # Generated Code +//! # Generated Routers //! -//! For `User` entity with Register and UpdateEmail commands: +//! | Configuration | Generated Function | Type Parameter | +//! |---------------|-------------------|----------------| +//! | `handlers` | `{entity}_router` | Repository trait | +//! | `commands` | `{entity}_commands_router` | CommandHandler trait | +//! +//! # Example +//! +//! For `User` entity with both handlers and commands: //! //! ```rust,ignore -//! /// Create router for User entity endpoints. -//! pub fn user_router() -> axum::Router +//! // CRUD router +//! pub fn user_router() -> axum::Router> +//! where +//! R: UserRepository + 'static, +//! { +//! axum::Router::new() +//! .route("/users", post(create_user::).get(list_user::)) +//! .route("/users/:id", get(get_user::).patch(update_user::).delete(delete_user::)) +//! } +//! +//! // Commands router +//! pub fn user_commands_router() -> axum::Router //! where //! H: UserCommandHandler + 'static, //! H::Context: Default, //! { //! axum::Router::new() -//! .route("/api/v1/users/register", axum::routing::post(register_user::)) -//! .route("/api/v1/users/:id/update-email", axum::routing::put(update_email_user::)) +//! .route("/users/register", post(register_user::)) //! } //! ``` @@ -28,10 +44,20 @@ use quote::{format_ident, quote}; use crate::entity::parse::{CommandDef, CommandKindHint, EntityDef}; -/// Generate the router factory function. +/// Generate all router factory functions. pub fn generate(entity: &EntityDef) -> TokenStream { - let commands = entity.command_defs(); - if commands.is_empty() { + let crud_router = generate_crud_router(entity); + let commands_router = generate_commands_router(entity); + + quote! { + #crud_router + #commands_router + } +} + +/// Generate CRUD router for repository-based handlers. +fn generate_crud_router(entity: &EntityDef) -> TokenStream { + if !entity.api_config().has_handlers() { return TokenStream::new(); } @@ -40,17 +66,88 @@ pub fn generate(entity: &EntityDef) -> TokenStream { let entity_name_str = entity.name_str(); let entity_snake = entity_name_str.to_case(Case::Snake); - // Router function name: user_router let router_fn = format_ident!("{}_router", entity_snake); + let repo_trait = format_ident!("{}Repository", entity_name); + + let crud_routes = generate_crud_routes(entity); + + let doc = format!( + "Create axum router for {} CRUD endpoints.\n\n\ + # Usage\n\n\ + ```rust,ignore\n\ + let pool = Arc::new(PgPool::connect(url).await?);\n\ + let app = Router::new()\n\ + .merge({}::())\n\ + .with_state(pool);\n\ + ```", + entity_name, router_fn + ); + + quote! { + #[doc = #doc] + #vis fn #router_fn() -> axum::Router> + where + R: #repo_trait + 'static, + { + axum::Router::new() + #crud_routes + } + } +} + +/// Generate CRUD route definitions. +fn generate_crud_routes(entity: &EntityDef) -> TokenStream { + 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); - // Handler trait name: UserCommandHandler + 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); + + quote! { + .route(#collection_path, axum::routing::post(#create_handler::).get(#list_handler::)) + .route(#item_path, axum::routing::get(#get_handler::).patch(#update_handler::).delete(#delete_handler::)) + } +} + +/// 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); - // Generate route definitions - let routes = generate_routes(entity, commands); + let routes = generate_command_routes(entity, commands); let doc = format!( - "Create axum router for {} entity endpoints.\n\n\ + "Create axum router for {} command endpoints.\n\n\ # Usage\n\n\ ```rust,ignore\n\ let handler = Arc::new(MyHandler::new());\n\ @@ -74,20 +171,20 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } } -/// Generate all route definitions. -fn generate_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { +/// Generate command route definitions. +fn generate_command_routes(entity: &EntityDef, commands: &[CommandDef]) -> TokenStream { let routes: Vec = commands .iter() - .map(|cmd| generate_route(entity, cmd)) + .map(|cmd| generate_command_route(entity, cmd)) .collect(); quote! { #(#routes)* } } -/// Generate a single route definition. -fn generate_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { - let path = build_axum_path(entity, cmd); - let handler_name = handler_function_name(entity, cmd); +/// Generate a single command route definition. +fn generate_command_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { + let path = build_command_path(entity, cmd); + let handler_name = command_handler_name(entity, cmd); let method = axum_method_for_command(cmd); quote! { @@ -95,8 +192,8 @@ fn generate_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { } } -/// Build the axum-style path (uses :id instead of {id}). -fn build_axum_path(entity: &EntityDef, cmd: &CommandDef) -> String { +/// Build command path (uses :id instead of {id}). +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); @@ -108,18 +205,17 @@ fn build_axum_path(entity: &EntityDef, cmd: &CommandDef) -> String { format!("{}/{}s/{}", prefix, entity_path, cmd_path) }; - // Normalize double slashes that can appear when prefix is empty path.replace("//", "/") } -/// Get the handler function name. -fn handler_function_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { +/// Get command handler function name. +fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); let cmd_snake = cmd.name.to_string().to_case(Case::Snake); format_ident!("{}_{}", cmd_snake, entity_snake) } -/// Get the axum routing method for a command. +/// Get axum routing method for a command. fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { match cmd.kind { CommandKindHint::Create => format_ident!("post"), @@ -128,3 +224,50 @@ fn axum_method_for_command(cmd: &CommandDef) -> syn::Ident { CommandKindHint::Custom => format_ident!("post") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crud_collection_path() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_collection_path(&entity); + assert_eq!(path, "/users"); + } + + #[test] + fn crud_item_path() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_item_path(&entity); + assert_eq!(path, "/users/:id"); + } + + #[test] + fn crud_path_with_prefix() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", path_prefix = "/api/v1", handlers))] + pub struct User { + #[id] + pub id: uuid::Uuid, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let path = build_crud_collection_path(&entity); + assert_eq!(path, "/api/v1/users"); + } +} diff --git a/crates/entity-derive-impl/src/entity/parse/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs index c0b258c..a75e91f 100644 --- a/crates/entity-derive-impl/src/entity/parse/api.rs +++ b/crates/entity-derive-impl/src/entity/parse/api.rs @@ -73,7 +73,17 @@ pub struct ApiConfig { /// Version in which this API is deprecated. /// /// Marks all endpoints with `deprecated = true` in OpenAPI. - pub deprecated_in: Option + pub deprecated_in: Option, + + /// Generate CRUD handlers for REST API. + /// + /// When `true`, generates: + /// - `create_{entity}` - POST handler + /// - `get_{entity}` - GET by ID handler + /// - `update_{entity}` - PATCH handler + /// - `delete_{entity}` - DELETE handler + /// - `list_{entity}` - GET list handler + pub handlers: bool } impl ApiConfig { @@ -121,6 +131,11 @@ impl ApiConfig { self.deprecated_in.is_some() } + /// Check if CRUD handlers should be generated. + pub fn has_handlers(&self) -> bool { + self.handlers + } + /// Get security scheme for a command. /// /// Returns `None` for public commands, otherwise the default security. @@ -209,12 +224,22 @@ pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { let value: syn::LitStr = nested.value()?.parse()?; config.deprecated_in = Some(value.value()); } + "handlers" => { + // Support both `handlers` (flag) and `handlers = true` + if nested.input.peek(syn::Token![=]) { + let _: syn::Token![=] = nested.input.parse()?; + let value: syn::LitBool = nested.input.parse()?; + config.handlers = value.value(); + } else { + config.handlers = true; + } + } _ => { return Err(syn::Error::new( ident.span(), format!( "unknown api option '{}', expected: tag, tag_description, path_prefix, \ - security, public, version, deprecated_in", + security, public, version, deprecated_in, handlers", ident_str ) )); @@ -343,4 +368,28 @@ mod tests { }; assert_eq!(config.full_path_prefix(), "/api/v1"); } + + #[test] + fn parse_handlers_flag() { + let config = parse_test_config(r#"api(tag = "Users", handlers)"#); + assert!(config.has_handlers()); + } + + #[test] + fn parse_handlers_true() { + let config = parse_test_config(r#"api(tag = "Users", handlers = true)"#); + assert!(config.has_handlers()); + } + + #[test] + fn parse_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users", handlers = false)"#); + assert!(!config.has_handlers()); + } + + #[test] + fn default_handlers_false() { + let config = parse_test_config(r#"api(tag = "Users")"#); + assert!(!config.has_handlers()); + } } From 172ddc587fbf3d4ecd4f673fd83abb925e221e4b Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 13:11:35 +0700 Subject: [PATCH 2/4] #67 feat(api): add OpenAPI info config and selective handlers - Add OpenAPI info configuration (title, description, version, license, contact) - Add selective handlers via handlers(create, get, list) syntax - Generate ErrorResponse and PaginationQuery schemas in OpenAPI - Refactor path generation to only include enabled handlers - Update router and crud modules to respect HandlerConfig - Fix utoipa 5.x compatibility with Modify trait pattern --- README.md | 3 + .../entity-derive-impl/src/entity/api/crud.rs | 417 +++++---- .../src/entity/api/openapi.rs | 788 ++++++++++++++++-- .../src/entity/api/router.rs | 59 +- .../src/entity/parse/api.rs | 209 ++++- examples/basic/Cargo.toml | 1 + examples/basic/src/main.rs | 271 ++---- 7 files changed, 1286 insertions(+), 462 deletions(-) diff --git a/README.md b/README.md index 94bd40c..4fbeb7e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ REUSE Compliant + + Wiki +

--- diff --git a/crates/entity-derive-impl/src/entity/api/crud.rs b/crates/entity-derive-impl/src/entity/api/crud.rs index ce6f5b3..08219e8 100644 --- a/crates/entity-derive-impl/src/entity/api/crud.rs +++ b/crates/entity-derive-impl/src/entity/api/crud.rs @@ -3,43 +3,56 @@ //! CRUD handler generation with utoipa annotations. //! -//! Generates REST handlers for CRUD operations on entities. -//! Each handler includes `#[utoipa::path]` annotations for OpenAPI -//! documentation. +//! Generates production-ready REST handlers with: +//! - OpenAPI documentation via `#[utoipa::path]` +//! - Cookie/Bearer authentication via `security` attribute +//! - Proper error responses using `masterror::ErrorResponse` +//! - Standard HTTP status codes and error handling //! //! # Generated Handlers //! -//! | Operation | HTTP Method | Path Pattern | Handler | -//! |-----------|-------------|--------------|---------| -//! | Create | POST | `/{prefix}/{entities}` | `create_{entity}` | -//! | Get | GET | `/{prefix}/{entities}/{id}` | `get_{entity}` | -//! | Update | PATCH | `/{prefix}/{entities}/{id}` | `update_{entity}` | -//! | Delete | DELETE | `/{prefix}/{entities}/{id}` | `delete_{entity}` | -//! | List | GET | `/{prefix}/{entities}` | `list_{entity}` | +//! | Operation | HTTP Method | Path | Status Codes | +//! |-----------|-------------|------|--------------| +//! | Create | POST | `/{entities}` | 201, 400, 401, 500 | +//! | Get | GET | `/{entities}/{id}` | 200, 401, 404, 500 | +//! | Update | PATCH | `/{entities}/{id}` | 200, 400, 401, 404, 500 | +//! | Delete | DELETE | `/{entities}/{id}` | 204, 401, 404, 500 | +//! | List | GET | `/{entities}` | 200, 401, 500 | //! -//! # Example +//! # Security +//! +//! When `security = "cookie"` or `security = "bearer"` is specified, +//! handlers require authentication and use `Claims` extractor. //! -//! For `User` entity with `api(tag = "Users", handlers)`: +//! # Example //! //! ```rust,ignore +//! #[derive(Entity)] +//! #[entity(table = "users", api(tag = "Users", security = "cookie", handlers))] +//! pub struct User { /* ... */ } +//! +//! // Generated handler with auth: //! #[utoipa::path( //! post, -//! path = "/api/v1/users", +//! path = "/users", //! tag = "Users", -//! request_body = CreateUserRequest, +//! request_body(content = CreateUserRequest, description = "User data to create"), //! responses( -//! (status = 201, body = UserResponse), -//! (status = 400, description = "Validation error"), -//! (status = 500, description = "Internal server error") -//! ) +//! (status = 201, description = "User created successfully", body = UserResponse), +//! (status = 400, description = "Invalid request data", body = ErrorResponse), +//! (status = 401, description = "Authentication required", body = ErrorResponse), +//! (status = 500, description = "Internal server error", body = ErrorResponse) +//! ), +//! security(("cookieAuth" = [])) //! )] -//! pub async fn create_user( -//! State(pool): State>, +//! pub async fn create_user( +//! _claims: Claims, +//! State(repo): State>, //! Json(dto): Json, -//! ) -> Result<(StatusCode, Json), StatusCode> { -//! let entity = pool.create(dto).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; -//! Ok((StatusCode::CREATED, Json(UserResponse::from(entity)))) -//! } +//! ) -> AppResult<(StatusCode, Json)> +//! where +//! R: UserRepository + 'static, +//! { /* ... */ } //! ``` use convert_case::{Case, Casing}; @@ -48,17 +61,39 @@ use quote::{format_ident, quote}; use crate::entity::parse::EntityDef; -/// Generate all CRUD handler functions for the entity. +/// Generate CRUD handler functions based on enabled handlers. pub fn generate(entity: &EntityDef) -> TokenStream { if !entity.api_config().has_handlers() { return TokenStream::new(); } - let create = generate_create_handler(entity); - let get = generate_get_handler(entity); - let update = generate_update_handler(entity); - let delete = generate_delete_handler(entity); - let list = generate_list_handler(entity); + 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 @@ -73,30 +108,38 @@ pub fn generate(entity: &EntityDef) -> TokenStream { 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 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 tag = api_config.tag_or_default(&entity_name_str); - let security_attr = build_security_attr(entity, "create"); + let security_attr = build_security_attr(entity); let deprecated_attr = build_deprecated_attr(entity); - let utoipa_attr = if security_attr.is_empty() { + 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 = #create_dto, + request_body(content = #create_dto, description = #request_body_desc), responses( - (status = 201, body = #response_dto, description = "Created"), - (status = 400, description = "Validation error"), + (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 )] } @@ -106,23 +149,31 @@ fn generate_create_handler(entity: &EntityDef) -> TokenStream { post, path = #path, tag = #tag, - request_body = #create_dto, + request_body(content = #create_dto, description = #request_body_desc), responses( - (status = 201, body = #response_dto, description = "Created"), - (status = 400, description = "Validation error"), - (status = 401, description = "Unauthorized"), + (status = 201, description = #success_desc, body = #response_dto), + (status = 400, description = "Invalid request data"), (status = 500, description = "Internal server error") - ), - #security_attr + ) #deprecated_attr )] } }; let doc = format!( - "Create a new {} entity.\n\n\ - Generated by entity-derive.", - entity_name + "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! { @@ -131,11 +182,14 @@ fn generate_create_handler(entity: &EntityDef) -> TokenStream { #vis async fn #handler_name( axum::extract::State(repo): axum::extract::State>, axum::extract::Json(dto): axum::extract::Json<#create_dto>, - ) -> Result<(axum::http::StatusCode, axum::response::Json<#response_dto>), axum::http::StatusCode> + ) -> masterror::AppResult<(axum::http::StatusCode, axum::response::Json<#response_dto>)> where - R: #entity_name Repository + 'static, + R: #repo_trait + 'static, { - let entity = repo.create(dto).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + 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)))) } } @@ -145,31 +199,40 @@ fn generate_create_handler(entity: &EntityDef) -> TokenStream { 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 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 tag = api_config.tag_or_default(&entity_name_str); - let security_attr = build_security_attr(entity, "get"); + let security_attr = build_security_attr(entity); let deprecated_attr = build_deprecated_attr(entity); - let utoipa_attr = if security_attr.is_empty() { + 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 = "Entity ID")), + params(("id" = #id_type, Path, description = #id_desc)), responses( - (status = 200, body = #response_dto, description = "Found"), - (status = 404, description = "Not found"), + (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 )] } @@ -179,40 +242,51 @@ fn generate_get_handler(entity: &EntityDef) -> TokenStream { get, path = #path, tag = #tag, - params(("id" = #id_type, Path, description = "Entity ID")), + params(("id" = #id_type, Path, description = #id_desc)), responses( - (status = 200, body = #response_dto, description = "Found"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 200, description = #success_desc, body = #response_dto), + (status = 404, description = #not_found_desc), (status = 500, description = "Internal server error") - ), - #security_attr + ) #deprecated_attr )] } }; let doc = format!( - "Get {} entity by ID.\n\n\ - Generated by entity-derive.", + "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>, - ) -> Result, axum::http::StatusCode> + ) -> masterror::AppResult> where - R: #entity_name Repository + 'static, + R: #repo_trait + 'static, { let entity = repo .find_by_id(id) .await - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(axum::http::StatusCode::NOT_FOUND)?; + .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))) } } @@ -222,34 +296,44 @@ fn generate_get_handler(entity: &EntityDef) -> TokenStream { 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 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 tag = api_config.tag_or_default(&entity_name_str); - let security_attr = build_security_attr(entity, "update"); + let security_attr = build_security_attr(entity); let deprecated_attr = build_deprecated_attr(entity); - let utoipa_attr = if security_attr.is_empty() { + 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 = "Entity ID")), - request_body = #update_dto, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), responses( - (status = 200, body = #response_dto, description = "Updated"), - (status = 400, description = "Validation error"), - (status = 404, description = "Not found"), + (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 )] } @@ -259,24 +343,34 @@ fn generate_update_handler(entity: &EntityDef) -> TokenStream { patch, path = #path, tag = #tag, - params(("id" = #id_type, Path, description = "Entity ID")), - request_body = #update_dto, + params(("id" = #id_type, Path, description = #id_desc)), + request_body(content = #update_dto, description = #request_body_desc), responses( - (status = 200, body = #response_dto, description = "Updated"), - (status = 400, description = "Validation error"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (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") - ), - #security_attr + ) #deprecated_attr )] } }; let doc = format!( - "Update {} entity by ID.\n\n\ - Generated by entity-derive.", + "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 ); @@ -287,14 +381,14 @@ fn generate_update_handler(entity: &EntityDef) -> TokenStream { 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>, - ) -> Result, axum::http::StatusCode> + ) -> masterror::AppResult> where - R: #entity_name Repository + 'static, + R: #repo_trait + 'static, { let entity = repo .update(id, dto) .await - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| masterror::AppError::internal(e.to_string()))?; Ok(axum::response::Json(#response_dto::from(entity))) } } @@ -304,30 +398,39 @@ fn generate_update_handler(entity: &EntityDef) -> TokenStream { 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 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 tag = api_config.tag_or_default(&entity_name_str); - let security_attr = build_security_attr(entity, "delete"); + let security_attr = build_security_attr(entity); let deprecated_attr = build_deprecated_attr(entity); - let utoipa_attr = if security_attr.is_empty() { + 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 = "Entity ID")), + params(("id" = #id_type, Path, description = #id_desc)), responses( - (status = 204, description = "Deleted"), - (status = 404, description = "Not found"), + (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 )] } @@ -337,43 +440,54 @@ fn generate_delete_handler(entity: &EntityDef) -> TokenStream { delete, path = #path, tag = #tag, - params(("id" = #id_type, Path, description = "Entity ID")), + params(("id" = #id_type, Path, description = #id_desc)), responses( - (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 204, description = #success_desc), + (status = 404, description = #not_found_desc), (status = 500, description = "Internal server error") - ), - #security_attr + ) #deprecated_attr )] } }; let doc = format!( - "Delete {} entity by ID.\n\n\ - Generated by entity-derive.", + "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>, - ) -> Result + ) -> masterror::AppResult where - R: #entity_name Repository + 'static, + R: #repo_trait + 'static, { let deleted = repo .delete(id) .await - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| masterror::AppError::internal(e.to_string()))?; if deleted { Ok(axum::http::StatusCode::NO_CONTENT) } else { - Err(axum::http::StatusCode::NOT_FOUND) + Err(masterror::AppError::not_found(#not_found_msg)) } } } @@ -383,31 +497,38 @@ fn generate_delete_handler(entity: &EntityDef) -> TokenStream { 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 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 tag = api_config.tag_or_default(&entity_name_str); - let security_attr = build_security_attr(entity, "list"); + let security_attr = build_security_attr(entity); let deprecated_attr = build_deprecated_attr(entity); - let utoipa_attr = if security_attr.is_empty() { + 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 = "Max items to return"), - ("offset" = Option, Query, description = "Items to skip") + ("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, body = Vec<#response_dto>, description = "List of entities"), + (status = 200, description = #success_desc, body = Vec<#response_dto>), + (status = 401, description = "Authentication required"), (status = 500, description = "Internal server error") - ) + ), + #security_attr #deprecated_attr )] } @@ -418,15 +539,13 @@ fn generate_list_handler(entity: &EntityDef) -> TokenStream { path = #path, tag = #tag, params( - ("limit" = Option, Query, description = "Max items to return"), - ("offset" = Option, Query, description = "Items to skip") + ("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, body = Vec<#response_dto>, description = "List of entities"), - (status = 401, description = "Unauthorized"), + (status = 200, description = #success_desc, body = Vec<#response_dto>), (status = 500, description = "Internal server error") - ), - #security_attr + ) #deprecated_attr )] } @@ -434,18 +553,30 @@ fn generate_list_handler(entity: &EntityDef) -> TokenStream { let doc = format!( "List {} entities with pagination.\n\n\ - Generated by entity-derive.", - entity_name + # Query Parameters\n\n\ + - `limit` - Maximum number of items to return (default: 100)\n\ + - `offset` - Number of items to skip for pagination\n\n\ + # Responses\n\n\ + - `200 OK` - List of {} entities\n\ + {}\ + - `500 Internal Server Error` - Database or server error", + entity_name, + entity_name, + if has_security { + "- `401 Unauthorized` - Authentication required\n" + } else { + "" + } ); quote! { /// Pagination query parameters. - #[derive(Debug, Clone, serde::Deserialize)] + #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] #vis struct PaginationQuery { /// Maximum number of items to return. #[serde(default = "default_limit")] pub limit: i64, - /// Number of items to skip. + /// Number of items to skip for pagination. #[serde(default)] pub offset: i64, } @@ -457,14 +588,14 @@ fn generate_list_handler(entity: &EntityDef) -> TokenStream { #vis async fn #handler_name( axum::extract::State(repo): axum::extract::State>, axum::extract::Query(pagination): axum::extract::Query, - ) -> Result>, axum::http::StatusCode> + ) -> masterror::AppResult>> where - R: #entity_name Repository + 'static, + R: #repo_trait + 'static, { let entities = repo .list(pagination.limit, pagination.offset) .await - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + .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)) } @@ -488,18 +619,22 @@ fn build_item_path(entity: &EntityDef) -> String { } /// Build security attribute for a handler. -fn build_security_attr(entity: &EntityDef, _operation: &str) -> TokenStream { +/// +/// Returns the appropriate security scheme based on the `security` option: +/// - `"cookie"` -> `security(("cookieAuth" = []))` +/// - `"bearer"` -> `security(("bearerAuth" = []))` +/// - `"api_key"` -> `security(("apiKey" = []))` +fn build_security_attr(entity: &EntityDef) -> TokenStream { let api_config = entity.api_config(); if let Some(security) = &api_config.security { let security_name = match security.as_str() { - "bearer" => "bearer_auth", - "api_key" => "api_key", - "admin" => "admin_auth", - "oauth2" => "oauth2", - _ => "bearer_auth" + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" }; - quote! { security(#security_name = []) } + quote! { security((#security_name = [])) } } else { TokenStream::new() } diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index ce88f08..5a4a76b 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -1,27 +1,31 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! OpenAPI struct generation. +//! OpenAPI struct generation for utoipa 5.x. //! //! Generates a struct that implements `utoipa::OpenApi` for Swagger UI -//! integration. +//! integration, with security schemes and paths added via the `Modify` trait. //! //! # Generated Code //! -//! For `User` entity with handlers and commands: +//! For `User` entity with handlers and security: //! //! ```rust,ignore +//! /// OpenAPI modifier for User entity. +//! struct UserApiModifier; +//! +//! impl utoipa::Modify for UserApiModifier { +//! fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { +//! // Add security schemes +//! // Add CRUD paths with documentation +//! } +//! } +//! //! /// OpenAPI documentation for User entity endpoints. //! #[derive(utoipa::OpenApi)] //! #[openapi( -//! paths( -//! create_user, get_user, update_user, delete_user, list_user, -//! register_user, update_email_user -//! ), -//! components(schemas( -//! User, UserResponse, CreateUserRequest, UpdateUserRequest, -//! RegisterUser, UpdateEmailUser -//! )), +//! components(schemas(UserResponse, CreateUserRequest, UpdateUserRequest)), +//! modifiers(&UserApiModifier), //! tags((name = "Users", description = "User management")) //! )] //! pub struct UserApi; @@ -33,7 +37,7 @@ use quote::{format_ident, quote}; use crate::entity::parse::{CommandDef, EntityDef}; -/// Generate the OpenAPI struct. +/// Generate the OpenAPI struct with modifier. pub fn generate(entity: &EntityDef) -> TokenStream { let has_crud = entity.api_config().has_handlers(); let has_commands = !entity.command_defs().is_empty(); @@ -47,6 +51,7 @@ pub fn generate(entity: &EntityDef) -> TokenStream { 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 @@ -55,9 +60,8 @@ pub fn generate(entity: &EntityDef) -> TokenStream { .or_else(|| entity.doc().map(String::from)) .unwrap_or_else(|| format!("{} management", entity_name)); - let handler_names = generate_all_handler_names(entity); let schema_types = generate_all_schema_types(entity); - let security_schemes = generate_security_schemes(api_config.security.as_deref()); + let modifier_impl = generate_modifier(entity, &modifier_struct); let doc = format!( "OpenAPI documentation for {} entity endpoints.\n\n\ @@ -69,107 +73,674 @@ pub fn generate(entity: &EntityDef) -> TokenStream { entity_name, api_struct ); - if security_schemes.is_empty() { - quote! { - #[doc = #doc] - #[derive(utoipa::OpenApi)] - #[openapi( - paths(#handler_names), - components(schemas(#schema_types)), - tags((name = #tag, description = #tag_description)) - )] - #vis struct #api_struct; - } - } else { - quote! { - #[doc = #doc] - #[derive(utoipa::OpenApi)] - #[openapi( - paths(#handler_names), - components( - schemas(#schema_types), - #security_schemes - ), - tags((name = #tag, description = #tag_description)) - )] - #vis struct #api_struct; - } + quote! { + #modifier_impl + + #[doc = #doc] + #[derive(utoipa::OpenApi)] + #[openapi( + components(schemas(#schema_types)), + modifiers(&#modifier_struct), + tags((name = #tag, description = #tag_description)) + )] + #vis struct #api_struct; } } -/// Generate all handler names (CRUD + commands). -fn generate_all_handler_names(entity: &EntityDef) -> TokenStream { - let mut names: Vec = Vec::new(); +/// Generate all schema types (DTOs, commands). +fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { + let entity_name_str = entity.name_str(); + let mut types: Vec = Vec::new(); - // CRUD handlers + // CRUD DTOs if entity.api_config().has_handlers() { - let snake = entity.name_str().to_case(Case::Snake); - names.push(format_ident!("create_{}", snake)); - names.push(format_ident!("get_{}", snake)); - names.push(format_ident!("update_{}", snake)); - names.push(format_ident!("delete_{}", snake)); - names.push(format_ident!("list_{}", snake)); + let response = entity.ident_with("", "Response"); + let create = entity.ident_with("Create", "Request"); + let update = entity.ident_with("Update", "Request"); + types.push(quote! { #response }); + types.push(quote! { #create }); + types.push(quote! { #update }); } - // Command handlers + // Command structs for cmd in entity.command_defs() { - names.push(command_handler_name(entity, cmd)); + let cmd_struct = cmd.struct_name(&entity_name_str); + types.push(quote! { #cmd_struct }); } - quote! { #(#names),* } + quote! { #(#types),* } } -/// Generate all schema types (entity, DTOs, commands). -fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { +/// Generate the modifier struct with Modify implementation. +/// +/// This adds security schemes, common schemas, CRUD paths, and info to the +/// OpenAPI spec. +fn generate_modifier(entity: &EntityDef, modifier_name: &syn::Ident) -> TokenStream { let entity_name = entity.name(); - let entity_name_str = entity.name_str(); - let mut types: Vec = vec![entity_name.clone()]; + let api_config = entity.api_config(); - // CRUD DTOs - if entity.api_config().has_handlers() { - types.push(entity.ident_with("", "Response")); - types.push(entity.ident_with("Create", "Request")); - types.push(entity.ident_with("Update", "Request")); + 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 + } + } } +} - // Command structs - for cmd in entity.command_defs() { - types.push(cmd.struct_name(&entity_name_str)); +/// Generate code to configure OpenAPI info section. +/// +/// Sets title, description, version, license, and contact information. +fn generate_info_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + + // Title: use configured or generate default + let title_code = if let Some(ref title) = api_config.title { + quote! { openapi.info.title = #title.to_string(); } + } else { + TokenStream::new() + }; + + // Description + let description_code = if let Some(ref description) = api_config.description { + quote! { openapi.info.description = Some(#description.to_string()); } + } else if let Some(doc) = entity.doc() { + // Use entity doc comment if no description configured + quote! { openapi.info.description = Some(#doc.to_string()); } + } else { + TokenStream::new() + }; + + // API Version + let version_code = if let Some(ref version) = api_config.api_version { + quote! { openapi.info.version = #version.to_string(); } + } else { + TokenStream::new() + }; + + // License + let license_code = match (&api_config.license, &api_config.license_url) { + (Some(name), Some(url)) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .url(Some(#url)) + .build() + ); + } + } + (Some(name), None) => { + quote! { + openapi.info.license = Some( + info::LicenseBuilder::new() + .name(#name) + .build() + ); + } + } + _ => TokenStream::new() + }; + + // Contact + let has_contact = api_config.contact_name.is_some() + || api_config.contact_email.is_some() + || api_config.contact_url.is_some(); + + let contact_code = if has_contact { + let name = api_config.contact_name.as_deref().unwrap_or(""); + let email = api_config.contact_email.as_deref(); + let url = api_config.contact_url.as_deref(); + + let email_setter = if let Some(e) = email { + quote! { .email(Some(#e)) } + } else { + TokenStream::new() + }; + + let url_setter = if let Some(u) = url { + quote! { .url(Some(#u)) } + } else { + TokenStream::new() + }; + + quote! { + openapi.info.contact = Some( + info::ContactBuilder::new() + .name(Some(#name)) + #email_setter + #url_setter + .build() + ); + } + } else { + TokenStream::new() + }; + + // Deprecated flag + let deprecated_code = if api_config.is_deprecated() { + let version = api_config.deprecated_in.as_deref().unwrap_or("unknown"); + let msg = format!("Deprecated since {}", version); + quote! { + // Mark in description that API is deprecated + if let Some(ref desc) = openapi.info.description { + openapi.info.description = Some(format!("**DEPRECATED**: {}\n\n{}", #msg, desc)); + } else { + openapi.info.description = Some(format!("**DEPRECATED**: {}", #msg)); + } + } + } else { + TokenStream::new() + }; + + quote! { + #title_code + #description_code + #version_code + #license_code + #contact_code + #deprecated_code } +} - quote! { #(#types),* } +/// Generate common schemas (ErrorResponse, PaginationQuery) for the OpenAPI +/// spec. +fn generate_common_schemas_code() -> TokenStream { + quote! { + // Add ErrorResponse schema for error responses + if let Some(components) = openapi.components.as_mut() { + // ErrorResponse schema (RFC 7807 Problem Details) + let error_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("ErrorResponse")) + .description(Some("Error response following RFC 7807 Problem Details")) + .property("type", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A URI reference that identifies the problem type")) + .example(Some(serde_json::json!("https://errors.example.com/not-found"))) + .build()) + .required("type") + .property("title", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A short, human-readable summary of the problem")) + .example(Some(serde_json::json!("Resource not found"))) + .build()) + .required("title") + .property("status", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("HTTP status code")) + .example(Some(serde_json::json!(404))) + .build()) + .required("status") + .property("detail", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("A human-readable explanation specific to this occurrence")) + .example(Some(serde_json::json!("User with ID '123' was not found"))) + .build()) + .property("code", schema::ObjectBuilder::new() + .schema_type(schema::Type::String) + .description(Some("Application-specific error code")) + .example(Some(serde_json::json!("NOT_FOUND"))) + .build()) + .build(); + + components.schemas.insert("ErrorResponse".to_string(), error_schema.into()); + + // PaginationQuery schema + let pagination_schema = schema::ObjectBuilder::new() + .schema_type(schema::Type::Object) + .title(Some("PaginationQuery")) + .description(Some("Query parameters for paginated list endpoints")) + .property("limit", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Maximum number of items to return")) + .default(Some(serde_json::json!(100))) + .minimum(Some(1.0)) + .maximum(Some(1000.0)) + .build()) + .property("offset", schema::ObjectBuilder::new() + .schema_type(schema::Type::Integer) + .description(Some("Number of items to skip for pagination")) + .default(Some(serde_json::json!(0))) + .minimum(Some(0.0)) + .build()) + .build(); + + components.schemas.insert("PaginationQuery".to_string(), pagination_schema.into()); + } + } } -/// Generate security schemes if configured. -fn generate_security_schemes(security: Option<&str>) -> TokenStream { - match security { - Some("bearer") => { +/// Generate security scheme code for the Modify implementation. +fn generate_security_code(security: Option<&str>) -> TokenStream { + let Some(security) = security else { + return TokenStream::new(); + }; + + let (scheme_name, scheme_impl) = match security { + "cookie" => ( + "cookieAuth", quote! { - security_schemes( - ("bearer_auth" = ( - ty = Http, - scheme = "bearer", - bearer_format = "JWT" - )) + security::SecurityScheme::ApiKey( + security::ApiKey::Cookie( + security::ApiKeyValue::with_description( + "token", + "JWT token stored in HTTP-only cookie" + ) + ) ) } - } - Some("api_key") => { + ), + "bearer" => ( + "bearerAuth", quote! { - security_schemes( - ("api_key" = ( - ty = ApiKey, - in = "header", - name = "X-API-Key" - )) + security::SecurityScheme::Http( + security::HttpBuilder::new() + .scheme(security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("JWT token in Authorization header")) + .build() ) } + ), + "api_key" => ( + "apiKey", + quote! { + security::SecurityScheme::ApiKey( + security::ApiKey::Header( + security::ApiKeyValue::with_description( + "X-API-Key", + "API key for service-to-service authentication" + ) + ) + ) + } + ), + _ => return TokenStream::new() + }; + + quote! { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme(#scheme_name, #scheme_impl); } - _ => TokenStream::new() } } +/// Generate code to add CRUD paths to OpenAPI. +/// +/// Only generates paths for enabled handlers based on `HandlerConfig`. +fn generate_paths_code(entity: &EntityDef) -> TokenStream { + let api_config = entity.api_config(); + let handlers = api_config.handlers(); + let entity_name = entity.name(); + let entity_name_str = entity.name_str(); + let id_field = entity.id_field(); + let id_type = &id_field.ty; + + let tag = api_config.tag_or_default(&entity_name_str); + let collection_path = build_collection_path(entity); + let item_path = build_item_path(entity); + + let response_schema = entity.ident_with("", "Response"); + let create_schema = entity.ident_with("Create", "Request"); + let update_schema = entity.ident_with("Update", "Request"); + + // Schema names for $ref - just the name, not full path + let response_ref = response_schema.to_string(); + let create_ref = create_schema.to_string(); + let update_ref = update_schema.to_string(); + + // Security requirement + let security_req = if let Some(security) = &api_config.security { + let scheme_name = match security.as_str() { + "cookie" => "cookieAuth", + "bearer" => "bearerAuth", + "api_key" => "apiKey", + _ => "cookieAuth" + }; + quote! { + Some(vec![security::SecurityRequirement::new::<_, _, &str>(#scheme_name, [])]) + } + } else { + quote! { None } + }; + + // ID parameter type (only needed for item paths) + let needs_id_param = handlers.get || handlers.update || handlers.delete; + let id_type_str = quote!(#id_type).to_string().replace(' ', ""); + let id_schema_type = if id_type_str.contains("Uuid") { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .format(Some(schema::SchemaFormat::Custom("uuid".into()))) + .build() + } + } else { + quote! { + ObjectBuilder::new() + .schema_type(schema::Type::String) + .build() + } + }; + + let create_op_id = format!("create_{}", entity_name_str.to_case(Case::Snake)); + let get_op_id = format!("get_{}", entity_name_str.to_case(Case::Snake)); + let update_op_id = format!("update_{}", entity_name_str.to_case(Case::Snake)); + let delete_op_id = format!("delete_{}", entity_name_str.to_case(Case::Snake)); + let list_op_id = format!("list_{}", entity_name_str.to_case(Case::Snake)); + + let create_summary = format!("Create a new {}", entity_name); + let get_summary = format!("Get {} by ID", entity_name); + let update_summary = format!("Update {} by ID", entity_name); + let delete_summary = format!("Delete {} by ID", entity_name); + let list_summary = format!("List all {}", entity_name); + + let create_desc = format!("Creates a new {} entity", entity_name); + let get_desc = format!("Retrieves a {} by its unique identifier", entity_name); + let update_desc = format!("Updates an existing {} by ID", entity_name); + let delete_desc = format!("Deletes a {} by ID", entity_name); + let list_desc = format!("Returns a paginated list of {} entities", entity_name); + + let id_param_desc = format!("{} unique identifier", entity_name); + let created_desc = format!("{} created successfully", entity_name); + let found_desc = format!("{} found", entity_name); + let updated_desc = format!("{} updated successfully", entity_name); + let deleted_desc = format!("{} deleted successfully", entity_name); + let list_desc_resp = format!("List of {} entities", entity_name); + let not_found_desc = format!("{} not found", entity_name); + + // Common code: error helper, params, security + let common_code = quote! { + // Helper to build error response with ErrorResponse schema + let error_response = |desc: &str| -> response::Response { + response::ResponseBuilder::new() + .description(desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name("ErrorResponse"))) + .build() + ) + .build() + }; + + // Security requirements + let security_req: Option> = #security_req; + }; + + // ID parameter (only if needed) + let id_param_code = if needs_id_param { + quote! { + let id_param = path::ParameterBuilder::new() + .name("id") + .parameter_in(path::ParameterIn::Path) + .required(utoipa::openapi::Required::True) + .description(Some(#id_param_desc)) + .schema(Some(#id_schema_type)) + .build(); + } + } else { + TokenStream::new() + }; + + // CREATE handler + let create_code = if handlers.create { + quote! { + let create_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#create_op_id)) + .tag(#tag) + .summary(Some(#create_summary)) + .description(Some(#create_desc)) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Request body")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#create_ref))) + .build() + ) + .build() + )) + .response("201", + response::ResponseBuilder::new() + .description(#created_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Post], create_op); + } + } else { + TokenStream::new() + }; + + // LIST handler + let list_code = if handlers.list { + quote! { + let limit_param = path::ParameterBuilder::new() + .name("limit") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Maximum number of items to return (default: 100)")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let offset_param = path::ParameterBuilder::new() + .name("offset") + .parameter_in(path::ParameterIn::Query) + .required(utoipa::openapi::Required::False) + .description(Some("Number of items to skip for pagination")) + .schema(Some(ObjectBuilder::new().schema_type(schema::Type::Integer).build())) + .build(); + + let list_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#list_op_id)) + .tag(#tag) + .summary(Some(#list_summary)) + .description(Some(#list_desc)) + .parameter(limit_param) + .parameter(offset_param) + .response("200", + response::ResponseBuilder::new() + .description(#list_desc_resp) + .content("application/json", + content::ContentBuilder::new() + .schema(Some( + schema::ArrayBuilder::new() + .items(Ref::from_schema_name(#response_ref)) + .build() + )) + .build() + ) + .build() + ) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#collection_path, vec![path::HttpMethod::Get], list_op); + } + } else { + TokenStream::new() + }; + + // GET handler + let get_code = if handlers.get { + quote! { + let get_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#get_op_id)) + .tag(#tag) + .summary(Some(#get_summary)) + .description(Some(#get_desc)) + .parameter(id_param.clone()) + .response("200", + response::ResponseBuilder::new() + .description(#found_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Get], get_op); + } + } else { + TokenStream::new() + }; + + // UPDATE handler + let update_code = if handlers.update { + quote! { + let update_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#update_op_id)) + .tag(#tag) + .summary(Some(#update_summary)) + .description(Some(#update_desc)) + .parameter(id_param.clone()) + .request_body(Some( + request_body::RequestBodyBuilder::new() + .description(Some("Fields to update")) + .required(Some(utoipa::openapi::Required::True)) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#update_ref))) + .build() + ) + .build() + )) + .response("200", + response::ResponseBuilder::new() + .description(#updated_desc) + .content("application/json", + content::ContentBuilder::new() + .schema(Some(Ref::from_schema_name(#response_ref))) + .build() + ) + .build() + ) + .response("400", error_response("Invalid request data")) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Patch], update_op); + } + } else { + TokenStream::new() + }; + + // DELETE handler + let delete_code = if handlers.delete { + quote! { + let delete_op = { + let mut op = path::OperationBuilder::new() + .operation_id(Some(#delete_op_id)) + .tag(#tag) + .summary(Some(#delete_summary)) + .description(Some(#delete_desc)) + .parameter(id_param.clone()) + .response("204", + response::ResponseBuilder::new() + .description(#deleted_desc) + .build() + ) + .response("404", error_response(#not_found_desc)) + .response("500", error_response("Internal server error")); + if let Some(ref sec) = security_req { + op = op.securities(Some(sec.clone())) + .response("401", error_response("Authentication required")); + } + op.build() + }; + openapi.paths.add_path_operation(#item_path, vec![path::HttpMethod::Delete], delete_op); + } + } else { + TokenStream::new() + }; + + quote! { + #common_code + #id_param_code + #create_code + #list_code + #get_code + #update_code + #delete_code + } +} + +/// Build the collection path (e.g., `/users`). +fn build_collection_path(entity: &EntityDef) -> String { + let api_config = entity.api_config(); + let prefix = api_config.full_path_prefix(); + let entity_path = entity.name_str().to_case(Case::Kebab); + + let path = format!("{}/{}s", prefix, entity_path); + path.replace("//", "/") +} + +/// Build the item path (e.g., `/users/{id}`). +fn build_item_path(entity: &EntityDef) -> String { + let collection = build_collection_path(entity); + format!("{}/{{id}}", collection) +} + /// Get command handler function name. +#[allow(dead_code)] fn command_handler_name(entity: &EntityDef, cmd: &CommandDef) -> syn::Ident { let entity_snake = entity.name_str().to_case(Case::Snake); let cmd_snake = cmd.name.to_string().to_case(Case::Snake); @@ -195,8 +766,7 @@ mod tests { let tokens = generate(&entity); let output = tokens.to_string(); assert!(output.contains("UserApi")); - assert!(output.contains("create_user")); - assert!(output.contains("get_user")); + assert!(output.contains("UserApiModifier")); assert!(output.contains("UserResponse")); assert!(output.contains("CreateUserRequest")); } @@ -213,7 +783,23 @@ mod tests { let entity = EntityDef::from_derive_input(&input).unwrap(); let tokens = generate(&entity); let output = tokens.to_string(); - assert!(output.contains("bearer_auth")); + 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] @@ -229,4 +815,32 @@ mod tests { 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}"); + } } diff --git a/crates/entity-derive-impl/src/entity/api/router.rs b/crates/entity-derive-impl/src/entity/api/router.rs index bfa19c8..f0445a7 100644 --- a/crates/entity-derive-impl/src/entity/api/router.rs +++ b/crates/entity-derive-impl/src/entity/api/router.rs @@ -95,8 +95,9 @@ fn generate_crud_router(entity: &EntityDef) -> TokenStream { } } -/// Generate CRUD route definitions. +/// 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); @@ -107,9 +108,51 @@ fn generate_crud_routes(entity: &EntityDef) -> TokenStream { let delete_handler = format_ident!("delete_{}", snake); let list_handler = format_ident!("list_{}", snake); + // Build collection route methods (POST, GET) + let mut collection_methods = Vec::new(); + if handlers.create { + collection_methods.push(quote! { post(#create_handler::) }); + } + if handlers.list { + collection_methods.push(quote! { get(#list_handler::) }); + } + + // Build item route methods (GET, PATCH, DELETE) + let mut item_methods = Vec::new(); + if handlers.get { + item_methods.push(quote! { get(#get_handler::) }); + } + if handlers.update { + item_methods.push(quote! { patch(#update_handler::) }); + } + if handlers.delete { + item_methods.push(quote! { delete(#delete_handler::) }); + } + + // Generate routes only for non-empty method lists + let collection_route = if !collection_methods.is_empty() { + let first = &collection_methods[0]; + let rest: Vec<_> = collection_methods.iter().skip(1).collect(); + quote! { + .route(#collection_path, axum::routing::#first #(.#rest)*) + } + } else { + TokenStream::new() + }; + + let item_route = if !item_methods.is_empty() { + let first = &item_methods[0]; + let rest: Vec<_> = item_methods.iter().skip(1).collect(); + quote! { + .route(#item_path, axum::routing::#first #(.#rest)*) + } + } else { + TokenStream::new() + }; + quote! { - .route(#collection_path, axum::routing::post(#create_handler::).get(#list_handler::)) - .route(#item_path, axum::routing::get(#get_handler::).patch(#update_handler::).delete(#delete_handler::)) + #collection_route + #item_route } } @@ -123,10 +166,10 @@ fn build_crud_collection_path(entity: &EntityDef) -> String { path.replace("//", "/") } -/// Build CRUD item path (e.g., `/api/v1/users/:id`). +/// 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) + format!("{}/{{id}}", collection) } /// Generate commands router for command handler. @@ -192,7 +235,7 @@ fn generate_command_route(entity: &EntityDef, cmd: &CommandDef) -> TokenStream { } } -/// Build command path (uses :id instead of {id}). +/// 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(); @@ -200,7 +243,7 @@ fn build_command_path(entity: &EntityDef, cmd: &CommandDef) -> String { let cmd_path = cmd.name.to_string().to_case(Case::Kebab); let path = if cmd.requires_id { - format!("{}/{}s/:id/{}", prefix, entity_path, cmd_path) + format!("{}/{}s/{{id}}/{}", prefix, entity_path, cmd_path) } else { format!("{}/{}s/{}", prefix, entity_path, cmd_path) }; @@ -254,7 +297,7 @@ mod tests { }; let entity = EntityDef::from_derive_input(&input).unwrap(); let path = build_crud_item_path(&entity); - assert_eq!(path, "/users/:id"); + assert_eq!(path, "/users/{id}"); } #[test] diff --git a/crates/entity-derive-impl/src/entity/parse/api.rs b/crates/entity-derive-impl/src/entity/parse/api.rs index a75e91f..ec7d419 100644 --- a/crates/entity-derive-impl/src/entity/parse/api.rs +++ b/crates/entity-derive-impl/src/entity/parse/api.rs @@ -30,6 +30,44 @@ use syn::Ident; +/// Handler configuration for selective CRUD generation. +/// +/// # Syntax +/// +/// - `handlers` - enables all handlers +/// - `handlers(create, get, list)` - enables specific handlers +#[derive(Debug, Clone, Default)] +pub struct HandlerConfig { + /// Generate create handler (POST /collection). + pub create: bool, + /// Generate get handler (GET /collection/{id}). + pub get: bool, + /// Generate update handler (PATCH /collection/{id}). + pub update: bool, + /// Generate delete handler (DELETE /collection/{id}). + pub delete: bool, + /// Generate list handler (GET /collection). + pub list: bool +} + +impl HandlerConfig { + /// Create config with all handlers enabled. + pub fn all() -> Self { + Self { + create: true, + get: true, + update: true, + delete: true, + list: true + } + } + + /// Check if any handler is enabled. + pub fn any(&self) -> bool { + self.create || self.get || self.update || self.delete || self.list + } +} + /// API configuration parsed from `#[entity(api(...))]`. /// /// Controls HTTP handler generation and OpenAPI documentation. @@ -75,15 +113,56 @@ pub struct ApiConfig { /// Marks all endpoints with `deprecated = true` in OpenAPI. pub deprecated_in: Option, - /// Generate CRUD handlers for REST API. + /// 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. /// - /// When `true`, generates: - /// - `create_{entity}` - POST handler - /// - `get_{entity}` - GET by ID handler - /// - `update_{entity}` - PATCH handler - /// - `delete_{entity}` - DELETE handler - /// - `list_{entity}` - GET list handler - pub handlers: bool + /// 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 { @@ -131,9 +210,14 @@ impl ApiConfig { self.deprecated_in.is_some() } - /// Check if CRUD handlers should be generated. + /// Check if any CRUD handler should be generated. pub fn has_handlers(&self) -> bool { - self.handlers + self.handlers.any() + } + + /// Get handler configuration. + pub fn handlers(&self) -> &HandlerConfig { + &self.handlers } /// Get security scheme for a command. @@ -225,21 +309,84 @@ pub fn parse_api_config(meta: &syn::Meta) -> syn::Result { config.deprecated_in = Some(value.value()); } "handlers" => { - // Support both `handlers` (flag) and `handlers = true` + // Support: + // - `handlers` - all handlers + // - `handlers = true/false` - all or none + // - `handlers(create, get, list)` - specific handlers if nested.input.peek(syn::Token![=]) { let _: syn::Token![=] = nested.input.parse()?; let value: syn::LitBool = nested.input.parse()?; - config.handlers = value.value(); + 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 = true; + 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", + security, public, version, deprecated_in, handlers, title, description, \ + api_version, license, license_url, contact_name, contact_email, contact_url", ident_str ) )); @@ -392,4 +539,38 @@ mod tests { let config = parse_test_config(r#"api(tag = "Users")"#); assert!(!config.has_handlers()); } + + #[test] + fn parse_handlers_selective() { + let config = parse_test_config(r#"api(tag = "Users", handlers(create, get, list))"#); + assert!(config.has_handlers()); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(config.handlers().list); + } + + #[test] + fn parse_handlers_single() { + let config = parse_test_config(r#"api(tag = "Users", handlers(get))"#); + assert!(config.has_handlers()); + assert!(!config.handlers().create); + assert!(config.handlers().get); + assert!(!config.handlers().update); + assert!(!config.handlers().delete); + assert!(!config.handlers().list); + } + + #[test] + fn parse_handlers_all_explicit() { + let config = parse_test_config( + r#"api(tag = "Users", handlers(create, get, update, delete, list))"# + ); + assert!(config.handlers().create); + assert!(config.handlers().get); + assert!(config.handlers().update); + assert!(config.handlers().delete); + assert!(config.handlers().list); + } } diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index f200972..5fb2d38 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -16,6 +16,7 @@ validate = [] [dependencies] entity-derive = { path = "../../crates/entity-derive", features = ["postgres", "api"] } +masterror = { version = "0.27", features = ["axum", "openapi"] } axum = "0.8" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 55ce2c9..f71c5db 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,38 +1,59 @@ // SPDX-FileCopyrightText: 2025-2026 RAprogramm // SPDX-License-Identifier: MIT -//! Axum CRUD Example with entity-derive +//! Basic CRUD Example with Generated Handlers //! //! Demonstrates full CRUD operations using: -//! - entity-derive for code generation +//! - entity-derive for code generation including HTTP handlers //! - Axum for HTTP routing //! - sqlx for PostgreSQL access //! - utoipa for OpenAPI docs +//! +//! Key features: +//! - `api(tag = "Users", handlers)` generates CRUD handlers automatically +//! - `user_router()` provides ready-to-use axum Router +//! - `UserApi` provides OpenAPI documentation use std::sync::Arc; -use axum::{ - Json, Router, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, post}, -}; +use axum::Router; use chrono::{DateTime, Utc}; use entity_derive::Entity; -use serde::Deserialize; use sqlx::PgPool; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; // ============================================================================ -// Entity Definition +// Entity Definition with Generated API // ============================================================================ -/// User entity with full CRUD support. +/// User entity with full CRUD support and cookie authentication. +/// +/// The `api(tag = "Users", security = "cookie", handlers)` attribute generates: +/// - `create_user()` - POST /users (requires auth) +/// - `get_user()` - GET /users/{id} (requires auth) +/// - `update_user()` - PATCH /users/{id} (requires auth) +/// - `delete_user()` - DELETE /users/{id} (requires auth) +/// - `list_user()` - GET /users (requires auth) +/// - `user_router()` - axum Router with all routes +/// - `UserApi` - OpenAPI documentation with security scheme #[derive(Debug, Clone, Entity)] -#[entity(table = "users", schema = "public")] +#[entity( + table = "users", + schema = "public", + api( + tag = "Users", + security = "cookie", + handlers, + title = "User Service API", + description = "RESTful API for user management with cookie-based authentication", + api_version = "1.0.0", + license = "MIT", + contact_name = "API Support", + contact_email = "support@example.com" + ) +)] pub struct User { /// Unique identifier (UUID v7). #[id] @@ -61,203 +82,25 @@ pub struct User { pub updated_at: DateTime, } -// ============================================================================ -// Application State -// ============================================================================ - -#[derive(Clone)] -struct AppState { - pool: Arc, -} - -impl AppState { - fn new(pool: PgPool) -> Self { - Self { - pool: Arc::new(pool), - } - } - - fn repo(&self) -> &PgPool { - &self.pool - } -} - -// ============================================================================ -// Query Parameters -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct ListParams { - #[serde(default = "default_limit")] - limit: i64, - #[serde(default)] - offset: i64, -} - -fn default_limit() -> i64 { - 20 -} - -// ============================================================================ -// Error Handling -// ============================================================================ - -enum AppError { - NotFound, - Database(sqlx::Error), -} - -impl From for AppError { - fn from(err: sqlx::Error) -> Self { - match err { - sqlx::Error::RowNotFound => Self::NotFound, - _ => Self::Database(err), - } - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - match self { - Self::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), - Self::Database(e) => { - tracing::error!("Database error: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() - } - } - } -} - -// ============================================================================ -// HTTP Handlers -// ============================================================================ - -/// Create a new user. -#[utoipa::path( - post, - path = "/users", - request_body = CreateUserRequest, - responses( - (status = 201, description = "User created", body = UserResponse), - (status = 500, description = "Internal error"), - ) -)] -async fn create_user( - State(state): State, - Json(dto): Json, -) -> Result { - let user = state.repo().create(dto).await?; - Ok((StatusCode::CREATED, Json(UserResponse::from(user)))) -} - -/// Get user by ID. -#[utoipa::path( - get, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - responses( - (status = 200, description = "User found", body = UserResponse), - (status = 404, description = "User not found"), - ) -)] -async fn get_user( - State(state): State, - Path(id): Path, -) -> Result { - let user = state.repo().find_by_id(id).await?.ok_or(AppError::NotFound)?; - Ok(Json(UserResponse::from(user))) -} - -/// Update user by ID. -#[utoipa::path( - patch, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - request_body = UpdateUserRequest, - responses( - (status = 200, description = "User updated", body = UserResponse), - (status = 404, description = "User not found"), - ) -)] -async fn update_user( - State(state): State, - Path(id): Path, - Json(dto): Json, -) -> Result { - let user = state.repo().update(id, dto).await?; - Ok(Json(UserResponse::from(user))) -} - -/// Delete user by ID. -#[utoipa::path( - delete, - path = "/users/{id}", - params(("id" = Uuid, Path, description = "User ID")), - responses( - (status = 204, description = "User deleted"), - (status = 404, description = "User not found"), - ) -)] -async fn delete_user( - State(state): State, - Path(id): Path, -) -> Result { - let deleted = state.repo().delete(id).await?; - if deleted { - Ok(StatusCode::NO_CONTENT) - } else { - Err(AppError::NotFound) - } -} - -/// List users with pagination. -#[utoipa::path( - get, - path = "/users", - params( - ("limit" = Option, Query, description = "Max results"), - ("offset" = Option, Query, description = "Skip results"), - ), - responses( - (status = 200, description = "List of users", body = Vec), - ) -)] -async fn list_users( - State(state): State, - Query(params): Query, -) -> Result { - let users = state.repo().list(params.limit, params.offset).await?; - let responses: Vec = users.into_iter().map(UserResponse::from).collect(); - Ok(Json(responses)) -} - -// ============================================================================ -// OpenAPI Documentation -// ============================================================================ - -#[derive(OpenApi)] -#[openapi( - paths( - create_user, - get_user, - update_user, - delete_user, - list_users, - ), - components(schemas(CreateUserRequest, UpdateUserRequest, UserResponse)) -)] -struct ApiDoc; - // ============================================================================ // Router Setup // ============================================================================ -fn app(state: AppState) -> Router { +/// Create the application router. +/// +/// Uses the generated `user_router()` function which includes: +/// - POST /users - create user +/// - GET /users - list users +/// - GET /users/{id} - get user +/// - PATCH /users/{id} - update user +/// - DELETE /users/{id} - delete user +fn app(pool: Arc) -> Router { Router::new() - .route("/users", post(create_user).get(list_users)) - .route("/users/{id}", get(get_user).patch(update_user).delete(delete_user)) - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) - .with_state(state) + // Use the generated router for CRUD operations + .merge(user_router::()) + // Add Swagger UI using generated OpenAPI struct + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", UserApi::openapi())) + .with_state(pool) } // ============================================================================ @@ -267,13 +110,11 @@ fn app(state: AppState) -> Router { #[tokio::main] async fn main() { tracing_subscriber::fmt() - .with_env_filter("axum_crud_example=debug,tower_http=debug") + .with_env_filter("example_basic=debug,tower_http=debug") .init(); - let database_url = - std::env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://postgres:postgres@localhost:5432/entity_example".to_string() - }); + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/entity_example".into()); let pool = PgPool::connect(&database_url) .await @@ -284,12 +125,18 @@ async fn main() { .await .expect("Failed to run migrations"); - let state = AppState::new(pool); - let app = app(state); + let app = app(Arc::new(pool)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); tracing::info!("Listening on http://localhost:3000"); tracing::info!("Swagger UI: http://localhost:3000/swagger-ui"); + tracing::info!(""); + tracing::info!("Try these endpoints:"); + tracing::info!(" POST /users - Create a user"); + tracing::info!(" GET /users - List users"); + tracing::info!(" GET /users/{{id}} - Get user by ID"); + tracing::info!(" PATCH /users/{{id}} - Update user"); + tracing::info!(" DELETE /users/{{id}} - Delete user"); axum::serve(listener, app).await.unwrap(); } From dba1dcf08d271f844ecb8b99e8c27130c0e6f73b Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 13:17:15 +0700 Subject: [PATCH 3/4] #67 fix(openapi): register only schemas for enabled handlers - OpenAPI now only includes CreateRequest if handlers.create is enabled - OpenAPI now only includes UpdateRequest if handlers.update is enabled - Response schema is always included (used by all read operations) - Added 3 tests for selective handlers schema registration --- .../src/entity/api/openapi.rs | 88 +++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/crates/entity-derive-impl/src/entity/api/openapi.rs b/crates/entity-derive-impl/src/entity/api/openapi.rs index 5a4a76b..c9193b9 100644 --- a/crates/entity-derive-impl/src/entity/api/openapi.rs +++ b/crates/entity-derive-impl/src/entity/api/openapi.rs @@ -88,18 +88,30 @@ pub fn generate(entity: &EntityDef) -> TokenStream { } /// Generate all schema types (DTOs, commands). +/// +/// Only registers schemas for enabled handlers to keep OpenAPI spec clean. fn generate_all_schema_types(entity: &EntityDef) -> TokenStream { let entity_name_str = entity.name_str(); let mut types: Vec = Vec::new(); - // CRUD DTOs - if entity.api_config().has_handlers() { + // CRUD DTOs - only include schemas for enabled handlers + let handlers = entity.api_config().handlers(); + if handlers.any() { + // Response is always needed (for get, list, create, update responses) let response = entity.ident_with("", "Response"); - let create = entity.ident_with("Create", "Request"); - let update = entity.ident_with("Update", "Request"); types.push(quote! { #response }); - types.push(quote! { #create }); - types.push(quote! { #update }); + + // CreateRequest only if create handler is enabled + if handlers.create { + let create = entity.ident_with("Create", "Request"); + types.push(quote! { #create }); + } + + // UpdateRequest only if update handler is enabled + if handlers.update { + let update = entity.ident_with("Update", "Request"); + types.push(quote! { #update }); + } } // Command structs @@ -843,4 +855,68 @@ mod tests { let path = build_item_path(&entity); assert_eq!(path, "/users/{id}"); } + + #[test] + fn selective_handlers_schemas_get_list_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(get, list)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // Response should be included (needed for get/list) + assert!(output.contains("UserResponse")); + // CreateRequest should NOT be included (no create handler) + assert!(!output.contains("CreateUserRequest")); + // UpdateRequest should NOT be included (no update handler) + assert!(!output.contains("UpdateUserRequest")); + } + + #[test] + fn selective_handlers_schemas_create_only() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // Response should be included (create returns response) + assert!(output.contains("UserResponse")); + // CreateRequest should be included + assert!(output.contains("CreateUserRequest")); + // UpdateRequest should NOT be included + assert!(!output.contains("UpdateUserRequest")); + } + + #[test] + fn selective_handlers_all_schemas() { + let input: syn::DeriveInput = syn::parse_quote! { + #[entity(table = "users", api(tag = "Users", handlers(create, update)))] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub name: String, + } + }; + let entity = EntityDef::from_derive_input(&input).unwrap(); + let tokens = generate(&entity); + let output = tokens.to_string(); + // All schemas should be included + assert!(output.contains("UserResponse")); + assert!(output.contains("CreateUserRequest")); + assert!(output.contains("UpdateUserRequest")); + } } From 459d5baf2224342935625ec4f0ef8fa0a154e212 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 7 Jan 2026 13:24:18 +0700 Subject: [PATCH 4/4] #67 fix(ci): show green status for non-dependabot PRs Changed workflow to run for all PRs but skip auto-merge steps for non-dependabot PRs. This shows green (success) instead of gray (skipped) status in the PR checks. --- .github/workflows/dependabot-automerge.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 }}