graphiql.ex (12747B)
1 defmodule Absinthe.Plug.GraphiQL do 2 @moduledoc """ 3 Provides a GraphiQL interface. 4 5 6 ## Examples 7 8 The examples here are shown in 9 10 Serve the GraphiQL "advanced" interface at `/graphiql`, but only in 11 development: 12 13 if Mix.env == :dev do 14 forward "/graphiql", 15 to: Absinthe.Plug.GraphiQL, 16 init_opts: [schema: MyAppWeb.Schema] 17 end 18 19 Use the "simple" interface (original GraphiQL) instead: 20 21 forward "/graphiql", 22 to: Absinthe.Plug.GraphiQL, 23 init_opts: [ 24 schema: MyAppWeb.Schema, 25 interface: :simple 26 ] 27 28 Finally there is also support for GraphiQL Playground 29 https://github.com/graphcool/graphql-playground 30 31 forward "/graphiql", 32 to: Absinthe.Plug.GraphiQL, 33 init_opts: [ 34 schema: MyAppWeb.Schema, 35 interface: :playground 36 ] 37 38 39 ## Interface Selection 40 41 The GraphiQL interface can be switched using the `:interface` option. 42 43 - `:advanced` (default) will serve the [GraphiQL Workspace](https://github.com/OlegIlyenko/graphiql-workspace) interface from Oleg Ilyenko. 44 - `:simple` will serve the original [GraphiQL](https://github.com/graphql/graphiql) interface from Facebook. 45 - `:playground` will serve the [GraphQL Playground](https://github.com/graphcool/graphql-playground) interface from Graphcool. 46 47 See `Absinthe.Plug` for the other options. 48 49 ## Default Headers 50 51 You can optionally provide default headers if the advanced interface (GraphiQL Workspace) is selected. 52 Note that you may have to clean up your existing workspace by clicking the trashcan icon in order to see the newly set default headers. 53 54 forward "/graphiql", 55 to: Absinthe.Plug.GraphiQL, 56 init_opts: [ 57 schema: MyAppWeb.Schema, 58 default_headers: {__MODULE__, :graphiql_headers} 59 ] 60 61 def graphiql_headers do 62 %{ 63 "X-CSRF-Token" => Plug.CSRFProtection.get_csrf_token(), 64 "X-Foo" => "Bar" 65 } 66 end 67 68 You can also provide a function that takes a conn argument if you need to access connection data 69 (e.g. if you need to set an Authorization header based on the currently logged-in user). 70 71 def graphiql_headers(conn) do 72 %{ 73 "Authorization" => "Bearer " <> conn.assigns[:token] 74 } 75 end 76 77 ## Default URL 78 79 You can also optionally set the default URL to be used for sending the queries to. 80 This only applies to the advanced interface (GraphiQL Workspace) and the GraphQL Playground. 81 82 forward "/graphiql", 83 to: Absinthe.Plug.GraphiQL, 84 init_opts: [ 85 schema: MyAppWeb.Schema, 86 default_url: "https://api.mydomain.com/graphql" 87 ] 88 89 This option also accepts a function: 90 91 forward "/graphiql", 92 to: Absinthe.Plug.GraphiQL, 93 init_opts: [ 94 schema: MyAppWeb.Schema, 95 default_url: {__MODULE__, :graphiql_default_url} 96 ] 97 98 def graphiql_default_url(conn) do 99 conn.assigns[:graphql_url] 100 end 101 102 ## Socket URL 103 104 You can also optionally set the default websocket URL to be used for subscriptions. 105 This only applies to the advanced interface (GraphiQL Workspace) and the GraphQL Playground. 106 107 forward "/graphiql", 108 to: Absinthe.Plug.GraphiQL, 109 init_opts: [ 110 schema: MyAppWeb.Schema, 111 socket_url: "wss://api.mydomain.com/socket" 112 ] 113 114 This option also accepts a function: 115 116 forward "/graphiql", 117 to: Absinthe.Plug.GraphiQL, 118 init_opts: [ 119 schema: MyAppWeb.Schema, 120 socket_url: {__MODULE__, :graphiql_socket_url} 121 ] 122 123 def graphiql_socket_url(conn) do 124 conn.assigns[:graphql_socket_url] 125 end 126 """ 127 128 require EEx 129 130 @graphiql_template_path Path.join(__DIR__, "graphiql") 131 132 EEx.function_from_file( 133 :defp, 134 :graphiql_html, 135 Path.join(@graphiql_template_path, "graphiql.html.eex"), 136 [:query_string, :variables_string, :result_string, :socket_url, :assets] 137 ) 138 139 EEx.function_from_file( 140 :defp, 141 :graphiql_workspace_html, 142 Path.join(@graphiql_template_path, "graphiql_workspace.html.eex"), 143 [:query_string, :variables_string, :default_headers, :default_url, :socket_url, :assets] 144 ) 145 146 EEx.function_from_file( 147 :defp, 148 :graphiql_playground_html, 149 Path.join(@graphiql_template_path, "graphiql_playground.html.eex"), 150 [:default_url, :socket_url, :assets] 151 ) 152 153 @behaviour Plug 154 155 import Plug.Conn 156 157 @type opts :: [ 158 schema: atom, 159 adapter: atom, 160 path: binary, 161 context: map, 162 json_codec: atom | {atom, Keyword.t()}, 163 interface: :playground | :advanced | :simple, 164 default_headers: {module, atom}, 165 default_url: binary, 166 assets: Keyword.t(), 167 socket: module, 168 socket_url: binary 169 ] 170 171 @doc false 172 @spec init(opts :: opts) :: map 173 def init(opts) do 174 assets = Absinthe.Plug.GraphiQL.Assets.get_assets() 175 176 opts 177 |> Absinthe.Plug.init() 178 |> Map.put(:interface, Keyword.get(opts, :interface) || :advanced) 179 |> Map.put(:default_headers, Keyword.get(opts, :default_headers)) 180 |> Map.put(:default_url, Keyword.get(opts, :default_url)) 181 |> Map.put(:assets, assets) 182 |> Map.put(:socket, Keyword.get(opts, :socket)) 183 |> Map.put(:socket_url, Keyword.get(opts, :socket_url)) 184 |> Map.put(:default_query, Keyword.get(opts, :default_query, "")) 185 |> set_pipeline 186 end 187 188 @doc false 189 def call(conn, config) do 190 case html?(conn) do 191 true -> do_call(conn, config) 192 _ -> Absinthe.Plug.call(conn, config) 193 end 194 end 195 196 defp html?(conn) do 197 Plug.Conn.get_req_header(conn, "accept") 198 |> List.first() 199 |> case do 200 string when is_binary(string) -> 201 String.contains?(string, "text/html") 202 203 _ -> 204 false 205 end 206 end 207 208 defp do_call(conn, %{interface: interface} = config) do 209 config = 210 config 211 |> handle_default_headers(conn) 212 |> put_config_value(:default_url, conn) 213 |> handle_socket_url(conn) 214 215 with {:ok, conn, request} <- Absinthe.Plug.Request.parse(conn, config), 216 {:process, request} <- select_mode(request), 217 {:ok, request} <- Absinthe.Plug.ensure_processable(request, config), 218 :ok <- Absinthe.Plug.Request.log(request, config.log_level) do 219 conn_info = %{ 220 conn_private: (conn.private[:absinthe] || %{}) |> Map.put(:http_method, conn.method) 221 } 222 223 {conn, result} = Absinthe.Plug.run_request(request, conn, conn_info, config) 224 225 case result do 226 {:ok, result} -> 227 # GraphiQL doesn't batch requests, so the first query is the only one 228 query = hd(request.queries) 229 {:ok, conn, result, query.variables, query.document || ""} 230 231 {:error, {:http_method, _}, _} -> 232 query = hd(request.queries) 233 {:http_method_error, query.variables, query.document || ""} 234 235 other -> 236 other 237 end 238 end 239 |> case do 240 {:ok, conn, result, variables, query} -> 241 query = query |> js_escape 242 243 var_string = 244 variables 245 |> config.json_codec.module.encode!(pretty: true) 246 |> js_escape 247 248 result = 249 result 250 |> config.json_codec.module.encode!(pretty: true) 251 |> js_escape 252 253 config = 254 %{ 255 query: query, 256 var_string: var_string, 257 result: result 258 } 259 |> Map.merge(config) 260 261 conn 262 |> render_interface(interface, config) 263 264 {:input_error, msg} -> 265 conn 266 |> send_resp(400, msg) 267 268 :start_interface -> 269 conn 270 |> render_interface(interface, config) 271 272 {:http_method_error, variables, query} -> 273 query = query |> js_escape 274 275 var_string = 276 variables 277 |> config.json_codec.module.encode!(pretty: true) 278 |> js_escape 279 280 config = 281 %{ 282 query: query, 283 var_string: var_string 284 } 285 |> Map.merge(config) 286 287 conn 288 |> render_interface(interface, config) 289 290 {:error, error, _} when is_binary(error) -> 291 conn 292 |> send_resp(500, error) 293 end 294 end 295 296 defp set_pipeline(config) do 297 config 298 |> Map.put(:additional_pipeline, config.pipeline) 299 |> Map.put(:pipeline, {__MODULE__, :pipeline}) 300 end 301 302 @doc false 303 def pipeline(config, opts) do 304 {module, fun} = config.additional_pipeline 305 306 apply(module, fun, [config, opts]) 307 |> Absinthe.Pipeline.insert_after( 308 Absinthe.Phase.Document.CurrentOperation, 309 [ 310 Absinthe.GraphiQL.Validation.NoSubscriptionOnHTTP 311 ] 312 ) 313 end 314 315 @spec select_mode(request :: Absinthe.Plug.Request.t()) :: 316 :start_interface | {:process, Absinthe.Plug.Request.t()} 317 defp select_mode(%{queries: [%Absinthe.Plug.Request.Query{document: nil}]}), 318 do: :start_interface 319 320 defp select_mode(request), do: {:process, request} 321 322 defp find_socket_path(conn, socket) do 323 if endpoint = conn.private[:phoenix_endpoint] do 324 Enum.find_value(endpoint.__sockets__, :error, fn 325 # Phoenix 1.4 326 {path, ^socket, _opts} -> {:ok, path} 327 # Phoenix <= 1.3 328 {path, ^socket} -> {:ok, path} 329 _ -> false 330 end) 331 else 332 :error 333 end 334 end 335 336 @render_defaults %{var_string: "", results: ""} 337 338 @spec render_interface(Plug.Conn.t(), :advanced | :simple | :playground, map()) :: 339 Plug.Conn.t() 340 defp render_interface(conn, interface, opts) 341 342 defp render_interface(conn, :simple, opts) do 343 opts = opts_with_default(opts) 344 345 graphiql_html( 346 opts[:query], 347 opts[:var_string], 348 opts[:result], 349 opts[:socket_url], 350 opts[:assets] 351 ) 352 |> rendered(conn) 353 end 354 355 defp render_interface(conn, :advanced, opts) do 356 opts = opts_with_default(opts) 357 358 graphiql_workspace_html( 359 opts[:query], 360 opts[:var_string], 361 opts[:default_headers], 362 default_url(opts[:default_url]), 363 opts[:socket_url], 364 opts[:assets] 365 ) 366 |> rendered(conn) 367 end 368 369 defp render_interface(conn, :playground, opts) do 370 opts = opts_with_default(opts) 371 372 graphiql_playground_html( 373 default_url(opts[:default_url]), 374 opts[:socket_url], 375 opts[:assets] 376 ) 377 |> rendered(conn) 378 end 379 380 defp opts_with_default(opts) do 381 defaults = Map.put(@render_defaults, :query, opts[:default_query]) 382 383 Map.merge(defaults, opts) 384 end 385 386 defp default_url(nil), do: "window.location.origin + window.location.pathname" 387 defp default_url(url), do: "'#{url}'" 388 389 @spec rendered(String.t(), Plug.Conn.t()) :: Plug.Conn.t() 390 defp rendered(html, conn) do 391 conn 392 |> put_resp_content_type("text/html") 393 |> send_resp(200, html) 394 end 395 396 defp js_escape(string) do 397 string 398 |> String.replace(~r/\n/, "\\n") 399 |> String.replace(~r/'/, "\\'") 400 end 401 402 defp handle_default_headers(config, conn) do 403 case get_config_val(config, :default_headers, conn) do 404 nil -> 405 Map.put(config, :default_headers, "[]") 406 407 val when is_map(val) -> 408 header_string = 409 val 410 |> Enum.map(fn {k, v} -> %{"name" => k, "value" => v} end) 411 |> config.json_codec.module.encode!(pretty: true) 412 413 Map.put(config, :default_headers, header_string) 414 415 val -> 416 raise "invalid default headers: #{inspect(val)}" 417 end 418 end 419 420 defp function_arity(module, fun) do 421 Enum.find([1, 0], nil, &function_exported?(module, fun, &1)) 422 end 423 424 defp put_config_value(config, key, conn) do 425 case get_config_val(config, key, conn) do 426 nil -> 427 config 428 429 val when is_binary(val) -> 430 Map.put(config, key, val) 431 432 val -> 433 raise "invalid #{key}: #{inspect(val)}" 434 end 435 end 436 437 defp get_config_val(config, key, conn) do 438 case Map.get(config, key) do 439 {module, fun} when is_atom(fun) -> 440 case function_arity(module, fun) do 441 1 -> 442 apply(module, fun, [conn]) 443 444 0 -> 445 apply(module, fun, []) 446 447 _ -> 448 raise "function for #{key}: {#{module}, #{fun}} is not exported with arity 1 or 0" 449 end 450 451 val -> 452 val 453 end 454 end 455 456 defp handle_socket_url(config, conn) do 457 config 458 |> put_config_value(:socket_url, conn) 459 |> normalize_socket_url(conn) 460 end 461 462 defp normalize_socket_url(%{socket_url: nil, socket: socket} = config, conn) do 463 url = 464 with {:ok, socket_path} <- find_socket_path(conn, socket) do 465 "`${protocol}//${window.location.host}#{socket_path}`" 466 else 467 _ -> "''" 468 end 469 470 %{config | socket_url: url} 471 end 472 473 defp normalize_socket_url(%{socket_url: url} = config, _) do 474 %{config | socket_url: "'#{url}'"} 475 end 476 end