Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
1/// Lexicon GraphQL schema entry point
2///
3/// Public API for building and executing the lexicon-driven GraphQL schema.
4/// External code should import this module for all lexicon GraphQL operations.
5import atproto_auth
6import backfill
7import database/executor.{type Executor}
8import database/repositories/config as config_repo
9import database/repositories/label_definitions
10import database/repositories/label_preferences
11import database/repositories/lexicons
12import gleam/dict
13import gleam/dynamic/decode
14import gleam/erlang/process.{type Subject}
15import gleam/json
16import gleam/list
17import gleam/option
18import gleam/result
19import gleam/string
20import graphql/admin/types as admin_types
21import graphql/lexicon/converters
22import graphql/lexicon/fetchers
23import graphql/lexicon/mutations
24import lexicon_graphql
25import lexicon_graphql/schema/database
26import lib/oauth/did_cache
27import swell/executor as swell_executor
28import swell/schema
29import swell/value
30
31/// Build a GraphQL schema from database lexicons
32///
33/// This is exposed for WebSocket subscriptions to build the schema once
34/// and reuse it for multiple subscription executions.
35pub fn build_schema_from_db(
36 db: Executor,
37 did_cache: Subject(did_cache.Message),
38 signing_key: option.Option(String),
39 atp_client_id: String,
40 plc_url: String,
41 domain_authority: String,
42) -> Result(schema.Schema, String) {
43 // Step 1: Fetch lexicons from database
44 use lexicon_records <- result.try(
45 lexicons.get_all(db)
46 |> result.map_error(fn(_) { "Failed to fetch lexicons from database" }),
47 )
48
49 // Step 2: Parse lexicon JSON into structured Lexicon types
50 let parsed_lexicons =
51 lexicon_records
52 |> list.filter_map(fn(lex) {
53 case lexicon_graphql.parse_lexicon(lex.json) {
54 Ok(parsed) -> Ok(parsed)
55 Error(_) -> Error(Nil)
56 }
57 })
58
59 // Check if we got any valid lexicons
60 case parsed_lexicons {
61 [] -> Error("No valid lexicons found in database")
62 _ -> {
63 // Step 3: Create fetchers
64 let record_fetcher = fetchers.record_fetcher(db)
65 let batch_fetcher = fetchers.batch_fetcher(db)
66 let paginated_batch_fetcher = fetchers.paginated_batch_fetcher(db)
67 let aggregate_fetcher = fetchers.aggregate_fetcher(db)
68 let viewer_fetcher = fetchers.viewer_fetcher(db)
69
70 // Step 4: Determine local and external collections for backfill
71 let collection_ids =
72 parsed_lexicons
73 |> list.filter_map(fn(lex) {
74 case
75 backfill.nsid_matches_domain_authority(lex.id, domain_authority)
76 {
77 True -> Ok(lex.id)
78 False -> Error(Nil)
79 }
80 })
81
82 let external_collection_ids =
83 parsed_lexicons
84 |> list.filter_map(fn(lex) {
85 case
86 backfill.nsid_matches_domain_authority(lex.id, domain_authority)
87 {
88 True -> Error(Nil)
89 False -> Ok(lex.id)
90 }
91 })
92
93 // Step 5: Create mutation resolver factories
94 let mutation_ctx =
95 mutations.MutationContext(
96 db: db,
97 did_cache: did_cache,
98 signing_key: signing_key,
99 atp_client_id: atp_client_id,
100 plc_url: plc_url,
101 collection_ids: collection_ids,
102 external_collection_ids: external_collection_ids,
103 )
104
105 let create_factory =
106 option.Some(fn(collection) {
107 mutations.create_resolver_factory(collection, mutation_ctx)
108 })
109
110 let update_factory =
111 option.Some(fn(collection) {
112 mutations.update_resolver_factory(collection, mutation_ctx)
113 })
114
115 let delete_factory =
116 option.Some(fn(collection) {
117 mutations.delete_resolver_factory(collection, mutation_ctx)
118 })
119
120 let upload_blob_factory =
121 option.Some(fn() {
122 mutations.upload_blob_resolver_factory(mutation_ctx)
123 })
124
125 // Step 6: Create notification fetcher
126 let notification_fetcher = fetchers.notification_fetcher(db)
127
128 // Step 7: Create viewer state fetcher
129 let viewer_state_fetcher = fetchers.viewer_state_fetcher(db)
130
131 // Step 8: Create labels fetcher
132 let labels_fetch = fetchers.labels_fetcher(db)
133
134 // Step 9: Build createReport mutation field
135 let create_report_field =
136 schema.field_with_args(
137 "createReport",
138 schema.non_null(admin_types.report_type()),
139 "Submit a moderation report for content",
140 [
141 schema.argument(
142 "subjectUri",
143 schema.non_null(schema.string_type()),
144 "URI of the content to report (at:// or did:)",
145 option.None,
146 ),
147 schema.argument(
148 "reasonType",
149 schema.non_null(admin_types.report_reason_type_enum()),
150 "Type of report",
151 option.None,
152 ),
153 schema.argument(
154 "reason",
155 schema.string_type(),
156 "Optional additional details",
157 option.None,
158 ),
159 ],
160 mutations.create_report_resolver_factory(mutation_ctx),
161 )
162
163 // Step 9b: Build setLabelPreference mutation field
164 let set_label_pref_field =
165 schema.field_with_args(
166 "setLabelPreference",
167 schema.non_null(admin_types.label_preference_type()),
168 "Set visibility preference for a label type",
169 [
170 schema.argument(
171 "val",
172 schema.non_null(schema.string_type()),
173 "Label value",
174 option.None,
175 ),
176 schema.argument(
177 "visibility",
178 schema.non_null(admin_types.label_visibility_enum()),
179 "Visibility setting",
180 option.None,
181 ),
182 ],
183 mutations.set_label_preference_resolver_factory(mutation_ctx),
184 )
185
186 // Step 10: Build viewerLabelPreferences query field
187 let viewer_label_prefs_field =
188 schema.field(
189 "viewerLabelPreferences",
190 schema.non_null(
191 schema.list_type(
192 schema.non_null(admin_types.label_preference_type()),
193 ),
194 ),
195 "Get label preferences for the current user (non-system labels only)",
196 fn(ctx) {
197 // Get viewer_did from context variables (set by auth middleware)
198 case schema.get_variable(ctx, "viewer_did") {
199 option.Some(value.String(viewer_did)) -> {
200 // Get non-system label definitions
201 case label_definitions.get_non_system(mutation_ctx.db) {
202 Ok(defs) -> {
203 // Get user's preferences
204 case
205 label_preferences.get_by_did(mutation_ctx.db, viewer_did)
206 {
207 Ok(prefs) -> {
208 // Build a map of label_val -> visibility
209 let pref_map =
210 list.fold(prefs, [], fn(acc, pref) {
211 [#(pref.label_val, pref.visibility), ..acc]
212 })
213
214 // Map each definition to a preference
215 let result =
216 list.map(defs, fn(def) {
217 let visibility = case
218 list.key_find(pref_map, def.val)
219 {
220 Ok(v) -> v
221 Error(_) -> def.default_visibility
222 }
223 converters.label_preference_to_value(
224 def,
225 visibility,
226 )
227 })
228
229 Ok(value.List(result))
230 }
231 Error(_) -> Error("Failed to fetch label preferences")
232 }
233 }
234 Error(_) -> Error("Failed to fetch label definitions")
235 }
236 }
237 _ -> Error("Authentication required")
238 }
239 },
240 )
241
242 // Step 11: Build schema with database-backed resolvers, mutations, and subscriptions
243 database.build_schema_with_subscriptions(
244 parsed_lexicons,
245 record_fetcher,
246 option.Some(batch_fetcher),
247 option.Some(paginated_batch_fetcher),
248 create_factory,
249 update_factory,
250 delete_factory,
251 upload_blob_factory,
252 option.Some(aggregate_fetcher),
253 option.Some(viewer_fetcher),
254 option.Some(notification_fetcher),
255 option.Some(viewer_state_fetcher),
256 option.Some(labels_fetch),
257 option.Some([create_report_field, set_label_pref_field]),
258 option.Some([viewer_label_prefs_field]),
259 )
260 }
261 }
262}
263
264/// Execute a GraphQL query against lexicons in the database
265///
266/// This fetches lexicons, builds a schema with database resolvers,
267/// executes the query, and returns the result as JSON.
268pub fn execute_query_with_db(
269 db: Executor,
270 query_string: String,
271 variables_json_str: String,
272 auth_token: Result(String, Nil),
273 did_cache: Subject(did_cache.Message),
274 signing_key: option.Option(String),
275 atp_client_id: String,
276 plc_url: String,
277) -> Result(String, String) {
278 // Get domain authority from database
279 let domain_authority = case config_repo.get(db, "domain_authority") {
280 Ok(authority) -> authority
281 Error(_) -> ""
282 }
283
284 // Build the schema
285 use graphql_schema <- result.try(build_schema_from_db(
286 db,
287 did_cache,
288 signing_key,
289 atp_client_id,
290 plc_url,
291 domain_authority,
292 ))
293
294 // Convert json variables to Dict(String, value.Value)
295 // SECURITY: Strip any client-provided viewer_did - it must come from auth token only
296 let variables_dict =
297 json_string_to_variables_dict(variables_json_str)
298 |> dict.delete("viewer_did")
299
300 // Extract viewer DID from auth token and add to variables
301 // This is stored in variables (not ctx.data) because ctx.data gets
302 // overwritten with parent values during field resolution
303 let #(ctx_data, variables_with_viewer) = case auth_token {
304 Ok(token) -> {
305 case atproto_auth.verify_token(db, token) {
306 Ok(user_info) -> {
307 // Add viewer_did to variables for viewer state fields
308 let vars_with_viewer =
309 dict.insert(
310 variables_dict,
311 "viewer_did",
312 value.String(user_info.did),
313 )
314 // Keep auth_token in ctx.data for mutation resolvers
315 let data =
316 option.Some(value.Object([#("auth_token", value.String(token))]))
317 #(data, vars_with_viewer)
318 }
319 Error(_) -> {
320 // Token invalid/expired - allow query but without viewer context
321 #(option.None, variables_dict)
322 }
323 }
324 }
325 Error(_) -> #(option.None, variables_dict)
326 }
327
328 let ctx = schema.context_with_variables(ctx_data, variables_with_viewer)
329
330 // Execute the query
331 use response <- result.try(swell_executor.execute(
332 query_string,
333 graphql_schema,
334 ctx,
335 ))
336
337 // Format the response as JSON
338 Ok(format_response(response))
339}
340
341/// Format a swell_executor.Response as JSON string
342/// Per GraphQL spec, only include "errors" field when there are actual errors
343pub fn format_response(response: swell_executor.Response) -> String {
344 let data_json = value_to_json(response.data)
345
346 case response.errors {
347 [] -> "{\"data\": " <> data_json <> "}"
348 errors -> {
349 let error_strings =
350 list.map(errors, fn(err) {
351 let message_json = json.string(err.message) |> json.to_string
352 let path_json =
353 json.array(err.path, of: json.string) |> json.to_string
354 "{\"message\": " <> message_json <> ", \"path\": " <> path_json <> "}"
355 })
356
357 let errors_json = "[" <> string.join(error_strings, ",") <> "]"
358 "{\"data\": " <> data_json <> ", \"errors\": " <> errors_json <> "}"
359 }
360 }
361}
362
363/// Convert JSON string variables to Dict(String, value.Value)
364/// Exported for use by subscription handlers
365pub fn json_string_to_variables_dict(
366 json_string: String,
367) -> dict.Dict(String, value.Value) {
368 // First try to extract the "variables" field from the JSON
369 let variables_decoder = {
370 use vars <- decode.field("variables", decode.dynamic)
371 decode.success(vars)
372 }
373
374 case json.parse(json_string, variables_decoder) {
375 Ok(dyn) -> {
376 // Convert dynamic to value.Value
377 case converters.json_dynamic_to_value(dyn) {
378 value.Object(fields) -> dict.from_list(fields)
379 _ -> dict.new()
380 }
381 }
382 Error(_) -> dict.new()
383 }
384}
385
386/// Re-export parse_json_to_value for WebSocket handler
387pub fn parse_json_to_value(json_str: String) -> Result(value.Value, String) {
388 converters.parse_json_to_value(json_str)
389}
390
391// ─── Private Helpers ───────────────────────────────────────────────
392
393/// Convert a GraphQL value to JSON string
394fn value_to_json(val: value.Value) -> String {
395 case val {
396 value.Null -> "null"
397 value.Int(i) -> json.int(i) |> json.to_string
398 value.Float(f) -> json.float(f) |> json.to_string
399 value.String(s) -> json.string(s) |> json.to_string
400 value.Boolean(b) -> json.bool(b) |> json.to_string
401 value.Enum(e) -> json.string(e) |> json.to_string
402 value.List(items) -> {
403 let item_jsons = list.map(items, value_to_json)
404 "[" <> string.join(item_jsons, ",") <> "]"
405 }
406 value.Object(fields) -> {
407 let field_jsons =
408 list.map(fields, fn(field) {
409 let #(key, v) = field
410 let key_json = json.string(key) |> json.to_string
411 let value_json = value_to_json(v)
412 key_json <> ": " <> value_json
413 })
414 "{" <> string.join(field_jsons, ",") <> "}"
415 }
416 }
417}