Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 160 lines 4.3 kB view raw
1/// GraphQL-WS Protocol Implementation 2/// 3/// Implements the graphql-ws WebSocket subprotocol for GraphQL subscriptions 4/// Spec: https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md 5import gleam/dict.{type Dict} 6import gleam/dynamic/decode 7import gleam/json 8import gleam/option.{type Option, None, Some} 9import gleam/result 10import gleam/string 11 12/// Client-to-server message types 13/// Server-to-client message types 14pub type Message { 15 // Client messages 16 ConnectionInit(payload: Dict(String, String)) 17 Subscribe(id: String, query: String, variables: Option(String)) 18 Complete(id: String) 19 Ping 20 Pong 21 22 // Server messages 23 ConnectionAck 24 Next(id: String, data: String) 25 ErrorMessage(id: String, message: String) 26} 27 28/// Parse a JSON string into a GraphQL-WS message 29pub fn parse_message(json_str: String) -> Result(Message, String) { 30 // First parse to extract the type field 31 let type_decoder = { 32 use message_type <- decode.field("type", decode.string) 33 decode.success(message_type) 34 } 35 36 use message_type <- result.try( 37 json.parse(json_str, type_decoder) 38 |> result.map_error(fn(_) { "Missing or invalid 'type' field" }), 39 ) 40 41 case message_type { 42 "connection_init" -> { 43 // Try to extract payload, but it's optional 44 let payload = 45 extract_string_payload(json_str) |> option.unwrap(dict.new()) 46 Ok(ConnectionInit(payload)) 47 } 48 49 "subscribe" -> { 50 let subscribe_decoder = { 51 use id <- decode.field("id", decode.string) 52 use query <- decode.subfield(["payload", "query"], decode.string) 53 decode.success(#(id, query)) 54 } 55 56 use #(id, query) <- result.try( 57 json.parse(json_str, subscribe_decoder) 58 |> result.map_error(fn(_) { 59 "Subscribe message missing required fields" 60 }), 61 ) 62 63 // Variables are optional - try to extract them 64 let vars = extract_variables_from_json(json_str) 65 66 Ok(Subscribe(id, query, vars)) 67 } 68 69 "complete" -> { 70 let complete_decoder = { 71 use id <- decode.field("id", decode.string) 72 decode.success(id) 73 } 74 75 use id <- result.try( 76 json.parse(json_str, complete_decoder) 77 |> result.map_error(fn(_) { "Complete message missing 'id' field" }), 78 ) 79 80 Ok(Complete(id)) 81 } 82 83 "ping" -> Ok(Ping) 84 85 "pong" -> Ok(Pong) 86 87 _ -> { 88 let err_msg = "Unknown message type: " <> message_type 89 Error(err_msg) 90 } 91 } 92} 93 94/// Format a GraphQL-WS message as JSON string 95pub fn format_message(message: Message) -> String { 96 case message { 97 ConnectionAck -> "{\"type\":\"connection_ack\"}" 98 99 Next(id, data) -> 100 // data is already a JSON string containing the GraphQL response 101 "{\"id\":\"" <> id <> "\",\"type\":\"next\",\"payload\":" <> data <> "}" 102 103 ErrorMessage(id, msg) -> { 104 let escaped_msg = escape_json_string(msg) 105 "{\"id\":\"" 106 <> id 107 <> "\",\"type\":\"error\",\"payload\":[{\"message\":\"" 108 <> escaped_msg 109 <> "\"}]}" 110 } 111 112 Complete(id) -> "{\"id\":\"" <> id <> "\",\"type\":\"complete\"}" 113 114 Pong -> "{\"type\":\"pong\"}" 115 116 Ping -> "{\"type\":\"ping\"}" 117 118 // These are client messages, shouldn't normally be formatted by server 119 ConnectionInit(_) -> "{\"type\":\"connection_init\"}" 120 121 Subscribe(id, _, _) -> "{\"id\":\"" <> id <> "\",\"type\":\"subscribe\"}" 122 } 123} 124 125// Helper to escape JSON strings 126fn escape_json_string(str: String) -> String { 127 str 128 |> string.replace("\\", "\\\\") 129 |> string.replace("\"", "\\\"") 130 |> string.replace("\n", "\\n") 131 |> string.replace("\r", "\\r") 132 |> string.replace("\t", "\\t") 133} 134 135// Helper to extract payload field as dict of strings 136fn extract_string_payload(json_str: String) -> Option(Dict(String, String)) { 137 let decoder = { 138 use payload <- decode.field( 139 "payload", 140 decode.dict(decode.string, decode.string), 141 ) 142 decode.success(payload) 143 } 144 145 json.parse(json_str, decoder) 146 |> result.map(Some) 147 |> result.unwrap(None) 148} 149 150// Helper to extract variables from subscribe message 151fn extract_variables_from_json(json_str: String) -> Option(String) { 152 let vars_decoder = { 153 use vars <- decode.subfield(["payload", "variables"], decode.string) 154 decode.success(vars) 155 } 156 157 json.parse(json_str, vars_decoder) 158 |> result.map(Some) 159 |> result.unwrap(None) 160}