package dream
Source file graphql.ml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344(* This file is part of Dream, released under the MIT license. See LICENSE.md for details, or visit https://github.com/aantron/dream. Copyright 2021 Anton Bachin *) module Dream = Dream__pure.Inmost (* This GraphQL handler supports two transports, i.e. two GraphQL "wire" protocols: - HTTP requests/responses for queries and mutations. See https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md - WebSockets for queries, mutations, and subscriptions. See https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md *) let log = Dream__middleware.Log.sub_log "dream.graphql" (* Shared between HTTP and WebSocket transport. *) let make_error message = `Assoc [ "errors", `List [ `Assoc [ "message", `String message ] ] ] let run_query make_context schema request json = let module Y = Yojson.Basic.Util in let query = json |> Y.member "query" |> Y.to_string_option and operation_name = json |> Y.member "operationName" |> Y.to_string_option and variables = json |> Y.member "variables" |> Option.some in match query with | None -> Lwt.return (Error (make_error "No query")) | Some query -> match Graphql_parser.parse query with | Error message -> Lwt.return (Error (make_error message)) | Ok query -> (* TODO Consider being more strict here, allowing only `Assoc and `Null. *) let variables = match variables with | Some (`Assoc _ as json) -> (Yojson.Basic.Util.to_assoc json :> (string * Graphql_parser.const_value) list) |> Option.some | _ -> None in let%lwt context = make_context request in Graphql_lwt.Schema.execute ?variables ?operation_name schema context query (* WebSocket transport. *) let operation_id json = Yojson.Basic.Util.(json |> member "id" |> to_string_option) let close_and_clean ?code subscriptions websocket = match%lwt Dream.close_websocket ?code websocket with | _ -> Hashtbl.iter (fun _ close -> close ()) subscriptions; Lwt.return_unit | exception _ -> Hashtbl.iter (fun _ close -> close ()) subscriptions; Lwt.return_unit let ack_message = `Assoc [ "type", `String "connection_ack"; ] |> Yojson.Basic.to_string let data_message id payload = `Assoc [ "type", `String "next"; "id", `String id; "payload", payload; ] |> Yojson.Basic.to_string let error_message id json = `Assoc [ "type", `String "error"; "id", `String id; "payload", json |> Yojson.Basic.Util.member "errors"; ] |> Yojson.Basic.to_string let complete_message id = `Assoc [ "type", `String "complete"; "id", `String id; ] |> Yojson.Basic.to_string (* TODO Take care to pass around the request Lwt.key in async, etc. *) (* TODO Test client complete racing against a stream. *) let handle_over_websocket make_context schema subscriptions request websocket = let rec loop inited = match%lwt Dream.receive websocket with | None -> log.info (fun log -> log ~request "GraphQL WebSocket closed by client"); close_and_clean subscriptions websocket | Some message -> log.debug (fun log -> log ~request "Message '%s'" message); (* TODO Avoid using exceptions here. *) match Yojson.Basic.from_string message with | exception _ -> log.warning (fun log -> log ~request "GraphQL message is not JSON"); close_and_clean subscriptions websocket ~code:4400 | json -> match Yojson.Basic.Util.(json |> member "type" |> to_string_option) with | None -> log.warning (fun log -> log ~request "GraphQL message lacks a type"); close_and_clean subscriptions websocket ~code:4400 | Some message_type -> match message_type with | "connection_init" -> if inited then begin log.warning (fun log -> log ~request "Duplicate connection_init"); close_and_clean subscriptions websocket ~code:4429 end else begin let%lwt () = Dream.send websocket ack_message in loop true end | "complete" -> if not inited then begin log.warning (fun log -> log ~request "complete before connection_init"); close_and_clean subscriptions websocket ~code:4401 end else begin match operation_id json with | None -> log.warning (fun log -> log ~request "client complete: operation id missing"); close_and_clean subscriptions websocket ~code:4400 | Some id -> begin match Hashtbl.find_opt subscriptions id with | None -> () | Some close -> close () end; loop inited end | "subscribe" -> if not inited then begin log.warning (fun log -> log ~request "subscribe before connection_init"); close_and_clean subscriptions websocket ~code:4401 end else begin match operation_id json with | None -> log.warning (fun log -> log ~request "subscribe: operation id missing"); close_and_clean subscriptions websocket ~code:4400 | Some id -> let payload = json |> Yojson.Basic.Util.member "payload" in Lwt.async begin fun () -> let subscribed = ref false in try%lwt match%lwt run_query make_context schema request payload with | Error json -> log.warning (fun log -> log ~request "subscribe: error %s" (Yojson.Basic.to_string json)); Dream.send websocket (error_message id json) (* It's not clear that this case ever occurs, because graphql-ws is only used for subscriptions, at the protocol level. *) | Ok (`Response json) -> let%lwt () = Dream.send websocket (data_message id json) in let%lwt () = Dream.send websocket (complete_message id) in Lwt.return_unit | Ok (`Stream (stream, close)) -> match Hashtbl.mem subscriptions id with | true -> log.warning (fun log -> log ~request "subscribe: duplicate operation id"); close_and_clean subscriptions websocket ~code:4409 | false -> Hashtbl.replace subscriptions id close; subscribed := true; let%lwt () = stream |> Lwt_stream.iter_s (function | Ok json -> Dream.send websocket (data_message id json) | Error json -> log.warning (fun log -> log ~request "Subscription: error %s" (Yojson.Basic.to_string json)); Dream.send websocket (error_message id json)) in let%lwt () = Dream.send websocket (complete_message id) in Hashtbl.remove subscriptions id; Lwt.return_unit with exn -> let backtrace = Printexc.get_backtrace () in log.error (fun log -> log ~request "Exception while handling WebSocket message:"); log.error (fun log -> log ~request "%s" (Printexc.to_string exn)); backtrace |> Dream__middleware.Log.iter_backtrace (fun line -> log.error (fun log -> log ~request "%s" line)); try%lwt let%lwt () = Dream.send websocket (error_message id (make_error "Internal Server Error")) in if !subscribed then Dream.send websocket (complete_message id) else Lwt.return_unit with _ -> Lwt.return_unit end; loop inited end | message_type -> log.warning (fun log -> log ~request "Unknown WebSocket message type '%s'" message_type); close_and_clean subscriptions websocket ~code:4400 in loop false (* HTTP transport. Supports either POST requests carrying a GraphQL query, or GET requests carrying WebSocket upgrade headers. *) let graphql make_context schema = fun request -> match Dream.method_ request with | `GET -> let upgrade = Dream.header "Upgrade" request and protocol = Dream.header "Sec-WebSocket-Protocol" request in begin match upgrade, protocol with | Some "websocket", Some "graphql-transport-ws" -> Dream.websocket ~headers:["Sec-WebSocket-Protocol", "graphql-transport-ws"] (handle_over_websocket make_context schema (Hashtbl.create 16) request) | _ -> log.warning (fun log -> log ~request "Upgrade: websocket header missing"); Dream.empty `Not_Found end | `POST -> begin match Dream.header "Content-Type" request with | Some "application/json" -> let%lwt body = Dream.body request in (* TODO This almost certainly raises exceptions... *) let json = Yojson.Basic.from_string body in begin match%lwt run_query make_context schema request json with | Error json -> Yojson.Basic.to_string json |> Dream.json | Ok (`Response json) -> Yojson.Basic.to_string json |> Dream.json | Ok (`Stream _) -> make_error "Subscriptions and streaming should use WebSocket transport" |> Yojson.Basic.to_string |> Dream.json end | _ -> log.warning (fun log -> log ~request "Content-Type not 'application/json'"); Dream.empty `Bad_Request end | method_ -> log.error (fun log -> log ~request "Method %s; must be GET or POST" (Dream.method_to_string method_)); Dream.empty `Not_Found let graphiql ?(default_query = "") graphql_endpoint = begin match String.index_opt graphql_endpoint '"' with | None -> () | Some _ -> log.error (fun log -> log "GraphQL endpoint route '%s' contains '\"'" graphql_endpoint); log.error (fun log -> log "If intentional, please open an issue about supporting this"); log.error (fun log -> log "https://github.com/aantron/dream/issues") end; let html = lazy begin Dream__graphiql.content |> Str.(global_replace (regexp (quote "%%ENDPOINT%%")) graphql_endpoint) |> Str.(global_replace (regexp (quote "%%DEFAULT_QUERY%%")) default_query) end in fun _request -> Dream.html (Lazy.force html)