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`. See the modify_conn guide for more details and examples.",
      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: []
    ],
    action_names_in_schema: [
      type: :keyword_list,
      doc:
        "A mapping of action names to how they should appear in the OpenAPI schema. You only need to set this if you see a warning during schema generation."
    ],
    name: [
      type: :string,
      required: false,
      doc:
        "A globally unique name for this route, to be used when generating docs and open api specifications"
    ],
    description: [
      type: :string,
      required: false,
      doc:
        "A human-friendly description of this specific route to use in generated documentation and OpenAPI. If provided, this overrides the action description."
    ],
    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
    ],
    path_param_is_composite_key: [
      type: :atom,
      doc:
        "The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details.",
      default: nil
    ]
  ]

  @get %Spark.Dsl.Entity{
    name: :get,
    args: [:action],
    describe: "A GET route to retrieve a single record",
    examples: [
      "get :read",
      "get :read, path_param_is_composite_key: :id"
    ],
    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",
      "patch :update, path_param_is_composite_key: :id"
    ],
    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",
      "delete :destroy, path_param_is_composite_key: :id"
    ],
    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"
      ],
      always_include_linkage: [
        type: {:list, :atom},
        doc:
          "A list of relationships that should always have their linkage included in the resource",
        default: []
      ],
      includes: [
        type: {:wrap_list, :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
        case Ash.Resource.Info.primary_key(resource) do
          [] ->
            nil

          [key] ->
            case Map.get(record, key) do
              nil -> nil
              value -> to_string(value)
            end
        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