diff --git a/README.md b/README.md index 7c81367..91eb41f 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ tracing-subscriber = "0.3" | **PATCH Semantics** | Dynamic UPDATE SET; double-`Option` distinguishes "leave" from "set NULL" | | **Optimistic Locking** | `#[version]` — guarded, auto-incremented version column | | **Typed Constraint Errors** | `typed_constraints` — violations resolved to `ConstraintError` with field info | +| **OpenAPI Overrides** | `#[schema(...)]` forwarded verbatim to the generated create/response DTOs and joined views | | **Embedded Value Objects** | `#[embed(prefix, fields(...))]` — structs flattened to prefixed columns | | **Relations** | `#[belongs_to]`, `#[has_many]` and many-to-many via `through = "junction"` | | **Ownership Scoping** | `#[owner]` generates `find_by_id_scoped` / `list_by_owner` / `update_scoped` / `delete_scoped` | @@ -251,6 +252,7 @@ tracing-subscriber = "0.3" #[has_many(E, through = "t")] // Many-to-many via junction table #[projection(Name: fields)] // Partial view #[scope(name: col_a | col_b)] // list_{name} over an OR group of columns +#[schema(value_type = T)] // OpenAPI type override, forwarded to the generated structs ``` ### Transactional Outbox diff --git a/crates/entity-derive-impl/src/entity/dto.rs b/crates/entity-derive-impl/src/entity/dto.rs index 8fc9117..aa12ab3 100644 --- a/crates/entity-derive-impl/src/entity/dto.rs +++ b/crates/entity-derive-impl/src/entity/dto.rs @@ -66,8 +66,10 @@ fn generate_create_dto(entity: &EntityDef) -> TokenStream { let n = f.name(); let t = f.ty(); let garde = garde_attr(f, 0); + let schema = f.schema_attr(); quote! { #garde + #schema pub #n: #t } }); @@ -310,7 +312,11 @@ fn generate_response_dto(entity: &EntityDef) -> TokenStream { let field_defs = fields.iter().map(|f| { let n = f.name(); let t = f.ty(); - quote! { pub #n: #t } + let schema = f.schema_attr(); + quote! { + #schema + pub #n: #t + } }); let extra_derives_api = if cfg!(feature = "api") { diff --git a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs index 1d08dc6..e51a48c 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -410,7 +410,8 @@ fn expand_embed_fields(fields: &mut Vec) -> darling::Result<()> { doc: None, validation: ValidationConfig::default(), example: None, - map: MapConfig::default() + map: MapConfig::default(), + schema: None }); } insertions.push((idx + 1, synthetic)); diff --git a/crates/entity-derive-impl/src/entity/parse/field.rs b/crates/entity-derive-impl/src/entity/parse/field.rs index 9e0679d..cb4b36b 100644 --- a/crates/entity-derive-impl/src/entity/parse/field.rs +++ b/crates/entity-derive-impl/src/entity/parse/field.rs @@ -31,6 +31,7 @@ mod example; mod expose; mod filter; pub mod map; +mod schema; mod storage; mod validation; @@ -214,7 +215,13 @@ pub struct FieldDef { /// /// Parsed from `#[map(...)]` attributes for transforming fields /// in the `From for Entity` implementation. - pub map: MapConfig + pub map: MapConfig, + + /// `OpenAPI` schema override carried from `#[schema(...)]`. + /// + /// The token list utoipa is given verbatim on every generated + /// struct that derives `utoipa::ToSchema`. + pub schema: Option } impl FieldDef { @@ -234,6 +241,7 @@ impl FieldDef { let doc = extract_doc_comments(&field.attrs); let validation = validation::parse_validation_attrs(&field.attrs); let example = example::parse_example_attr(&field.attrs); + let schema = schema::parse_schema_attr(&field.attrs); let mut sortable = false; let mut embed: Option = None; @@ -286,10 +294,24 @@ impl FieldDef { doc, validation, example, - map + map, + schema }) } + /// The `#[schema(...)]` attribute to place on a generated field. + /// + /// Empty unless the entity declared one and the `api` feature is on: + /// with it off nothing generated derives `utoipa::ToSchema`, and the + /// attribute would land on a struct that cannot interpret it. + #[must_use] + pub fn schema_attr(&self) -> proc_macro2::TokenStream { + match &self.schema { + Some(tokens) if cfg!(feature = "api") => quote::quote! { #[schema(#tokens)] }, + _ => proc_macro2::TokenStream::new() + } + } + /// Get the field name as an identifier. #[must_use] pub const fn name(&self) -> &Ident { diff --git a/crates/entity-derive-impl/src/entity/parse/field/schema.rs b/crates/entity-derive-impl/src/entity/parse/field/schema.rs new file mode 100644 index 0000000..3e24fe2 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/field/schema.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! `OpenAPI` schema overrides carried from the entity to the generated +//! structs. +//! +//! A column whose Rust type says little to `OpenAPI` — `serde_json::Value` +//! for a JSONB column, a newtype over a primitive — documents as a +//! free-form object. utoipa fixes that with `#[schema(...)]` on the +//! field; this module carries such an attribute from the entity onto +//! every generated struct that derives `utoipa::ToSchema`. +//! +//! ```rust,ignore +//! #[field(create, response)] +//! #[schema(value_type = Option)] +//! pub size_cm: Option, +//! ``` +//! +//! The tokens are forwarded verbatim: what utoipa accepts inside +//! `#[schema(...)]` is what works here, and nothing is validated twice. +//! With the `api` feature off no generated struct derives `ToSchema`, so +//! the attribute is dropped rather than emitted onto a struct that +//! cannot interpret it. + +use proc_macro2::TokenStream; +use syn::{Attribute, Meta}; + +/// Extract the token list of a field's `#[schema(...)]` attribute. +/// +/// Returns `None` when the field carries no such attribute, or carries +/// one in a form utoipa does not use (`#[schema]`, `#[schema = ...]`). +pub fn parse_schema_attr(attrs: &[Attribute]) -> Option { + attrs.iter().find_map(|attr| match &attr.meta { + Meta::List(list) if list.path.is_ident("schema") => Some(list.tokens.clone()), + _ => None + }) +} + +#[cfg(test)] +mod tests { + use quote::quote; + use syn::{Field, parse_quote}; + + use super::parse_schema_attr; + + fn field(tokens: TokenStreamAlias) -> Field { + Field::parse_named.parse2(tokens).expect("field parses") + } + + type TokenStreamAlias = proc_macro2::TokenStream; + use syn::parse::Parser as _; + + #[test] + fn extracts_the_token_list() { + let f = field(quote! { + #[schema(value_type = Option)] + pub size_cm: Option + }); + + let tokens = parse_schema_attr(&f.attrs).expect("attribute found"); + + assert_eq!(tokens.to_string(), "value_type = Option < SizeCm >"); + } + + #[test] + fn absent_attribute_yields_none() { + let f: Field = parse_quote! { pub name: String }; + + assert!(parse_schema_attr(&f.attrs).is_none()); + } + + #[test] + fn path_and_name_value_forms_are_ignored() { + let bare = field(quote! { + #[schema] + pub name: String + }); + let named = field(quote! { + #[schema = "text"] + pub name: String + }); + + assert!(parse_schema_attr(&bare.attrs).is_none()); + assert!(parse_schema_attr(&named.attrs).is_none()); + } +} diff --git a/crates/entity-derive-impl/src/entity/view.rs b/crates/entity-derive-impl/src/entity/view.rs index b7d7ad1..d5b9973 100644 --- a/crates/entity-derive-impl/src/entity/view.rs +++ b/crates/entity-derive-impl/src/entity/view.rs @@ -48,7 +48,11 @@ pub fn generate(entity: &EntityDef) -> TokenStream { .map(|f| { let name = f.name(); let ty = f.ty(); - quote! { pub #name: #ty } + let schema = f.schema_attr(); + quote! { + #schema + pub #name: #ty + } }) .collect(); let join_fields: Vec = entity diff --git a/crates/entity-derive-impl/src/lib.rs b/crates/entity-derive-impl/src/lib.rs index c67536e..c93ccef 100644 --- a/crates/entity-derive-impl/src/lib.rs +++ b/crates/entity-derive-impl/src/lib.rs @@ -460,7 +460,7 @@ use proc_macro::TokenStream; Entity, attributes( entity, field, id, auto, owner, sort, version, embed, validate, belongs_to, has_many, - projection, filter, command, example, column, map, join, transition, scope + projection, filter, command, example, column, map, join, transition, scope, schema ) )] pub fn derive_entity(input: TokenStream) -> TokenStream { diff --git a/crates/entity-derive/tests/cases/pass/schema_override.rs b/crates/entity-derive/tests/cases/pass/schema_override.rs new file mode 100644 index 0000000..0748632 --- /dev/null +++ b/crates/entity-derive/tests/cases/pass/schema_override.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +use entity_derive::Entity; +use uuid::Uuid; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct SizeCm { + pub length: i32, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone, Entity, serde::Serialize, serde::Deserialize)] +#[join(airports as origin, on = origin_iata = iata, fields( + lat as origin_lat: f64 +))] +#[entity(table = "parcels")] +pub struct Parcel { + #[id] + pub id: Uuid, + + #[field(create, response)] + pub origin_iata: String, + + /// A JSONB column documents as a free-form object without the + /// override; with it the schema names the shape it really carries. + #[field(create, response)] + #[schema(value_type = Option)] + pub size_cm: Option, +} + +fn assert_schema() {} + +fn main() { + assert_schema::(); + assert_schema::(); + assert_schema::(); + + let _size: Option = None::; +} diff --git a/wiki/Atributos.md b/wiki/Atributos.md index 1e558b6..6828eb2 100644 --- a/wiki/Atributos.md +++ b/wiki/Atributos.md @@ -528,6 +528,26 @@ pub struct User { /* ... */ } **Generado:** Método `find_posts()` en repositorio. +### `#[schema(...)]` + +Sobrescribe cómo se documenta un campo en OpenAPI. La lista de tokens se +entrega a utoipa tal cual y aparece en cada estructura generada que +deriva `ToSchema`: los DTO de creación y respuesta y la vista unida. + +Una columna JSONB tipada como `serde_json::Value` se documenta como un +objeto libre; la sobrescritura nombra la forma que realmente lleva: + +```rust +#[field(create, response)] +#[schema(value_type = Option)] +pub size_cm: Option, +``` + +Sin la característica `api` nada generado deriva `ToSchema` y el atributo +se descarta. Los DTO de actualización no lo llevan: la macro reescribe +los tipos de sus campos para la semántica PATCH, así que un `value_type` +declarado contradiría el tipo generado. + ### `#[projection(Name: fields)]` Genera estructura de vista parcial (nivel de entidad). @@ -654,6 +674,7 @@ pub struct Post { | Usar comandos de dominio | `commands` en entidad + `#[command(...)]` | | Definir relación | `#[belongs_to(Entity)]` o `#[has_many(Entity)]` | | Vista parcial de entidad | `#[projection(Name: fields)]` | +| Sobrescribir el tipo OpenAPI de un campo | `#[schema(value_type = T)]` | ### DTO de actualización: semántica PATCH diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 0a132cc..479f3da 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -636,6 +636,27 @@ pub struct User { /* ... */ } **Generated:** `find_posts()` method in repository. +### `#[schema(...)]` + +Overrides how a field documents itself in OpenAPI. The token list is +handed to utoipa verbatim and lands on every generated struct that +derives `ToSchema` — the create and response DTOs and the joined view. + +A JSONB column typed as `serde_json::Value` documents as a free-form +object; the override names the shape it actually carries: + +```rust +#[field(create, response)] +#[schema(value_type = Option)] +pub size_cm: Option, +``` + +Without the `api` feature nothing generated derives `ToSchema`, and the +attribute is dropped rather than emitted onto a struct that cannot +interpret it. Update DTOs do not carry it: the macro rewrites their +field types for PATCH semantics, so a declared `value_type` would +contradict the type actually generated. + ### `#[projection(Name: fields)]` Generate partial view struct (entity-level). @@ -763,6 +784,7 @@ pub struct Post { | Use multi-entity transactions | `transactions` on entity | | Define relationship | `#[belongs_to(Entity)]` or `#[has_many(Entity)]` | | Partial entity view | `#[projection(Name: fields)]` | +| Override an OpenAPI field type | `#[schema(value_type = T)]` | ### Update DTOs: PATCH semantics diff --git "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" index caf569c..c1d587b 100644 --- "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" +++ "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" @@ -587,6 +587,26 @@ pub struct User { /* ... */ } **Генерируется:** метод `find_posts()` в репозитории. +### `#[schema(...)]` + +Переопределяет описание поля в OpenAPI. Список токенов передаётся в +utoipa без изменений и попадает на каждую сгенерированную структуру с +`ToSchema` — DTO создания и ответа, а также объединённое представление. + +Колонка JSONB с типом `serde_json::Value` документируется как объект +произвольной формы; переопределение называет реальную форму: + +```rust +#[field(create, response)] +#[schema(value_type = Option)] +pub size_cm: Option, +``` + +Без возможности `api` ни одна сгенерированная структура не выводит +`ToSchema`, поэтому атрибут отбрасывается. DTO обновления его не несут: +макрос переписывает типы их полей ради семантики PATCH, и объявленный +`value_type` противоречил бы сгенерированному типу. + ### `#[projection(Name: fields)]` Генерирует структуру частичного представления (на уровне сущности). @@ -714,6 +734,7 @@ pub struct Post { | Использовать транзакции с несколькими сущностями | `transactions` на сущности | | Определить связь | `#[belongs_to(Entity)]` или `#[has_many(Entity)]` | | Частичное представление сущности | `#[projection(Name: fields)]` | +| Переопределить тип поля в OpenAPI | `#[schema(value_type = T)]` | ### Update DTO: семантика PATCH diff --git "a/wiki/\345\261\236\346\200\247.md" "b/wiki/\345\261\236\346\200\247.md" index efd45ce..a245cd4 100644 --- "a/wiki/\345\261\236\346\200\247.md" +++ "b/wiki/\345\261\236\346\200\247.md" @@ -527,6 +527,24 @@ pub struct User { /* ... */ } **生成:** repository中的 `find_posts()` 方法。 +### `#[schema(...)]` + +覆盖字段在 OpenAPI 中的描述方式。词元列表原样交给 utoipa,出现在每个派生 +`ToSchema` 的生成结构上——创建与响应 DTO,以及联接视图。 + +类型为 `serde_json::Value` 的 JSONB 列会被记录为自由形式对象;覆盖后可以 +写出它实际承载的结构: + +```rust +#[field(create, response)] +#[schema(value_type = Option)] +pub size_cm: Option, +``` + +未启用 `api` 特性时,任何生成结构都不派生 `ToSchema`,该属性会被丢弃。 +更新 DTO 不携带它:宏为 PATCH 语义重写了字段类型,声明的 `value_type` +会与实际生成的类型矛盾。 + ### `#[projection(Name: fields)]` 生成部分视图结构(实体级)。 @@ -653,6 +671,7 @@ pub struct Post { | 使用领域命令 | 实体上 `commands` + `#[command(...)]` | | 定义关系 | `#[belongs_to(Entity)]` 或 `#[has_many(Entity)]` | | 实体部分视图 | `#[projection(Name: fields)]` | +| 覆盖字段的 OpenAPI 类型 | `#[schema(value_type = T)]` | ### 更新 DTO:PATCH 语义 diff --git "a/wiki/\354\206\215\354\204\261.md" "b/wiki/\354\206\215\354\204\261.md" index b4fd1aa..932c219 100644 --- "a/wiki/\354\206\215\354\204\261.md" +++ "b/wiki/\354\206\215\354\204\261.md" @@ -528,6 +528,26 @@ pub struct User { /* ... */ } **생성됨:** 리포지토리에 `find_posts()` 메서드. +### `#[schema(...)]` + +필드가 OpenAPI에 기술되는 방식을 재정의한다. 토큰 목록은 utoipa에 그대로 +전달되어 `ToSchema`를 파생하는 모든 생성 구조체 — 생성·응답 DTO와 조인 +뷰 — 에 실린다. + +`serde_json::Value`로 선언된 JSONB 컬럼은 자유 형식 객체로 문서화된다. +재정의하면 실제로 담는 형태를 명시할 수 있다: + +```rust +#[field(create, response)] +#[schema(value_type = Option)] +pub size_cm: Option, +``` + +`api` 기능이 꺼져 있으면 생성 구조체가 `ToSchema`를 파생하지 않으므로 +속성은 버려진다. 업데이트 DTO에는 실리지 않는다: 매크로가 PATCH 의미를 +위해 필드 타입을 다시 쓰기 때문에 선언한 `value_type`이 생성된 타입과 +어긋나게 된다. + ### `#[projection(Name: fields)]` 부분 뷰 구조체를 생성합니다 (엔티티 레벨). @@ -654,6 +674,7 @@ pub struct Post { | 도메인 커맨드 사용 | 엔티티에 `commands` + `#[command(...)]` | | 관계 정의 | `#[belongs_to(Entity)]` 또는 `#[has_many(Entity)]` | | 부분 엔티티 뷰 | `#[projection(Name: fields)]` | +| 필드의 OpenAPI 타입 재정의 | `#[schema(value_type = T)]` | ### Update DTO: PATCH 시맨틱