forked from
slices.network/quickslice
Auto-indexing service and GraphQL API for AT Protocol Records
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}