Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 417 lines 14 kB view raw
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}