Packages

ash_graphql

1.10.1

The extension for building GraphQL APIs with Ash

Security advisory: This version has known vulnerabilities. View advisories

Current section

Files

Jump to
ash_graphql lib igniter.ex
Raw

lib/igniter.ex

# SPDX-FileCopyrightText: 2020 ash_graphql contributors <https://github.com/ash-project/ash_graphql/graphs/contributors>
#
# SPDX-License-Identifier: MIT

if Code.ensure_loaded?(Igniter) do
  defmodule AshGraphql.Igniter do
    @moduledoc "Codemods and utilities for working with AshGraphql & Igniter"

    @doc "Returns the AshGraphql schema containing the domain in question, or a list of all AshGraphql schemas"
    def find_schema(igniter, domain) do
      {igniter, modules} = ash_graphql_schemas(igniter)

      modules
      |> Enum.find(fn module ->
        with {:ok, {_igniter, _source, zipper}} <-
               Igniter.Project.Module.find_module(igniter, module),
             {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, AshGraphql),
             {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
             {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :domains),
             {:ok, _zipper} <-
               Igniter.Code.List.move_to_list_item(
                 zipper,
                 &Igniter.Code.Common.nodes_equal?(&1, domain)
               ) do
          true
        else
          _ ->
            false
        end
      end)
      |> case do
        nil ->
          {:error, igniter, modules}

        module ->
          {:ok, igniter, module}
      end
    end

    @doc "Sets up an absinthe schema for AshGraphql"
    def setup_absinthe_schema(igniter, schema_name \\ nil) do
      schema_name = schema_name || Igniter.Project.Module.module_name(igniter, "GraphqlSchema")

      {igniter, domains} = Ash.Domain.Igniter.list_domains(igniter)

      {igniter, domains} =
        Enum.reduce(domains, {igniter, []}, fn domain, {igniter, list} ->
          case Spark.Igniter.has_extension(
                 igniter,
                 domain,
                 Ash.Domain,
                 :extensions,
                 AshGraphql.Domain
               ) do
            {igniter, true} -> {igniter, [domain | list]}
            {igniter, false} -> {igniter, list}
          end
        end)

      {igniter, resources} = Ash.Resource.Igniter.list_resources(igniter)

      {igniter, any_queries?} =
        Enum.reduce_while(
          Enum.map(resources, &{&1, [2, 3]}) ++ Enum.map(domains, &{&1, [3, 4]}),
          {igniter, false},
          fn {mod, arities}, {igniter, false} ->
            with {:ok, {igniter, _source, zipper}} <-
                   Igniter.Project.Module.find_module(igniter, mod),
                 {:ok, zipper} <-
                   Igniter.Code.Function.move_to_function_call_in_current_scope(
                     zipper,
                     :graphql,
                     1
                   ),
                 {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
                 {:ok, zipper} <-
                   Igniter.Code.Function.move_to_function_call_in_current_scope(
                     zipper,
                     :queries,
                     1
                   ),
                 {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
              has_query? =
                Enum.any?([:get, :read_one, :list, :action], fn query_name ->
                  match?(
                    {:ok, _},
                    Igniter.Code.Function.move_to_function_call_in_current_scope(
                      zipper,
                      query_name,
                      arities
                    )
                  )
                end)

              if has_query? do
                {:halt, {igniter, true}}
              else
                {:cont, {igniter, false}}
              end
            else
              _ ->
                {:cont, {igniter, false}}
            end
          end
        )

      placeholder_query =
        if !any_queries? do
          ~S'''
          @desc """
          Hello! This is a sample query to verify that AshGraphql has been set up correctly.
          Remove me once you have a query of your own!
          """
          field :say_hello, :string do
            resolve fn _, _, _ ->
              {:ok, "Hello from AshGraphql!"}
            end
          end
          '''
        end

      igniter
      |> Igniter.Project.Module.find_and_update_or_create_module(
        schema_name,
        """
        use Absinthe.Schema

        use AshGraphql,
          domains: #{inspect(domains)}

        import_types Absinthe.Plug.Types

        query do
          # Custom Absinthe queries can be placed here
          #{placeholder_query}
        end

        mutation do
          # Custom Absinthe mutations can be placed here
        end

        subscription do
          # Custom Absinthe subscriptions can be placed here
        end
        """,
        fn zipper ->
          # Should never get here
          {:ok, zipper}
        end
      )
    end

    @doc "Sets up the phoenix module for AshGraphql"
    def setup_phoenix(igniter, schema_name \\ nil, socket_name \\ nil, opts \\ []) do
      schema_name = schema_name || Igniter.Project.Module.module_name(igniter, "GraphqlSchema")
      socket_name = socket_name || Igniter.Project.Module.module_name(igniter, "GraphqlSocket")

      case Igniter.Libs.Phoenix.select_router(igniter) do
        {igniter, nil} ->
          igniter
          |> Igniter.add_warning("""
          No Phoenix router found, skipping Phoenix installation.

          See the Getting Started guide for instructions on installing AshGraphql with `plug`.
          If you have yet to set up Phoenix, you'll have to do that manually and then rerun this installer.
          """)

        {igniter, router} ->
          igniter
          |> Igniter.Project.Deps.add_dep({:absinthe_phoenix, "~> 2.0"})
          |> then(fn igniter ->
            if igniter.assigns[:test_mode?] do
              igniter
            else
              Igniter.apply_and_fetch_dependencies(igniter,
                error_on_abort?: true,
                yes: opts[:yes],
                yes_to_deps: true
              )
            end
          end)
          |> create_socket(schema_name, socket_name)
          |> update_router(router, socket_name, schema_name)
          |> update_application(router)
      end
    end

    defp update_router(igniter, router, socket_name, schema_name) do
      igniter
      |> update_endpoints(router, socket_name)
      |> Igniter.Libs.Phoenix.add_pipeline(:graphql, "plug AshGraphql.Plug", router: router)
      |> Igniter.Libs.Phoenix.add_scope(
        "/gql",
        """
          pipe_through [:graphql]

          forward "/playground",
                  Absinthe.Plug.GraphiQL,
                  schema: Module.concat(["#{inspect(schema_name)}"]),
                  socket: Module.concat(["#{inspect(socket_name)}"]),
                  interface: :simple

          forward "/",
            Absinthe.Plug,
            schema: Module.concat(["#{inspect(schema_name)}"])
        """,
        router: router
      )
    end

    defp update_application(igniter, router) do
      {igniter, endpoints} =
        Igniter.Libs.Phoenix.endpoints_for_router(igniter, router)

      igniter =
        Enum.reduce(endpoints, igniter, fn endpoint, igniter ->
          igniter
          |> Igniter.Project.Application.add_new_child({Absinthe.Subscription, endpoint},
            after: endpoint
          )
        end)

      igniter
      |> Igniter.Project.Application.add_new_child(AshGraphql.Subscription.Batcher,
        after: Absinthe.Subscription
      )
    end

    defp create_socket(igniter, schema_name, socket_name) do
      igniter
      |> Igniter.Project.Module.find_and_update_or_create_module(
        socket_name,
        """
        use Phoenix.Socket

        use Absinthe.Phoenix.Socket,
          schema: #{inspect(schema_name)}

        @impl true
        def connect(_params, socket, _connect_info) do
          {:ok, socket}
        end

        @impl true
        def id(_socket), do: nil
        """,
        fn zipper ->
          # Should never get here
          {:ok, zipper}
        end
      )
    end

    @doc "Returns all modules that `use AshGraphql`"
    def ash_graphql_schemas(igniter) do
      Igniter.Project.Module.find_all_matching_modules(igniter, fn _name, zipper ->
        match?({:ok, _}, Igniter.Code.Module.move_to_use(zipper, AshGraphql))
      end)
    end

    defp update_endpoints(igniter, router, socket_name) do
      {igniter, endpoints_that_need_parser} =
        Igniter.Libs.Phoenix.endpoints_for_router(igniter, router)

      igniter =
        Enum.reduce(endpoints_that_need_parser, igniter, fn endpoint, igniter ->
          igniter
          |> Igniter.Project.Module.find_and_update_module!(endpoint, fn zipper ->
            with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Phoenix.Endpoint) do
              {:ok, Igniter.Code.Common.add_code(zipper, "use Absinthe.Phoenix.Endpoint")}
            end
          end)
          |> Igniter.Project.Module.find_and_update_module!(endpoint, fn zipper ->
            case Igniter.Code.Function.move_to_function_call_in_current_scope(
                   zipper,
                   :plug,
                   2,
                   &Igniter.Code.Function.argument_equals?(&1, 0, Plug.Parsers)
                 ) do
              {:ok, zipper} ->
                with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
                     {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :parsers),
                     {:ok, zipper} <-
                       Igniter.Code.List.append_new_to_list(zipper, Absinthe.Plug.Parser) do
                  {:ok, zipper}
                else
                  _ ->
                    {:warning,
                     "Could not add `Absinthe.Plug.Parser` to parsers in endpoint #{endpoint}. Please make this change manually."}
                end

              :error ->
                case parser_location(zipper) do
                  {:ok, zipper} ->
                    {:ok,
                     Igniter.Code.Common.add_code(zipper, """
                     plug Plug.Parsers,
                       parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser],
                       pass: ["*/*"],
                       json_decoder: Jason
                     """)}

                  _ ->
                    {:warning,
                     "Could not add `Absinthe.Plug.Parser` to parsers in endpoint #{endpoint}. Please make this change manually."}
                end
            end
          end)
        end)

      Enum.reduce(endpoints_that_need_parser, igniter, fn endpoint, igniter ->
        Igniter.Project.Module.find_and_update_module!(igniter, endpoint, fn zipper ->
          case Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :socket,
                 3,
                 &Igniter.Code.Function.argument_equals?(&1, 1, socket_name)
               ) do
            {:ok, zipper} ->
              # Already installed
              {:ok, zipper}

            :error ->
              case socket_location(zipper) do
                {:ok, zipper} ->
                  {:ok,
                   Igniter.Code.Common.add_code(zipper, """
                     socket "/ws/gql", #{inspect(socket_name)},
                       websocket: true,
                       longpoll: true
                   """)}

                _ ->
                  {:warning,
                   "Could not add #{socket_name} in endpoint #{endpoint}. Please make this change manually."}
              end
          end
        end)
      end)
    end

    defp socket_location(zipper) do
      with :error <-
             Igniter.Code.Function.move_to_function_call_in_current_scope(
               zipper,
               :socket,
               3,
               &Igniter.Code.Function.argument_equals?(&1, 1, Phoenix.LiveView.Socket)
             ) do
        Igniter.Code.Module.move_to_use(zipper, Phoenix.Endpoint)
      end
    end

    defp parser_location(zipper) do
      with :error <-
             Igniter.Code.Function.move_to_function_call_in_current_scope(
               zipper,
               :plug,
               [1, 2],
               &Igniter.Code.Function.argument_equals?(&1, 0, Plug.Telemetry)
             ),
           :error <-
             Igniter.Code.Function.move_to_function_call_in_current_scope(
               zipper,
               :plug,
               [1, 2]
             ) do
        Igniter.Code.Module.move_to_use(zipper, Phoenix.Endpoint)
      end
    end
  end
end