Auto-indexing service and GraphQL API for AT Protocol Records
at main 297 lines 8.6 kB view raw
1/// GraphQL HTTP handler for admin API 2/// 3/// This handler serves the /admin/graphql endpoint which provides 4/// stats, settings, and activity data to the admin SPA 5import backfill_state 6import database/executor.{type Executor} 7import gleam/bit_array 8import gleam/dict 9import gleam/dynamic/decode 10import gleam/erlang/process.{type Subject} 11import gleam/http 12import gleam/json 13import gleam/list 14import gleam/option 15import graphql/admin/schema as admin_schema 16import jetstream_consumer 17import lib/oauth/did_cache 18import swell/executor as swell_executor 19import swell/schema as swell_schema 20import swell/value 21import wisp 22 23/// Handle GraphQL HTTP requests for admin API 24pub fn handle_admin_graphql_request( 25 req: wisp.Request, 26 db: Executor, 27 jetstream_subject: option.Option(Subject(jetstream_consumer.ManagerMessage)), 28 did_cache: Subject(did_cache.Message), 29 oauth_supported_scopes: List(String), 30 backfill_state_subject: Subject(backfill_state.Message), 31) -> wisp.Response { 32 case req.method { 33 http.Post -> 34 handle_post( 35 req, 36 db, 37 jetstream_subject, 38 did_cache, 39 oauth_supported_scopes, 40 backfill_state_subject, 41 ) 42 http.Get -> 43 handle_get( 44 req, 45 db, 46 jetstream_subject, 47 did_cache, 48 oauth_supported_scopes, 49 backfill_state_subject, 50 ) 51 _ -> method_not_allowed_response() 52 } 53} 54 55fn handle_post( 56 req: wisp.Request, 57 db: Executor, 58 jetstream_subject: option.Option(Subject(jetstream_consumer.ManagerMessage)), 59 did_cache: Subject(did_cache.Message), 60 oauth_supported_scopes: List(String), 61 backfill_state_subject: Subject(backfill_state.Message), 62) -> wisp.Response { 63 case wisp.read_body_bits(req) { 64 Ok(body) -> { 65 case bit_array.to_string(body) { 66 Ok(body_string) -> { 67 case extract_query_and_variables_from_json(body_string) { 68 Ok(#(query, variables)) -> 69 execute_query( 70 req, 71 db, 72 jetstream_subject, 73 did_cache, 74 oauth_supported_scopes, 75 backfill_state_subject, 76 query, 77 variables, 78 ) 79 Error(err) -> bad_request_response("Invalid JSON: " <> err) 80 } 81 } 82 Error(_) -> bad_request_response("Request body must be valid UTF-8") 83 } 84 } 85 Error(_) -> bad_request_response("Failed to read request body") 86 } 87} 88 89fn handle_get( 90 req: wisp.Request, 91 db: Executor, 92 jetstream_subject: option.Option(Subject(jetstream_consumer.ManagerMessage)), 93 did_cache: Subject(did_cache.Message), 94 oauth_supported_scopes: List(String), 95 backfill_state_subject: Subject(backfill_state.Message), 96) -> wisp.Response { 97 let query_params = wisp.get_query(req) 98 case list.key_find(query_params, "query") { 99 Ok(query) -> 100 execute_query( 101 req, 102 db, 103 jetstream_subject, 104 did_cache, 105 oauth_supported_scopes, 106 backfill_state_subject, 107 query, 108 option.None, 109 ) 110 Error(_) -> bad_request_response("Missing 'query' parameter") 111 } 112} 113 114fn execute_query( 115 req: wisp.Request, 116 db: Executor, 117 jetstream_subject: option.Option(Subject(jetstream_consumer.ManagerMessage)), 118 did_cache: Subject(did_cache.Message), 119 oauth_supported_scopes: List(String), 120 backfill_state_subject: Subject(backfill_state.Message), 121 query: String, 122 variables: option.Option(value.Value), 123) -> wisp.Response { 124 // Build the schema 125 let graphql_schema = 126 admin_schema.build_schema( 127 db, 128 req, 129 jetstream_subject, 130 did_cache, 131 oauth_supported_scopes, 132 backfill_state_subject, 133 ) 134 135 // Create context with variables 136 let ctx = case variables { 137 option.Some(value.Object(fields)) -> { 138 // Convert list of tuples to dict 139 let vars_dict = dict.from_list(fields) 140 swell_schema.context_with_variables(option.None, vars_dict) 141 } 142 _ -> swell_schema.context(option.None) 143 } 144 145 // Execute the query 146 case swell_executor.execute(query, graphql_schema, ctx) { 147 Ok(result) -> { 148 // Convert executor response to JSON 149 let response_json = case result.errors { 150 [] -> { 151 // Success with no errors 152 json.object([#("data", value_to_json(result.data))]) 153 } 154 errors -> { 155 // Convert GraphQLError records to JSON 156 let errors_json = 157 json.array(errors, fn(err) { 158 json.object([ 159 #("message", json.string(err.message)), 160 #("path", json.array(err.path, json.string)), 161 ]) 162 }) 163 164 // Partial success or errors 165 json.object([ 166 #("data", value_to_json(result.data)), 167 #("errors", errors_json), 168 ]) 169 } 170 } 171 172 let json_string = json.to_string(response_json) 173 success_response(json_string) 174 } 175 Error(err) -> internal_error_response(err) 176 } 177} 178 179/// Convert a GraphQL Value to JSON 180fn value_to_json(val: value.Value) -> json.Json { 181 case val { 182 value.Null -> json.null() 183 value.Int(i) -> json.int(i) 184 value.Float(f) -> json.float(f) 185 value.String(s) -> json.string(s) 186 value.Boolean(b) -> json.bool(b) 187 value.Enum(e) -> json.string(e) 188 value.List(items) -> json.array(items, value_to_json) 189 value.Object(fields) -> 190 json.object( 191 list.map(fields, fn(field) { #(field.0, value_to_json(field.1)) }), 192 ) 193 } 194} 195 196fn extract_query_and_variables_from_json( 197 json_str: String, 198) -> Result(#(String, option.Option(value.Value)), String) { 199 // First just get the query 200 let query_decoder = { 201 use query <- decode.field("query", decode.string) 202 decode.success(query) 203 } 204 205 case json.parse(json_str, query_decoder) { 206 Ok(query) -> { 207 // Try to parse variables separately (they're optional) 208 let variables_decoder = { 209 use vars <- decode.field("variables", decode.dynamic) 210 decode.success(option.Some(vars)) 211 } 212 213 let variables_value = case json.parse(json_str, variables_decoder) { 214 Ok(option.Some(vars)) -> option.Some(dynamic_to_value(vars)) 215 _ -> option.None 216 } 217 218 Ok(#(query, variables_value)) 219 } 220 Error(_) -> Error("Invalid JSON or missing 'query' field") 221 } 222} 223 224/// Convert a Dynamic value to a GraphQL Value 225/// For strings, we treat them as Enum values since GraphQL enums are sent as strings in JSON 226fn dynamic_to_value(dyn: decode.Dynamic) -> value.Value { 227 // Try to decode as different types 228 case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 229 Ok(dict_value) -> { 230 // It's an object 231 let fields = 232 dict_value 233 |> dict.to_list 234 |> list.map(fn(pair) { 235 let #(key, val) = pair 236 #(key, dynamic_to_value(val)) 237 }) 238 value.Object(fields) 239 } 240 Error(_) -> 241 case decode.run(dyn, decode.list(decode.dynamic)) { 242 Ok(list_value) -> { 243 let items = list.map(list_value, dynamic_to_value) 244 value.List(items) 245 } 246 Error(_) -> 247 case decode.run(dyn, decode.int) { 248 Ok(i) -> value.Int(i) 249 Error(_) -> 250 case decode.run(dyn, decode.float) { 251 Ok(f) -> value.Float(f) 252 Error(_) -> 253 case decode.run(dyn, decode.bool) { 254 Ok(b) -> value.Boolean(b) 255 Error(_) -> 256 case decode.run(dyn, decode.string) { 257 Ok(str) -> value.String(str) 258 Error(_) -> value.Null 259 } 260 } 261 } 262 } 263 } 264 } 265} 266 267// Response helpers 268 269fn success_response(data: String) -> wisp.Response { 270 wisp.response(200) 271 |> wisp.set_header("content-type", "application/json") 272 |> wisp.set_body(wisp.Text(data)) 273} 274 275fn bad_request_response(message: String) -> wisp.Response { 276 wisp.response(400) 277 |> wisp.set_header("content-type", "application/json") 278 |> wisp.set_body(wisp.Text( 279 "{\"error\": \"BadRequest\", \"message\": \"" <> message <> "\"}", 280 )) 281} 282 283fn internal_error_response(message: String) -> wisp.Response { 284 wisp.response(500) 285 |> wisp.set_header("content-type", "application/json") 286 |> wisp.set_body(wisp.Text( 287 "{\"error\": \"InternalError\", \"message\": \"" <> message <> "\"}", 288 )) 289} 290 291fn method_not_allowed_response() -> wisp.Response { 292 wisp.response(405) 293 |> wisp.set_header("content-type", "application/json") 294 |> wisp.set_body(wisp.Text( 295 "{\"error\": \"MethodNotAllowed\", \"message\": \"Only POST and GET are allowed\"}", 296 )) 297}