Auto-indexing service and GraphQL API for AT Protocol Records
at main 219 lines 5.9 kB view raw
1/// GraphQL HTTP request handler 2/// 3/// Handles POST requests to /graphql endpoint, builds schemas from lexicons, 4/// and executes GraphQL queries. 5import database/executor.{type Executor} 6import gleam/bit_array 7import gleam/dynamic/decode 8import gleam/erlang/process.{type Subject} 9import gleam/http 10import gleam/json 11import gleam/list 12import gleam/option 13import gleam/result 14import gleam/string 15import graphql/lexicon/schema as lexicon_schema 16import lib/oauth/did_cache 17import wisp 18 19/// Handle GraphQL HTTP requests 20/// 21/// Expects POST requests with JSON body containing: 22/// - query: GraphQL query string 23/// 24/// Returns GraphQL query results as JSON 25pub fn handle_graphql_request( 26 req: wisp.Request, 27 db: Executor, 28 did_cache: Subject(did_cache.Message), 29 signing_key: option.Option(String), 30 atp_client_id: String, 31 plc_url: String, 32) -> wisp.Response { 33 case req.method { 34 http.Post -> 35 handle_graphql_post( 36 req, 37 db, 38 did_cache, 39 signing_key, 40 atp_client_id, 41 plc_url, 42 ) 43 http.Get -> 44 handle_graphql_get( 45 req, 46 db, 47 did_cache, 48 signing_key, 49 atp_client_id, 50 plc_url, 51 ) 52 _ -> method_not_allowed_response() 53 } 54} 55 56fn handle_graphql_post( 57 req: wisp.Request, 58 db: Executor, 59 did_cache: Subject(did_cache.Message), 60 signing_key: option.Option(String), 61 atp_client_id: String, 62 plc_url: String, 63) -> wisp.Response { 64 // Extract Authorization header (optional for queries, required for mutations) 65 // Strip "Bearer " or "DPoP " prefix if present 66 let auth_token = 67 list.key_find(req.headers, "authorization") 68 |> result.map(strip_auth_prefix) 69 70 // Read request body 71 case wisp.read_body_bits(req) { 72 Ok(body) -> { 73 case bit_array.to_string(body) { 74 Ok(body_string) -> { 75 // Parse JSON to extract query and variables 76 case extract_request_from_json(body_string) { 77 Ok(#(query, variables)) -> { 78 execute_graphql_query( 79 db, 80 query, 81 variables, 82 auth_token, 83 did_cache, 84 signing_key, 85 atp_client_id, 86 plc_url, 87 ) 88 } 89 Error(err) -> bad_request_response("Invalid JSON: " <> err) 90 } 91 } 92 Error(_) -> bad_request_response("Request body must be valid UTF-8") 93 } 94 } 95 Error(_) -> bad_request_response("Failed to read request body") 96 } 97} 98 99fn handle_graphql_get( 100 req: wisp.Request, 101 db: Executor, 102 did_cache: Subject(did_cache.Message), 103 signing_key: option.Option(String), 104 atp_client_id: String, 105 plc_url: String, 106) -> wisp.Response { 107 // Extract Authorization header (optional for queries, required for mutations) 108 // Strip "Bearer " or "DPoP " prefix if present 109 let auth_token = 110 list.key_find(req.headers, "authorization") 111 |> result.map(strip_auth_prefix) 112 113 // Support GET requests with query parameter (no variables for GET) 114 let query_params = wisp.get_query(req) 115 case list.key_find(query_params, "query") { 116 Ok(query) -> 117 execute_graphql_query( 118 db, 119 query, 120 "{}", 121 auth_token, 122 did_cache, 123 signing_key, 124 atp_client_id, 125 plc_url, 126 ) 127 Error(_) -> bad_request_response("Missing 'query' parameter") 128 } 129} 130 131fn execute_graphql_query( 132 db: Executor, 133 query: String, 134 variables_json_str: String, 135 auth_token: Result(String, Nil), 136 did_cache: Subject(did_cache.Message), 137 signing_key: option.Option(String), 138 atp_client_id: String, 139 plc_url: String, 140) -> wisp.Response { 141 // Use the new pure Gleam GraphQL implementation 142 case 143 lexicon_schema.execute_query_with_db( 144 db, 145 query, 146 variables_json_str, 147 auth_token, 148 did_cache, 149 signing_key, 150 atp_client_id, 151 plc_url, 152 ) 153 { 154 Ok(result_json) -> success_response(result_json) 155 Error(err) -> internal_error_response(err) 156 } 157} 158 159fn extract_request_from_json( 160 json_str: String, 161) -> Result(#(String, String), String) { 162 // Extract just the query for now - variables will be parsed from the original JSON 163 let decoder = { 164 use query <- decode.field("query", decode.string) 165 decode.success(query) 166 } 167 168 use query <- result.try( 169 json.parse(json_str, decoder) 170 |> result.map_error(fn(_) { "Invalid JSON or missing 'query' field" }), 171 ) 172 173 // Pass the original JSON string so the executor can extract variables 174 Ok(#(query, json_str)) 175} 176 177/// Strip "Bearer " or "DPoP " prefix from Authorization header value 178fn strip_auth_prefix(auth_header: String) -> String { 179 case string.starts_with(auth_header, "Bearer ") { 180 True -> string.drop_start(auth_header, 7) 181 False -> 182 case string.starts_with(auth_header, "DPoP ") { 183 True -> string.drop_start(auth_header, 5) 184 False -> auth_header 185 } 186 } 187} 188 189// Response helpers 190 191fn success_response(data: String) -> wisp.Response { 192 wisp.response(200) 193 |> wisp.set_header("content-type", "application/json") 194 |> wisp.set_body(wisp.Text(data)) 195} 196 197fn bad_request_response(message: String) -> wisp.Response { 198 wisp.response(400) 199 |> wisp.set_header("content-type", "application/json") 200 |> wisp.set_body(wisp.Text( 201 "{\"error\": \"BadRequest\", \"message\": \"" <> message <> "\"}", 202 )) 203} 204 205fn internal_error_response(message: String) -> wisp.Response { 206 wisp.response(500) 207 |> wisp.set_header("content-type", "application/json") 208 |> wisp.set_body(wisp.Text( 209 "{\"error\": \"InternalError\", \"message\": \"" <> message <> "\"}", 210 )) 211} 212 213fn method_not_allowed_response() -> wisp.Response { 214 wisp.response(405) 215 |> wisp.set_header("content-type", "application/json") 216 |> wisp.set_body(wisp.Text( 217 "{\"error\": \"MethodNotAllowed\", \"message\": \"Only POST and GET are allowed\"}", 218 )) 219}