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