Skip to main content

fraiseql_storage/graphql/
mod.rs

1//! GraphQL schema type generation for storage operations.
2//!
3//! This module generates GraphQL type definitions that are embedded in the
4//! compiled schema, allowing storage buckets to be represented as first-class
5//! types in the GraphQL API.
6//!
7//! The generated JSON matches the compiled schema format used by `fraiseql-core`:
8//! - `field_type` uses bare scalars (`"String"`) and tagged variants (`{"Object": "Foo"}`)
9//! - Mutations use `"arguments"` (not `"parameters"`) and top-level `"return_type"` / `"operation"`
10//! - Queries use `"returns_list"` as a sibling boolean, not nested `{"List": ...}`
11
12use serde_json::{Value, json};
13
14use crate::config::BucketConfig;
15
16/// Generates GraphQL type definitions for storage operations.
17///
18/// Each bucket produces three schema entries:
19/// - A `TypeDefinition` for the storage object type
20/// - A `MutationDefinition` for presigned upload URL generation
21/// - A `QueryDefinition` for listing objects
22pub struct StorageSchemaTypes;
23
24/// All generated schema entries for a set of storage buckets.
25#[derive(Debug, Default)]
26pub struct StorageSchemaEntries {
27    /// Type definitions for inclusion in the compiled schema `types` array.
28    pub types:     Vec<Value>,
29    /// Mutation definitions for inclusion in the compiled schema `mutations` array.
30    pub mutations: Vec<Value>,
31    /// Query definitions for inclusion in the compiled schema `queries` array.
32    pub queries:   Vec<Value>,
33}
34
35impl StorageSchemaTypes {
36    /// Generate all schema entries for the given buckets.
37    ///
38    /// Returns empty vectors when `buckets` is empty — no storage types are
39    /// emitted unless at least one bucket is configured.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use fraiseql_storage::graphql::StorageSchemaTypes;
45    /// use fraiseql_storage::config::{BucketConfig, BucketAccess};
46    ///
47    /// let buckets = vec![BucketConfig {
48    ///     name: "avatars".to_string(),
49    ///     max_object_bytes: Some(5_000_000),
50    ///     allowed_mime_types: Some(vec!["image/jpeg".to_string()]),
51    ///     access: BucketAccess::PublicRead,
52    ///     transform_presets: None,
53    /// }];
54    ///
55    /// let entries = StorageSchemaTypes::generate(&buckets);
56    /// assert_eq!(entries.types.len(), 1);
57    /// assert_eq!(entries.mutations.len(), 1);
58    /// assert_eq!(entries.queries.len(), 1);
59    /// ```
60    #[must_use]
61    pub fn generate(buckets: &[BucketConfig]) -> StorageSchemaEntries {
62        let mut entries = StorageSchemaEntries::default();
63        for bucket in buckets {
64            entries.types.push(Self::storage_object_type(bucket));
65            entries.mutations.push(Self::upload_url_mutation(bucket));
66            entries.queries.push(Self::list_query(bucket));
67        }
68        entries
69    }
70
71    /// Generate a storage object type for a bucket.
72    ///
73    /// Creates a `TypeDefinition` JSON object representing files in the bucket
74    /// with fields for metadata (`key`, `size`, `content_type`, `created_at`, `updated_at`).
75    ///
76    /// # Example
77    ///
78    /// ```
79    /// use fraiseql_storage::graphql::StorageSchemaTypes;
80    /// use fraiseql_storage::config::{BucketConfig, BucketAccess};
81    ///
82    /// let bucket = BucketConfig {
83    ///     name: "avatars".to_string(),
84    ///     max_object_bytes: Some(5_000_000),
85    ///     allowed_mime_types: Some(vec!["image/jpeg".to_string()]),
86    ///     access: BucketAccess::PublicRead,
87    ///     transform_presets: None,
88    /// };
89    ///
90    /// let type_def = StorageSchemaTypes::storage_object_type(&bucket);
91    /// assert_eq!(type_def["name"], "AvatarsStorageObject");
92    /// ```
93    #[must_use]
94    pub fn storage_object_type(bucket: &BucketConfig) -> Value {
95        let type_name = format!("{}StorageObject", Self::bucket_type_name(&bucket.name));
96
97        json!({
98            "name": type_name,
99            "sql_source": format!("t_storage_{}", bucket.name),
100            "jsonb_column": "data",
101            "description": format!("Storage object in the {} bucket", bucket.name),
102            "fields": [
103                {
104                    "name": "key",
105                    "field_type": "String",
106                    "description": "Object key in the bucket"
107                },
108                {
109                    "name": "size",
110                    "field_type": "Int",
111                    "description": "Size in bytes"
112                },
113                {
114                    "name": "content_type",
115                    "field_type": "String",
116                    "description": "MIME type"
117                },
118                {
119                    "name": "created_at",
120                    "field_type": "DateTime",
121                    "description": "Upload timestamp"
122                },
123                {
124                    "name": "updated_at",
125                    "field_type": "DateTime",
126                    "nullable": true,
127                    "description": "Last modified timestamp"
128                }
129            ]
130        })
131    }
132
133    /// Generate an upload URL mutation for a bucket.
134    ///
135    /// Creates a `MutationDefinition` JSON object that generates presigned
136    /// upload URLs for direct client-to-storage uploads.
137    #[must_use]
138    pub fn upload_url_mutation(bucket: &BucketConfig) -> Value {
139        let mutation_name = format!("generate{}UploadUrl", Self::bucket_type_name(&bucket.name));
140
141        json!({
142            "name": mutation_name,
143            "description": format!("Generate presigned upload URL for {} bucket", bucket.name),
144            "operation": "Custom",
145            "return_type": "PresignedUrlResponse",
146            "arguments": [
147                {
148                    "name": "key",
149                    "arg_type": "String",
150                    "description": "Object key"
151                },
152                {
153                    "name": "content_type",
154                    "arg_type": "String",
155                    "description": "MIME type of the file being uploaded"
156                }
157            ]
158        })
159    }
160
161    /// Generate a list query for a bucket.
162    ///
163    /// Creates a `QueryDefinition` JSON object that lists objects in the bucket
164    /// with optional prefix filtering and cursor-based pagination.
165    #[must_use]
166    pub fn list_query(bucket: &BucketConfig) -> Value {
167        let query_name = format!("list{}Objects", Self::bucket_type_name(&bucket.name));
168        let return_type_name = format!("{}StorageObject", Self::bucket_type_name(&bucket.name));
169
170        json!({
171            "name": query_name,
172            "description": format!("List objects in {} bucket", bucket.name),
173            "return_type": return_type_name,
174            "returns_list": true,
175            "sql_source": format!("t_storage_{}", bucket.name),
176            "jsonb_column": "data",
177            "arguments": [
178                {
179                    "name": "prefix",
180                    "arg_type": "String",
181                    "nullable": true,
182                    "description": "Filter by key prefix"
183                },
184                {
185                    "name": "limit",
186                    "arg_type": "Int",
187                    "nullable": true,
188                    "description": "Maximum number of results"
189                },
190                {
191                    "name": "cursor",
192                    "arg_type": "String",
193                    "nullable": true,
194                    "description": "Pagination cursor"
195                }
196            ]
197        })
198    }
199
200    /// Convert a bucket name to a valid GraphQL type name.
201    ///
202    /// Converts `snake_case` or kebab-case bucket names to `PascalCase` for use in
203    /// GraphQL type names.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use fraiseql_storage::graphql::StorageSchemaTypes;
209    ///
210    /// assert_eq!(StorageSchemaTypes::bucket_type_name("user_avatars"), "UserAvatars");
211    /// assert_eq!(StorageSchemaTypes::bucket_type_name("product-images"), "ProductImages");
212    /// ```
213    #[must_use]
214    pub fn bucket_type_name(bucket_name: &str) -> String {
215        bucket_name
216            .split(['_', '-'])
217            .filter(|s| !s.is_empty())
218            .map(|s| {
219                let mut chars = s.chars();
220                match chars.next() {
221                    None => String::new(),
222                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
223                }
224            })
225            .collect()
226    }
227}
228
229#[cfg(test)]
230mod tests;