Skip to main content

backbone_core/
graphql.rs

1//! Generic GraphQL resolver — mirrors `BackboneCrudHandler` for the GraphQL transport.
2//!
3//! Generated code emits a type alias per entity:
4//!
5//! ```rust,ignore
6//! // Generated (was ~300 lines of async-graphql boilerplate):
7//! pub type OrderGraphQLResolver = GenericGraphQLResolver<
8//!     Order, CreateOrderDto, UpdateOrderDto, OrderService
9//! >;
10//! ```
11//!
12//! The resolver exposes the standard 11 CRUD operations as GraphQL
13//! queries and mutations.  Entity-specific computed fields and
14//! subscriptions go in the `// <<< CUSTOM` decorator.
15//!
16//! # Note on framework coupling
17//!
18//! This module deliberately avoids a hard dependency on `async-graphql` or
19//! any other GraphQL library.  The `GenericGraphQLResolver` is a thin
20//! service-holder; concrete GraphQL objects (#[Object] / #[SimpleObject])
21//! are generated at the module level where the GraphQL library is available.
22//! This keeps `backbone-core` dependency-free for GraphQL concerns.
23
24use async_trait::async_trait;
25use std::collections::HashMap;
26use std::marker::PhantomData;
27use std::sync::Arc;
28
29use crate::service::ServiceResult;
30
31// ─── Service contract ─────────────────────────────────────────────────────────
32
33/// The minimal contract `GenericGraphQLResolver` needs from a service.
34///
35/// Identical contract to `GrpcCapableService` — implementing once satisfies both.
36#[async_trait]
37pub trait GraphQLCapableService<E, C, U>: Send + Sync + 'static
38where
39    E: Clone + Send + Sync + 'static,
40    C: Send + Sync + 'static,
41    U: Send + Sync + 'static,
42{
43    async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> ServiceResult<(Vec<E>, u64)>;
44    async fn create(&self, dto: C) -> ServiceResult<E>;
45    async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>>;
46    async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>>;
47    async fn soft_delete(&self, id: &str) -> ServiceResult<bool>;
48    async fn restore(&self, id: &str) -> ServiceResult<Option<E>>;
49    async fn list_deleted(&self, page: u32, limit: u32) -> ServiceResult<(Vec<E>, u64)>;
50    async fn empty_trash(&self) -> ServiceResult<u64>;
51}
52
53// ─── GenericGraphQLResolver ───────────────────────────────────────────────────
54
55/// Generic GraphQL resolver.
56///
57/// This struct holds the service and provides named methods that match
58/// GraphQL field names (`query_list`, `mutation_create`, etc.).
59/// The actual `#[Object]` / `#[Subscription]` annotations are placed in
60/// generated wrapper code that calls these methods — keeping the generic
61/// base free from `async-graphql` attributes.
62///
63/// # Extension pattern
64///
65/// ```rust,ignore
66/// // Generated type alias (1 line):
67/// pub type OrderGqlResolver = GenericGraphQLResolver<Order, CreateOrderDto, UpdateOrderDto, OrderService>;
68///
69/// // Custom decorator adds entity-specific fields:
70/// pub struct OrderGqlResolverCustom {
71///     base: Arc<OrderGqlResolver>,
72///     pricing: Arc<PricingService>,
73/// }
74/// #[Object]
75/// impl OrderGqlResolverCustom {
76///     // Delegate standard fields to base
77///     async fn order(&self, id: ID) -> Option<Order> {
78///         self.base.query_get_by_id(&id.to_string()).await.ok().flatten()
79///     }
80///     // Add entity-specific field
81///     async fn order_price(&self, id: ID) -> f64 {
82///         self.pricing.calculate(&id.to_string()).await
83///     }
84/// }
85/// ```
86pub struct GenericGraphQLResolver<E, C, U, S>
87where
88    E: Clone + Send + Sync + 'static,
89    C: Send + Sync + 'static,
90    U: Send + Sync + 'static,
91    S: GraphQLCapableService<E, C, U>,
92{
93    service: Arc<S>,
94    _phantom: PhantomData<(E, C, U)>,
95}
96
97impl<E, C, U, S> GenericGraphQLResolver<E, C, U, S>
98where
99    E: Clone + Send + Sync + 'static,
100    C: Send + Sync + 'static,
101    U: Send + Sync + 'static,
102    S: GraphQLCapableService<E, C, U>,
103{
104    pub fn new(service: Arc<S>) -> Self {
105        Self {
106            service,
107            _phantom: PhantomData,
108        }
109    }
110
111    pub fn service(&self) -> &Arc<S> {
112        &self.service
113    }
114
115    // ── Query operations ──────────────────────────────────────────────────
116
117    /// GraphQL query: `entity(id: ID!): EntityType`
118    pub async fn query_get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
119        self.service.get_by_id(id).await
120    }
121
122    /// GraphQL query: `entities(page: Int, limit: Int, filters: JSON): EntityListResult`
123    pub async fn query_list(
124        &self,
125        page: u32,
126        limit: u32,
127        filters: HashMap<String, String>,
128    ) -> ServiceResult<GraphQLListResult<E>> {
129        let (items, total) = self.service.list(page, limit, filters).await?;
130        Ok(GraphQLListResult { items, total, page, limit })
131    }
132
133    /// GraphQL query: `deletedEntities(page: Int, limit: Int): EntityListResult`
134    pub async fn query_list_deleted(
135        &self,
136        page: u32,
137        limit: u32,
138    ) -> ServiceResult<GraphQLListResult<E>> {
139        let (items, total) = self.service.list_deleted(page, limit).await?;
140        Ok(GraphQLListResult { items, total, page, limit })
141    }
142
143    // ── Mutation operations ───────────────────────────────────────────────
144
145    /// GraphQL mutation: `createEntity(input: CreateInput!): EntityType!`
146    pub async fn mutation_create(&self, dto: C) -> ServiceResult<E> {
147        self.service.create(dto).await
148    }
149
150    /// GraphQL mutation: `updateEntity(id: ID!, input: UpdateInput!): EntityType`
151    pub async fn mutation_update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
152        self.service.update(id, dto).await
153    }
154
155    /// GraphQL mutation: `deleteEntity(id: ID!): Boolean!`
156    pub async fn mutation_delete(&self, id: &str) -> ServiceResult<bool> {
157        self.service.soft_delete(id).await
158    }
159
160    /// GraphQL mutation: `restoreEntity(id: ID!): EntityType`
161    pub async fn mutation_restore(&self, id: &str) -> ServiceResult<Option<E>> {
162        self.service.restore(id).await
163    }
164
165    /// GraphQL mutation: `emptyEntityTrash: Int!`
166    pub async fn mutation_empty_trash(&self) -> ServiceResult<u64> {
167        self.service.empty_trash().await
168    }
169}
170
171// Make resolver cloneable for sharing across query/mutation roots.
172impl<E, C, U, S> Clone for GenericGraphQLResolver<E, C, U, S>
173where
174    E: Clone + Send + Sync + 'static,
175    C: Send + Sync + 'static,
176    U: Send + Sync + 'static,
177    S: GraphQLCapableService<E, C, U>,
178{
179    fn clone(&self) -> Self {
180        Self {
181            service: self.service.clone(),
182            _phantom: PhantomData,
183        }
184    }
185}
186
187// ─── Response types ───────────────────────────────────────────────────────────
188
189/// Paginated list result for GraphQL responses.
190///
191/// Maps 1:1 to `PaginatedApiResponse` from the HTTP layer — same fields, no HTTP
192/// coupling. Module code can annotate this with `#[SimpleObject]` if needed.
193#[derive(Debug, Clone)]
194pub struct GraphQLListResult<E> {
195    pub items: Vec<E>,
196    pub total: u64,
197    pub page: u32,
198    pub limit: u32,
199}
200
201impl<E> GraphQLListResult<E> {
202    pub fn total_pages(&self) -> u32 {
203        if self.limit == 0 {
204            return 0;
205        }
206        ((self.total as f64) / (self.limit as f64)).ceil() as u32
207    }
208}
209
210// ─── GraphQL filter helpers ───────────────────────────────────────────────────
211
212/// Standard pagination input shared by all generated GraphQL list queries.
213#[derive(Debug, Clone)]
214pub struct GraphQLPaginationInput {
215    pub page: Option<u32>,
216    pub limit: Option<u32>,
217}
218
219impl GraphQLPaginationInput {
220    pub fn page(&self) -> u32 {
221        self.page.unwrap_or(1)
222    }
223
224    pub fn limit(&self) -> u32 {
225        self.limit.unwrap_or(20)
226    }
227}
228
229impl Default for GraphQLPaginationInput {
230    fn default() -> Self {
231        Self {
232            page: Some(1),
233            limit: Some(20),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use std::sync::Mutex;
242
243    #[derive(Debug, Clone)]
244    struct Item {
245        id: String,
246        name: String,
247    }
248
249    struct CreateItemInput { name: String }
250    struct UpdateItemInput { name: String }
251
252    struct FakeItemService {
253        store: Mutex<Vec<Item>>,
254    }
255
256    impl FakeItemService {
257        fn new() -> Self {
258            Self {
259                store: Mutex::new(Vec::new()),
260            }
261        }
262    }
263
264    #[async_trait]
265    impl GraphQLCapableService<Item, CreateItemInput, UpdateItemInput> for FakeItemService {
266        async fn list(&self, _p: u32, _l: u32, _f: HashMap<String, String>) -> ServiceResult<(Vec<Item>, u64)> {
267            let store = self.store.lock().unwrap();
268            let items: Vec<_> = store.iter().filter(|i| !i.id.starts_with("del-")).cloned().collect();
269            let total = items.len() as u64;
270            Ok((items, total))
271        }
272        async fn create(&self, dto: CreateItemInput) -> ServiceResult<Item> {
273            let item = Item { id: uuid::Uuid::new_v4().to_string(), name: dto.name };
274            self.store.lock().unwrap().push(item.clone());
275            Ok(item)
276        }
277        async fn get_by_id(&self, id: &str) -> ServiceResult<Option<Item>> {
278            Ok(self.store.lock().unwrap().iter().find(|i| i.id == id).cloned())
279        }
280        async fn update(&self, id: &str, dto: UpdateItemInput) -> ServiceResult<Option<Item>> {
281            let mut store = self.store.lock().unwrap();
282            if let Some(item) = store.iter_mut().find(|i| i.id == id) {
283                item.name = dto.name;
284                return Ok(Some(item.clone()));
285            }
286            Ok(None)
287        }
288        async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
289            let mut store = self.store.lock().unwrap();
290            if let Some(item) = store.iter_mut().find(|i| i.id == id) {
291                item.id = format!("del-{}", item.id);
292                return Ok(true);
293            }
294            Ok(false)
295        }
296        async fn restore(&self, _id: &str) -> ServiceResult<Option<Item>> { Ok(None) }
297        async fn list_deleted(&self, _p: u32, _l: u32) -> ServiceResult<(Vec<Item>, u64)> { Ok((vec![], 0)) }
298        async fn empty_trash(&self) -> ServiceResult<u64> { Ok(0) }
299    }
300
301    #[tokio::test]
302    async fn create_and_query_roundtrip() {
303        let service = Arc::new(FakeItemService::new());
304        let resolver = GenericGraphQLResolver::new(service);
305
306        resolver.mutation_create(CreateItemInput { name: "widget".into() }).await.unwrap();
307
308        let result = resolver.query_list(1, 20, Default::default()).await.unwrap();
309        assert_eq!(result.items.len(), 1);
310        assert_eq!(result.items[0].name, "widget");
311        assert_eq!(result.total, 1);
312    }
313
314    #[tokio::test]
315    async fn list_result_total_pages() {
316        let result: GraphQLListResult<i32> = GraphQLListResult {
317            items: vec![],
318            total: 50,
319            page: 1,
320            limit: 20,
321        };
322        assert_eq!(result.total_pages(), 3);
323    }
324}