lib/ash_json_api/resource/resource.ex

defmodule AshJsonApi.Resource do
  @route_schema [
    route: [
      type: :string,
      required: true,
      doc: "The path of the route"
    ],
    action: [
      type: :atom,
      required: true,
      doc: "The action to call when this route is hit"
    ],
    default_fields: [
      type: {:list, :atom},
      required: false,
      doc: "A list of fields to be shown in the attributes of the called route"
    ],
    primary?: [
      type: :boolean,
      default: false,
      doc:
        "Whether or not this is the route that should be linked to by default when rendering links to this type of route"
    ],
    metadata: [
      type: {:fun, 3},
      required: false,
      doc: "A function to generate arbitrary top-level metadata for the JSON:API response",
      snippet: "fn ${1:subject}, ${2:result}, ${3:request} -> $4 end"
    ],
    modify_conn: [
      type: {:fun, 4},
      required: false,
      doc:
        "A function to modify the conn before responding. Used for things like setting headers based on the response. Takes `conn, subject, result, request`",
      snippet: "fn ${1:conn}, ${2:subject}, ${3:result}, ${4:request} -> $5 end"
    ],
    query_params: [
      type: {:list, :atom},
      doc: "A list of action inputs to accept as query parameters.",
      default: []
    ],
    name: [
      type: :string,
      required: false,
      doc:
        "A globally unique name for this route, to be used when generating docs and open api specifications"
    ],
    derive_sort?: [
      type: :boolean,
      doc:
        "Whether or not to derive a sort parameter based on the sortable fields of the resource",
      default: true
    ],
    derive_filter?: [
      type: :boolean,
      doc:
        "Whether or not to derive a filter parameter based on the sortable fields of the resource",
      default: true
    ]
  ]

  @get %Spark.Dsl.Entity{
    name: :get,
    args: [:action],
    describe: "A GET route to retrieve a single record",
    examples: [
      "get :read"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.set_default!(:route, "/:id")
      |> Keyword.delete(:query_params),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :get,
      controller: AshJsonApi.Controllers.Get,
      action_type: :read,
      type: :get
    ]
  }

  @index %Spark.Dsl.Entity{
    name: :index,
    args: [:action],
    describe: "A GET route to retrieve a list of records",
    examples: [
      "index :read"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.set_default!(:route, "/")
      |> Keyword.put(:paginate?, type: :boolean, default: true)
      |> Keyword.delete(:query_params),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :get,
      controller: AshJsonApi.Controllers.Index,
      action_type: :read,
      type: :index
    ]
  }

  @post %Spark.Dsl.Entity{
    name: :post,
    args: [:action],
    describe: "A POST route to create a record",
    examples: [
      "post :create"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.set_default!(:route, "/")
      |> Keyword.merge(
        relationship_arguments: [
          type: {:list, {:or, [:atom, {:tuple, [{:literal, :id}, :atom]}]}},
          doc:
            "Arguments to be used to edit relationships. See the [relationships guide](/documentation/topics/relationships.md) for more.",
          default: []
        ],
        upsert?: [
          type: :boolean,
          default: false,
          doc: "Whether or not to use the `upsert?: true` option when calling `Ash.create/2`."
        ],
        upsert_identity: [
          type: :atom,
          default: false,
          doc: "Which identity to use for the upsert"
        ]
      ),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :post,
      controller: AshJsonApi.Controllers.Post,
      action_type: :create,
      type: :post
    ]
  }

  @patch %Spark.Dsl.Entity{
    name: :patch,
    args: [:action],
    describe: "A PATCH route to update a record",
    examples: [
      "patch :update"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.set_default!(:route, "/:id")
      |> Keyword.put(:read_action,
        type: :atom,
        default: nil,
        doc: "The read action to use to look the record up before updating"
      )
      |> Keyword.put(:relationship_arguments,
        type: :any,
        doc:
          "Arguments to be used to edit relationships. See the [relationships guide](/documentation/topics/relationships.md) for more.",
        default: []
      ),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :patch,
      controller: AshJsonApi.Controllers.Patch,
      action_type: :update,
      type: :patch
    ]
  }

  @delete %Spark.Dsl.Entity{
    name: :delete,
    args: [:action],
    describe: "A DELETE route to destroy a record",
    examples: [
      "delete :destroy"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.set_default!(:route, "/:id")
      |> Keyword.put(:read_action,
        type: :atom,
        default: nil,
        doc: "The read action to use to look the record up before updating"
      ),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :delete,
      controller: AshJsonApi.Controllers.Delete,
      action_type: :destroy,
      type: :delete
    ]
  }

  @related %Spark.Dsl.Entity{
    name: :related,
    args: [:relationship, :action],
    describe: "A GET route to read the related resources of a relationship",
    examples: [
      "related :comments, :read"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.make_optional!(:route)
      |> Spark.Options.Helpers.append_doc!(:route, "Defaults to /:id/[relationship_name]")
      |> Keyword.put(:relationship,
        type: :atom,
        required: true
      ),
    transform: {__MODULE__, :set_related_route, []},
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :get,
      controller: AshJsonApi.Controllers.GetRelated,
      action_type: :get_related,
      type: :get_related
    ]
  }

  @relationship %Spark.Dsl.Entity{
    name: :relationship,
    args: [:relationship, :action],
    describe: "A READ route to read the relationship, returns resource identifiers.",
    examples: [
      "relationship :comments, :read"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.make_optional!(:route)
      |> Spark.Options.Helpers.append_doc!(
        :route,
        " Defaults to /:id/relationships/[relationship_name]"
      )
      |> Keyword.put(:relationship,
        type: :atom,
        required: true
      ),
    transform: {__MODULE__, :set_relationship_route, []},
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :get,
      controller: AshJsonApi.Controllers.GetRelationship,
      action_type: :relationship,
      type: :relationship
    ]
  }

  @post_to_relationship %Spark.Dsl.Entity{
    name: :post_to_relationship,
    args: [:relationship],
    describe: "A POST route to create related entities using resource identifiers",
    examples: [
      "post_to_relationship :comments"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.make_optional!(:route)
      |> Spark.Options.Helpers.append_doc!(
        :route,
        " Defaults to /:id/relationships/[relationship_name]"
      )
      |> Keyword.put(:relationship,
        type: :atom,
        required: true
      )
      |> Keyword.delete(:action),
    transform: {__MODULE__, :set_relationship_route, []},
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :post,
      type: :post_to_relationship,
      action_type: :update,
      controller: AshJsonApi.Controllers.PostToRelationship
    ]
  }

  @patch_relationship %Spark.Dsl.Entity{
    name: :patch_relationship,
    args: [:relationship],
    describe: "A PATCH route to update a relationship using resource identifiers",
    examples: [
      "patch_relationship :comments"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.make_optional!(:route)
      |> Spark.Options.Helpers.append_doc!(
        :route,
        " Defaults to /:id/relationships/[relationship_name]"
      )
      |> Keyword.put(:relationship,
        type: :atom,
        required: true
      )
      |> Keyword.delete(:action),
    transform: {__MODULE__, :set_relationship_route, []},
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :patch,
      type: :patch_relationship,
      action_type: :update,
      controller: AshJsonApi.Controllers.PatchRelationship
    ]
  }

  @delete_from_relationship %Spark.Dsl.Entity{
    name: :delete_from_relationship,
    args: [:relationship],
    describe: "A DELETE route to remove related entities using resource identifiers",
    examples: [
      "delete_from_relationship :comments"
    ],
    schema:
      @route_schema
      |> Spark.Options.Helpers.make_optional!(:route)
      |> Spark.Options.Helpers.append_doc!(
        :route,
        " Defaults to /:id/relationships/[relationship_name]"
      )
      |> Keyword.put(:relationship,
        type: :atom,
        required: true
      )
      |> Keyword.delete(:action),
    transform: {__MODULE__, :set_relationship_route, []},
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      method: :delete,
      type: :delete_from_relationship,
      action_type: :update,
      controller: AshJsonApi.Controllers.DeleteFromRelationship
    ]
  }

  @route %Spark.Dsl.Entity{
    name: :route,
    args: [:method, :route, :action],
    describe: "A route for a generic action.",
    examples: [
      ~S{route :get, "say_hi/:name", :say_hello}
    ],
    schema:
      Keyword.put(@route_schema, :method,
        type: :atom,
        required: true,
        doc: "The HTTP method for the route, e.g `:get`, or `:post`"
      )
      |> Keyword.put(:wrap_in_result?,
        type: :boolean,
        default: false,
        doc: "Whether or not the action result should be wrapped in `{result: <result>}`"
      ),
    target: AshJsonApi.Resource.Route,
    auto_set_fields: [
      type: :route,
      action_type: :action,
      controller: AshJsonApi.Controllers.GenericActionRoute
    ]
  }

  @routes %Spark.Dsl.Section{
    name: :routes,
    describe: "Configure the routes that will be exposed via the JSON:API",
    schema: [
      base: [
        type: :string,
        doc: "A base route for the resource, e.g `\"/users\"`"
      ]
    ],
    examples: [
      """
      routes do
        base "/posts"

        get :read
        get :me, route: "/me"
        index :read
        post :confirm_name, route: "/confirm_name"
        patch :update
        related :comments, :read
        relationship :comments, :read
        post_to_relationship :comments
        patch_relationship :comments
        delete_from_relationship :comments
      end
      """
    ],
    entities: [
      @get,
      @index,
      @post,
      @patch,
      @delete,
      @related,
      @relationship,
      @post_to_relationship,
      @patch_relationship,
      @delete_from_relationship,
      @route
    ]
  }

  if Code.ensure_loaded?(Igniter) do
    # sobelow_skip ["DOS.StringToAtom"]
    def install(igniter, module, Ash.Resource, _path, _argv) do
      type =
        module
        |> Module.split()
        |> List.last()
        |> Macro.underscore()

      igniter =
        case Ash.Resource.Igniter.domain(igniter, module) do
          {:ok, igniter, domain} ->
            AshJsonApi.Domain.install(igniter, domain, Ash.Domain, nil, nil)

          {:error, igniter} ->
            igniter
        end

      igniter
      |> Spark.Igniter.add_extension(
        module,
        Ash.Resource,
        :extensions,
        AshJsonApi.Resource
      )
      |> Spark.Igniter.set_option(module, [:json_api, :type], type)
    end
  end

  @doc false
  def routes, do: @routes

  @primary_key %Spark.Dsl.Section{
    name: :primary_key,
    describe: "Encode the id of the JSON API response from selected attributes of a resource",
    examples: [
      """
      primary_key do
        keys [:first_name, :last_name]
        delimiter "~"
      end
      """
    ],
    schema: [
      keys: [
        type: {:wrap_list, :atom},
        doc: "the list of attributes to encode JSON API primary key",
        required: true
      ],
      delimiter: [
        type: :string,
        default: "-",
        required: false,
        doc: "The delimiter to concatenate the primary key values. Default to be '-'"
      ]
    ]
  }

  @json_api %Spark.Dsl.Section{
    name: :json_api,
    sections: [@routes, @primary_key],
    describe: "Configure the resource's behavior in the JSON:API",
    examples: [
      """
      json_api do
        type "post"
        includes [
          friends: [
            :comments
          ],
          comments: []
        ]

        routes do
          base "/posts"

          get :read
          get :me, route: "/me"
          index :read
          post :confirm_name, route: "/confirm_name"
          patch :update
          related :comments, :read
          relationship :comments, :read
          post_to_relationship :comments
          patch_relationship :comments
          delete_from_relationship :comments
        end
      end
      """
    ],
    schema: [
      type: [
        type: :string,
        doc: "The resource identifier type of this resource in JSON:API"
      ],
      includes: [
        type: :any,
        default: [],
        doc: "A keyword list of all paths that are includable from this resource"
      ],
      include_nil_values?: [
        type: :any,
        default: nil,
        doc: "Whether or not to include properties for values that are nil in the JSON output"
      ],
      default_fields: [
        type: {:list, :atom},
        doc:
          "The fields to include in the object if the `fields` query parameter does not specify. Defaults to all public"
      ],
      derive_sort?: [
        type: :boolean,
        doc:
          "Whether or not to derive a sort parameter based on the sortable fields of the resource",
        default: true
      ],
      derive_filter?: [
        type: :boolean,
        doc:
          "Whether or not to derive a filter parameter based on the sortable fields of the resource",
        default: true
      ]
    ]
  }

  @transformers [
    AshJsonApi.Resource.Transformers.PrependRoutePrefix,
    AshJsonApi.Resource.Transformers.ValidateNoOverlappingRoutes,
    AshJsonApi.Resource.Transformers.RequirePrimaryKey
  ]

  @persisters [
    AshJsonApi.Resource.Persisters.DefineRouter
  ]

  @verifiers [
    AshJsonApi.Resource.Verifiers.VerifyRelationships,
    AshJsonApi.Resource.Verifiers.VerifyIncludes,
    AshJsonApi.Resource.Verifiers.VerifyActions,
    AshJsonApi.Resource.Verifiers.VerifyHasType,
    AshJsonApi.Resource.Verifiers.VerifyQueryParams
  ]

  @sections [@json_api]

  @moduledoc """
  The entrypoint for adding JSON:API behavior to a resource"
  """

  use Spark.Dsl.Extension,
    sections: @sections,
    transformers: @transformers,
    persisters: @persisters,
    verifiers: @verifiers

  @deprecated "See AshJsonApi.Resource.Info.type/1"
  defdelegate type(resource), to: AshJsonApi.Resource.Info

  @deprecated "See AshJsonApi.Resource.Info.includes/1"
  defdelegate includes(resource), to: AshJsonApi.Resource.Info

  @deprecated "See AshJsonApi.Resource.Info.base_route/1"
  defdelegate base_route(resource), to: AshJsonApi.Resource.Info

  @deprecated "See AshJsonApi.Resource.Info.primary_key_fields/1"
  defdelegate primary_key_fields(resource), to: AshJsonApi.Resource.Info

  @deprecated "See AshJsonApi.Resource.Info.primary_key_delimiter/1"
  defdelegate primary_key_delimiter(resource), to: AshJsonApi.Resource.Info

  @deprecated "See AshJsonApi.Resource.Info.routes/1"
  defdelegate routes(resource, domains), to: AshJsonApi.Resource.Info

  def encode_primary_key(%resource{} = record) do
    case primary_key_fields(resource) do
      [] ->
        # Expect resource to have only 1 primary key if :primary_key section is not used
        [key] = Ash.Resource.Info.primary_key(resource)

        case Map.get(record, key) do
          nil -> nil
          value -> to_string(value)
        end

      keys ->
        delimiter = primary_key_delimiter(resource)

        [_ | concatenated_keys] =
          keys
          |> Enum.reverse()
          |> Enum.reduce([], fn key, acc -> [delimiter, to_string(Map.get(record, key)), acc] end)

        IO.iodata_to_binary(concatenated_keys)
    end
  end

  def route(resource, domains, criteria \\ %{}) do
    resource
    |> routes(domains)
    |> Enum.find(fn route ->
      Map.take(route, Map.keys(criteria)) == criteria
    end)
  end

  @doc false
  def set_related_route(%{route: nil, relationship: relationship} = route) do
    {:ok, %{route | route: ":id/#{relationship}"}}
  end

  def set_related_route(route), do: {:ok, route}

  @doc false
  def set_relationship_route(%{route: nil, relationship: relationship} = route) do
    {:ok, %{route | route: ":id/relationships/#{relationship}"}}
  end

  def set_relationship_route(route), do: {:ok, route}

  @doc false
  def validate_fields(fields) when is_list(fields) do
    if Enum.all?(fields, &is_atom/1) do
      {:ok, fields}
    else
      {:error, "Invalid fields"}
    end
  end

  def only_primary_key?(resource, field) do
    resource
    |> Ash.Resource.Info.primary_key()
    |> case do
      [^field] -> true
      _ -> false
    end
  end
end