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