plug.ex (19386B)
1 defmodule Absinthe.Plug do 2 @moduledoc """ 3 A plug for using [Absinthe](https://hex.pm/packages/absinthe) (GraphQL). 4 5 ## Usage 6 7 In your router: 8 9 plug Plug.Parsers, 10 parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], 11 pass: ["*/*"], 12 json_decoder: Jason 13 14 plug Absinthe.Plug, 15 schema: MyAppWeb.Schema 16 17 If you want only `Absinthe.Plug` to serve a particular route, configure your 18 router like: 19 20 plug Plug.Parsers, 21 parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], 22 pass: ["*/*"], 23 json_decoder: Jason 24 25 forward "/api", 26 to: Absinthe.Plug, 27 init_opts: [schema: MyAppWeb.Schema] 28 29 See the documentation on `Absinthe.Plug.init/1` and the `Absinthe.Plug.opts` 30 type for information on the available options. 31 32 To add support for a GraphiQL interface, add a configuration for 33 `Absinthe.Plug.GraphiQL`: 34 35 forward "/graphiql", 36 to: Absinthe.Plug.GraphiQL, 37 init_opts: [schema: MyAppWeb.Schema] 38 39 For more information, see the API documentation for `Absinthe.Plug`. 40 41 ### Phoenix.Router 42 43 If you are using [Phoenix.Router](https://hexdocs.pm/phoenix/Phoenix.Router.html), `forward` expects different arguments: 44 45 #### Plug.Router 46 47 forward "/graphiql", 48 to: Absinthe.Plug.GraphiQL, 49 init_opts: [ 50 schema: MyAppWeb.Schema, 51 interface: :simple 52 ] 53 54 #### Phoenix.Router 55 56 forward "/graphiql", 57 Absinthe.Plug.GraphiQL, 58 schema: MyAppWeb.Schema, 59 interface: :simple 60 61 For more information see [Phoenix.Router.forward/4](https://hexdocs.pm/phoenix/Phoenix.Router.html#forward/4). 62 63 ## Before Send 64 65 If you need to set a value (like a cookie) on the connection after resolution 66 but before values are sent to the client, use the `:before_send` option: 67 68 ``` 69 plug Absinthe.Plug, 70 schema: MyApp.Schema, 71 before_send: {__MODULE__, :absinthe_before_send} 72 73 def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do 74 if auth_token = blueprint.execution.context[:auth_token] do 75 put_session(conn, :auth_token, auth_token) 76 else 77 conn 78 end 79 end 80 def absinthe_before_send(conn, _) do 81 conn 82 end 83 ``` 84 85 The `auth_token` can be placed in the context by using middleware after your 86 mutation resolve: 87 88 ``` 89 # mutation resolver 90 resolve fn args, _ -> 91 case authenticate(args) do 92 {:ok, token} -> {:ok, %{token: token}} 93 error -> error 94 end 95 end 96 # middleware afterward 97 middleware fn resolution, _ -> 98 with %{value: %{token: token}} <- resolution do 99 Map.update!(resolution, :context, fn ctx -> 100 Map.put(ctx, :auth_token, token) 101 end) 102 end 103 end 104 ``` 105 106 ## Included GraphQL Types 107 108 This package includes additional types for use in Absinthe GraphQL schema and 109 type modules. 110 111 See the documentation on `Absinthe.Plug.Types` for more information. 112 113 ## More Information 114 115 For more on configuring `Absinthe.Plug` and how GraphQL requests are made, 116 see [the guide](https://hexdocs.pm/absinthe/plug-phoenix.html) at 117 <http://absinthe-graphql.org>. 118 119 """ 120 121 @behaviour Plug 122 import Plug.Conn 123 require Logger 124 125 alias __MODULE__.Request 126 127 @init_options [ 128 :adapter, 129 :context, 130 :no_query_message, 131 :json_codec, 132 :pipeline, 133 :document_providers, 134 :schema, 135 :serializer, 136 :content_type, 137 :before_send, 138 :log_level, 139 :pubsub, 140 :analyze_complexity, 141 :max_complexity, 142 :transport_batch_payload_key 143 ] 144 @raw_options [ 145 :analyze_complexity, 146 :max_complexity 147 ] 148 149 @type function_name :: atom 150 151 @typedoc """ 152 - `:adapter` -- (Optional) Absinthe adapter to use (default: `Absinthe.Adapter.LanguageConventions`). 153 - `:context` -- (Optional) Initial value for the Absinthe context, available to resolvers. (default: `%{}`). 154 - `:no_query_message` -- (Optional) Message to return to the client if no query is provided (default: "No query document supplied"). 155 - `:json_codec` -- (Optional) A `module` or `{module, Keyword.t}` dictating which JSON codec should be used (default: `Jason`). The codec module should implement `encode!/2` (e.g., `module.encode!(body, opts)`). 156 - `:pipeline` -- (Optional) `{module, atom}` reference to a 2-arity function that will be called to generate the processing pipeline. (default: `{Absinthe.Plug, :default_pipeline}`). 157 - `:document_providers` -- (Optional) A `{module, atom}` reference to a 1-arity function that will be called to determine the document providers that will be used to process the request. (default: `{Absinthe.Plug, :default_document_providers}`, which configures `Absinthe.Plug.DocumentProvider.Default` as the lone document provider). A simple list of document providers can also be given. See `Absinthe.Plug.DocumentProvider` for more information about document providers, their role in procesing requests, and how you can define and configure your own. 158 - `:schema` -- (Required, if not handled by Mix.Config) The Absinthe schema to use. If a module name is not provided, `Application.get_env(:absinthe, :schema)` will be attempt to find one. 159 - `:serializer` -- (Optional) Similar to `:json_codec` but allows the use of serialization formats other than JSON, like MessagePack or Erlang Term Format. Defaults to whatever is set in `:json_codec`. 160 - `:content_type` -- (Optional) The content type of the response. Should probably be set if `:serializer` option is used. Defaults to `"application/json"`. 161 - `:before_send` -- (Optional) Set a value(s) on the connection after resolution but before values are sent to the client. 162 - `:log_level` -- (Optional) Set the logger level for Absinthe Logger. Defaults to `:debug`. 163 - `:pubsub` -- (Optional) Pub Sub module for Subscriptions. 164 - `:analyze_complexity` -- (Optional) Set whether to calculate the complexity of incoming GraphQL queries. 165 - `:max_complexity` -- (Optional) Set the maximum allowed complexity of the GraphQL query. If a document’s calculated complexity exceeds the maximum, resolution will be skipped and an error will be returned in the result detailing the calculated and maximum complexities. 166 - `:transport_batch_payload_key` -- (Optional) Set whether or not to nest Transport Batch request results in a `payload` key. Older clients expected this key to be present, but newer clients have dropped this pattern. (default: `true`) 167 168 """ 169 @type opts :: [ 170 schema: module, 171 adapter: module, 172 context: map, 173 json_codec: module | {module, Keyword.t()}, 174 pipeline: {module, atom}, 175 no_query_message: String.t(), 176 document_providers: 177 [Absinthe.Plug.DocumentProvider.t(), ...] 178 | Absinthe.Plug.DocumentProvider.t() 179 | {module, atom}, 180 analyze_complexity: boolean, 181 max_complexity: non_neg_integer | :infinity, 182 serializer: module | {module, Keyword.t()}, 183 content_type: String.t(), 184 before_send: {module, atom}, 185 log_level: Logger.level(), 186 pubsub: module | nil, 187 transport_batch_payload_key: boolean 188 ] 189 190 @doc """ 191 Serve an Absinthe GraphQL schema with the specified options. 192 193 ## Options 194 195 See the documentation for the `Absinthe.Plug.opts` type for details on the available options. 196 """ 197 @spec init(opts :: opts) :: Plug.opts() 198 def init(opts) do 199 adapter = Keyword.get(opts, :adapter, Absinthe.Adapter.LanguageConventions) 200 context = Keyword.get(opts, :context, %{}) 201 202 no_query_message = Keyword.get(opts, :no_query_message, "No query document supplied") 203 204 pipeline = Keyword.get(opts, :pipeline, {__MODULE__, :default_pipeline}) 205 206 document_providers = 207 Keyword.get(opts, :document_providers, {__MODULE__, :default_document_providers}) 208 209 json_codec = 210 case Keyword.get(opts, :json_codec, Jason) do 211 module when is_atom(module) -> %{module: module, opts: []} 212 other -> other 213 end 214 215 serializer = 216 case Keyword.get(opts, :serializer, json_codec) do 217 module when is_atom(module) -> %{module: module, opts: []} 218 {mod, opts} -> %{module: mod, opts: opts} 219 other -> other 220 end 221 222 content_type = Keyword.get(opts, :content_type, "application/json") 223 224 schema_mod = opts |> get_schema 225 226 raw_options = Keyword.take(opts, @raw_options) 227 log_level = Keyword.get(opts, :log_level, :debug) 228 229 pubsub = Keyword.get(opts, :pubsub, nil) 230 231 before_send = Keyword.get(opts, :before_send) 232 233 transport_batch_payload_key = Keyword.get(opts, :transport_batch_payload_key, true) 234 235 %{ 236 adapter: adapter, 237 context: context, 238 document_providers: document_providers, 239 json_codec: json_codec, 240 no_query_message: no_query_message, 241 pipeline: pipeline, 242 raw_options: raw_options, 243 schema_mod: schema_mod, 244 serializer: serializer, 245 content_type: content_type, 246 log_level: log_level, 247 pubsub: pubsub, 248 before_send: before_send, 249 transport_batch_payload_key: transport_batch_payload_key 250 } 251 end 252 253 defp get_schema(opts) do 254 default = Application.get_env(:absinthe, :schema) 255 schema = Keyword.get(opts, :schema, default) 256 257 valid_schema_module?(schema) || 258 raise ArgumentError, "#{inspect(schema)} is not a valid `Absinthe.Schema`" 259 260 schema 261 end 262 263 defp valid_schema_module?(module) do 264 with true <- is_atom(module), 265 {:module, _} <- Code.ensure_compiled(module), 266 true <- Absinthe.Schema in Keyword.get(module.__info__(:attributes), :behaviour, []) do 267 true 268 else 269 _ -> false 270 end 271 end 272 273 @doc false 274 def apply_before_send(conn, bps, %{before_send: {mod, fun}}) do 275 Enum.reduce(bps, conn, fn bp, conn -> 276 apply(mod, fun, [conn, bp]) 277 end) 278 end 279 280 def apply_before_send(conn, _, _) do 281 conn 282 end 283 284 @doc """ 285 Parses, validates, resolves, and executes the given Graphql Document 286 """ 287 @spec call(Plug.Conn.t(), map) :: Plug.Conn.t() | no_return 288 def call(conn, config) do 289 config = update_config(conn, config) 290 {conn, result} = conn |> execute(config) 291 292 case result do 293 {:input_error, msg} -> 294 conn 295 |> encode(400, error_result(msg), config) 296 297 {:ok, %{"subscribed" => topic}} -> 298 conn 299 |> subscribe(topic, config) 300 301 {:ok, %{data: _} = result} -> 302 conn 303 |> encode(200, result, config) 304 305 {:ok, %{errors: _} = result} -> 306 conn 307 |> encode(200, result, config) 308 309 {:ok, result} when is_list(result) -> 310 conn 311 |> encode(200, result, config) 312 313 {:error, {:http_method, text}, _} -> 314 conn 315 |> encode(405, error_result(text), config) 316 317 {:error, error, _} when is_binary(error) -> 318 conn 319 |> encode(500, error_result(error), config) 320 end 321 end 322 323 @doc false 324 def update_config(conn, config) do 325 config 326 |> update_config(:raw_options, conn) 327 |> update_config(:init_options, conn) 328 |> update_config(:pubsub, conn) 329 |> update_config(:context, conn) 330 end 331 332 defp update_config(config, :pubsub, conn) do 333 pubsub = config[:pubsub] || config.context[:pubsub] || conn.private[:phoenix_endpoint] 334 335 if pubsub do 336 put_in(config, [:context, :pubsub], pubsub) 337 else 338 config 339 end 340 end 341 342 defp update_config(config, :raw_options, %{private: %{absinthe: absinthe}}) do 343 raw_options = Map.take(absinthe, @raw_options) |> Map.to_list() 344 update_in(config.raw_options, &Keyword.merge(&1, raw_options)) 345 end 346 347 defp update_config(config, :init_options, %{private: %{absinthe: absinthe}}) do 348 Map.merge(config, Map.take(absinthe, @init_options -- [:context | @raw_options])) 349 end 350 351 defp update_config(config, :context, %{private: %{absinthe: %{context: context}}}) do 352 update_in(config.context, &Map.merge(&1, context)) 353 end 354 355 defp update_config(config, _, _conn) do 356 config 357 end 358 359 def subscribe(conn, topic, %{context: %{pubsub: pubsub}} = config) do 360 pubsub.subscribe(topic) 361 362 conn 363 |> put_resp_header("content-type", "text/event-stream") 364 |> send_chunked(200) 365 |> subscribe_loop(topic, config) 366 end 367 368 def subscribe_loop(conn, topic, config) do 369 receive do 370 %{event: "subscription:data", payload: %{result: result}} -> 371 case chunk(conn, "#{encode_json!(result, config)}\n\n") do 372 {:ok, conn} -> 373 subscribe_loop(conn, topic, config) 374 375 {:error, :closed} -> 376 Absinthe.Subscription.unsubscribe(config.context.pubsub, topic) 377 conn 378 end 379 380 :close -> 381 Absinthe.Subscription.unsubscribe(config.context.pubsub, topic) 382 conn 383 after 384 30_000 -> 385 case chunk(conn, ":ping\n\n") do 386 {:ok, conn} -> 387 subscribe_loop(conn, topic, config) 388 389 {:error, :closed} -> 390 Absinthe.Subscription.unsubscribe(config.context.pubsub, topic) 391 conn 392 end 393 end 394 end 395 396 @doc """ 397 Sets the options for a given GraphQL document execution. 398 399 ## Examples 400 401 iex> Absinthe.Plug.put_options(conn, context: %{current_user: user}) 402 %Plug.Conn{} 403 """ 404 @spec put_options(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() 405 def put_options(%Plug.Conn{private: %{absinthe: absinthe}} = conn, opts) do 406 opts = Map.merge(absinthe, Enum.into(opts, %{})) 407 Plug.Conn.put_private(conn, :absinthe, opts) 408 end 409 410 def put_options(conn, opts) do 411 Plug.Conn.put_private(conn, :absinthe, Enum.into(opts, %{})) 412 end 413 414 @doc """ 415 Adds key-value pairs into Absinthe context. 416 417 ## Examples 418 419 iex> Absinthe.Plug.assign_context(conn, current_user: user) 420 %Plug.Conn{} 421 """ 422 @spec assign_context(Plug.Conn.t(), Keyword.t() | map) :: Plug.Conn.t() 423 def assign_context(%Plug.Conn{private: %{absinthe: absinthe}} = conn, assigns) do 424 context = 425 absinthe 426 |> Map.get(:context, %{}) 427 |> Map.merge(Map.new(assigns)) 428 429 put_options(conn, context: context) 430 end 431 432 def assign_context(conn, assigns) do 433 put_options(conn, context: Map.new(assigns)) 434 end 435 436 @doc """ 437 Same as `assign_context/2` except one key-value pair is assigned. 438 """ 439 @spec assign_context(Plug.Conn.t(), atom, any) :: Plug.Conn.t() 440 def assign_context(conn, key, value) do 441 assign_context(conn, [{key, value}]) 442 end 443 444 @doc false 445 @spec execute(Plug.Conn.t(), map) :: {Plug.Conn.t(), any} 446 def execute(conn, config) do 447 conn_info = %{ 448 conn_private: (conn.private[:absinthe] || %{}) |> Map.put(:http_method, conn.method) 449 } 450 451 with {:ok, conn, request} <- Request.parse(conn, config), 452 {:ok, request} <- ensure_processable(request, config) do 453 run_request(request, conn, conn_info, config) 454 else 455 result -> 456 {conn, result} 457 end 458 end 459 460 @doc false 461 @spec ensure_processable(Request.t(), map) :: {:ok, Request.t()} | {:input_error, String.t()} 462 def ensure_processable(request, config) do 463 with {:ok, request} <- ensure_documents(request, config) do 464 ensure_document_provider(request) 465 end 466 end 467 468 @spec ensure_documents(Request.t(), map) :: {:ok, Request.t()} | {:input_error, String.t()} 469 defp ensure_documents(%{queries: []}, config) do 470 {:input_error, config.no_query_message} 471 end 472 473 defp ensure_documents(%{queries: queries} = request, config) do 474 Enum.reduce_while(queries, {:ok, request}, fn query, _acc -> 475 query_status = 476 case query do 477 {:input_error, error_msg} -> {:input_error, error_msg} 478 query -> ensure_document(query, config) 479 end 480 481 case query_status do 482 {:ok, _query} -> {:cont, {:ok, request}} 483 {:input_error, error_msg} -> {:halt, {:input_error, error_msg}} 484 end 485 end) 486 end 487 488 @spec ensure_document(Request.Query.t(), map) :: 489 {:ok, Request.Query.t()} | {:input_error, String.t()} 490 defp ensure_document(%{document: nil}, config) do 491 {:input_error, config.no_query_message} 492 end 493 494 defp ensure_document(%{document: _} = query, _) do 495 {:ok, query} 496 end 497 498 @spec ensure_document_provider(Request.t()) :: {:ok, Request.t()} | {:input_error, String.t()} 499 defp ensure_document_provider(%{queries: queries} = request) do 500 if Enum.all?(queries, &Map.has_key?(&1, :document_provider)) do 501 {:ok, request} 502 else 503 {:input_error, "No document provider found to handle this request"} 504 end 505 end 506 507 @doc false 508 def run_request(%{batch: true, queries: queries} = request, conn, conn_info, config) do 509 Request.log(request, config.log_level) 510 {conn, results} = Absinthe.Plug.Batch.Runner.run(queries, conn, conn_info, config) 511 512 results = 513 results 514 |> Enum.zip(request.extra_keys) 515 |> Enum.map(fn {result, extra_keys} -> 516 result = 517 if config.transport_batch_payload_key, 518 do: %{payload: result}, 519 else: result 520 521 Map.merge(extra_keys, result) 522 end) 523 524 {conn, {:ok, results}} 525 end 526 527 def run_request(%{batch: false, queries: [query]} = request, conn, conn_info, config) do 528 Request.log(request, config.log_level) 529 run_query(query, conn, conn_info, config) 530 end 531 532 defp run_query(query, conn, conn_info, config) do 533 %{document: document, pipeline: pipeline} = 534 Request.Query.add_pipeline(query, conn_info, config) 535 536 case Absinthe.Pipeline.run(document, pipeline) do 537 {:ok, %{result: result} = bp, _} -> 538 conn = apply_before_send(conn, [bp], config) 539 {conn, {:ok, result}} 540 541 val -> 542 {conn, val} 543 end 544 end 545 546 # 547 # PIPELINE 548 # 549 550 @doc """ 551 The default pipeline used to process GraphQL documents. 552 553 This consists of Absinthe's default pipeline (as returned by `Absinthe.Pipeline.for_document/1`), 554 with the `Absinthe.Plug.Validation.HTTPMethod` phase inserted to ensure that the correct 555 HTTP verb is being used for the GraphQL operation type. 556 """ 557 @spec default_pipeline(map, Keyword.t()) :: Absinthe.Pipeline.t() 558 def default_pipeline(config, pipeline_opts) do 559 config.schema_mod 560 |> Absinthe.Pipeline.for_document(pipeline_opts) 561 |> Absinthe.Pipeline.insert_after( 562 Absinthe.Phase.Document.CurrentOperation, 563 [ 564 {Absinthe.Plug.Validation.HTTPMethod, method: config.conn_private.http_method} 565 ] 566 ) 567 end 568 569 # 570 # DOCUMENT PROVIDERS 571 # 572 573 @doc """ 574 The default list of document providers that are enabled. 575 576 This consists of a single document provider, `Absinthe.Plug.DocumentProvider.Default`, which 577 supports ad hoc GraphQL documents provided directly within the request. 578 579 For more information about document providers, see `Absinthe.Plug.DocumentProvider`. 580 """ 581 @spec default_document_providers(map) :: [Absinthe.Plug.DocumentProvider.t()] 582 def default_document_providers(_) do 583 [Absinthe.Plug.DocumentProvider.Default] 584 end 585 586 # 587 # SERIALIZATION 588 # 589 590 @doc false 591 @spec encode(Plug.Conn.t(), 200 | 400 | 405 | 500, map | list, map) :: Plug.Conn.t() | no_return 592 def encode(conn, status, body, %{ 593 serializer: %{module: mod, opts: opts}, 594 content_type: content_type 595 }) do 596 conn 597 |> put_resp_content_type(content_type) 598 |> send_resp(status, mod.encode!(body, opts)) 599 end 600 601 @doc false 602 def encode_json!(value, %{json_codec: json_codec}) do 603 json_codec.module.encode!(value, json_codec.opts) 604 end 605 606 @doc false 607 def error_result(message), do: %{"errors" => [%{"message" => message}]} 608 end