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;