defmodule Calcinator do @moduledoc """ Converts actions from a controller or RPC server using JSONAPI formatted params to calls on a `Calcinator.Resources` module. """ alias Alembic.{Document, Fetch, Fetch.Includes, FromJson, ToParams, Source} alias Calcinator.{Authorization, Meta} alias Calcinator.Authorization.SubjectLess alias Calcinator.Resources.{Page, Sorts} # Constants @actions ~w(create delete index update show)a # Struct defstruct associations_by_include: %{}, authorization_module: SubjectLess, ecto_schema_module: nil, params: %{}, resources_module: nil, subject: nil, view_module: nil # Types @type association :: atom | list | map @typedoc """ Nested params format used by `Ecto.Changeset.t`. """ @type insertable_params :: %{String.t => term} @typedoc """ The name of the parameter that was used for the query and was not found. """ @type parameter :: String.t @typedoc """ The raw request params that need to be validated as a JSONAPI document and converted to an `Alembic.Document.t` """ @type params :: %{String.t => term} @typedoc """ A rendered JSONAPI document as a `map` """ @type rendered :: map @typedoc """ * `authorization_module` - The module that implements the `Calcinator.Authorization` behaviour. Defaults to `Calcinator.Authorization.Subjectless`. * `subject` - the subject that is trying to do the action and needs to be authorized by `authorization_module` * `target` - the target of `subject`'s action """ @type t :: %__MODULE__{ authorization_module: module, ecto_schema_module: module, subject: Authorization.subject, view_module: module } # Functions ## Client Functions def allow_sandbox_access(state = %__MODULE__{resources_module: resources_module}, params) do allow_sandbox_access(state, params, resources_module.sandboxed?()) end # for docs def authorized(calcinator, resource_or_related) # Filters a related resource that does not exist @spec authorized(t, related :: nil) :: nil def authorized(%__MODULE__{}, nil), do: nil # Filters `struct` or list of `struct`s to only those that can be shown @spec authorized(t, resource :: struct) :: struct def authorized(%__MODULE__{authorization_module: authorization_module, subject: subject}, unfiltered = %_{}) do authorization_module.filter_associations_can(unfiltered, subject, :show) end @spec can(t, Authorization.action, Authorizaton.target) :: :ok | {:error, :unauthorized} def can(%__MODULE__{authorization_module: authorization_module, subject: subject}, action, target) when action in @actions and not is_nil(authorization_module) and (is_atom(target) or is_map(target) or is_list(target)) do if authorization_module.can?(subject, action, target) do :ok else {:error, :unauthorized} end end @spec changeset(t, Ecto.Schema.t, insertable_params) :: {:ok, Ecto.Changeset.t} | {:error, Ecto.Changeset.t} def changeset(%__MODULE__{resources_module: resources_module}, updatable, updatable_params) do updatable |> resources_module.changeset(updatable_params) |> status_changeset() end @spec get(module, params, id_key :: String.t, Resources.query_options) :: {:error, {:not_found, parameter} | :timeout | term} | {:ok, Ecto.Schema.t} def get(resources_module, params, id_key, query_options) when is_map(query_options) do with {:error, :not_found} <- params |> Map.fetch!(id_key) |> resources_module.get(query_options) do {:error, {:not_found, id_key}} end end @spec update_changeset(t, Ecto.Changeset.t, params) :: {:ok, Ecto.Schema.t} | {:error, Document.t} | {:error, Ecto.Changeset.t} | {:error, :bad_gateway} | {:error, :not_found} def update_changeset(state = %__MODULE__{resources_module: resources_module}, changeset = %Ecto.Changeset{}, params) do with {:ok, query_options} <- params_to_query_options(state, params) do resources_module.update(changeset, query_options) end end ## Actions @doc """ [Creates resource](http://jsonapi.org/format/#crud-creating) from `params`. ## Steps 1. `state` `authorization_module` `can?(subject, :create, ecto_schema_module)` 2. Check `params` are a valid JSONAPI document 3. `state` `authorization_module` `can?(subject, :create, Ecto.Changeset.t)` 4. `allow_sandbox_access/2` 5. `state` `authorization_module` `filter_associations_can(created, subject, :show)` 6. `state` `view_module` `show(authorized, ...)` ## Returns * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - if `state` `authorization_module` `can?(subject, :create, ecto_schema_module)` or `can?(subject, :create, %Ecto.Changeset{})` returns `false` * `{:error, Alembic.Document.t}` - if `params` is not a valid JSONAPI document * `{:error, Ecto.Changeset.t}` - if validations errors inserting `Ecto.Changeset.t` * `{:ok, rendereded}` - rendered view of created resource """ @spec create(t, params) :: {:error, :ownership} | {:error, :unauthorized} | {:error, Document.t} | {:error, Ecto.Changeset.t} | {:ok, rendered} def create(state = %__MODULE__{ ecto_schema_module: ecto_schema_module, subject: subject, view_module: view_module }, params) when not is_nil(ecto_schema_module) and is_atom(ecto_schema_module) and not is_nil(view_module) and is_atom(view_module) and is_map(params) do with :ok <- can(state, :create, ecto_schema_module), {:ok, document} <- document(params, :create), insertable_params = insertable_params(state, document), {:ok, changeset} <- changeset(state, insertable_params), :ok <- can(state, :create, changeset), {:ok, created} <- create_changeset(state, changeset, params) do authorized = authorized(state, created) {:ok, view_module.show(authorized, %{params: params, subject: subject})} end end @doc """ [Deletes resource](http://jsonapi.org/format/#crud-deleting) with `"id"` in `params`. ## Steps 1. `allow_sandbox_access/2` 2. `state` `resources_module` `get(id, ...)` 3. `state` `authorization_module` `can?(subject, :delete, struct)` 4. `state` `resources_module` `delete(struct)` ## Returns * `{:error, {:not_found, "id"}}` - The "id" did not correspond to resource in the backing store * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - The `state` `subject` is not authorized to delete the resource * `{:error, Ecto.Changeset.t}` - the deletion failed with the errors in `Ecto.Changeset.t` * `:ok` - resource was successfully deleted """ @spec delete(t, params) :: :ok | {:error, {:not_found, parameter}} | {:error, :ownership} | {:error, :unauthorized} | {:error, Ecto.Changeset.t} def delete(state = %__MODULE__{}, params) do with :ok <- allow_sandbox_access(state, params), {:ok, target} <- get(state, params), :ok <- can(state, :delete, target), {:ok, _deleted} <- delete_ecto_schema(state, target) do :ok end end @doc """ [Gets a resource related through a relationship] (http://jsonapi.org/format/#document-resource-object-related-resource-links). ## Steps 1. Gets source 2. `state` `authorization_module` `can?(subject, :show, source)` 3. Get related 4. `state` `authorization_module` `can?(subject, :show, [related, source])` 5. `state` `authorization_module` `filter_associations_can(related, subject, :show)` 6. `state` `view_module` `get_related_resource(authorized, ...)` ## Returns * `{:error, {:not_found, id_key}}` - The value of the `id_key` key in `params` did not correspond to a resource in the backing store. * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - if the either the source or related resource cannot be shown * `{:ok, rendered}` - rendered view of related resource """ @spec get_related_resource(t, params, map) :: {:error, {:not_found, parameter}} | {:error, :ownership} | {:error, :unauthorized} | {:ok, rendered} def get_related_resource( state = %__MODULE__{}, params, options = %{related: related} ) do related_property(state, params, put_in(options.related, Map.put(related, :property, :resource))) end @doc """ [Gets index of a resource](http://jsonapi.org/format/#fetching-resources) with [(optional) pagination](http://jsonapi.org/format/#fetching-pagination) depending on whether the `state` `resources_module` supports pagination. ## Steps 1. `state` `authorization_module` `can?(subject, :index, ecto_schema_module)` 2. `allow_sandbox_access/2` 3. `state` `resources_module` `list/1` 4. `state` `authorization_module` `filter_can(listed, subject, :show)` 5. `state` `authorization_module` `filter_associations_can(filtered_listed, subject, :show)` 6. `state` `view_module` `index(association_filtered, ...)` ## Returns * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :timeout}` - if the backing store for `state` `resources_module` times out when calling `list/1`. * `{:error, :unauthorized}` - if `state` `authorization_module` `can?(subject, :index, ecto_schema_module)` returns `false` * `{:error, Alembic.Document.t}` - if `params` are not valid JSONAPI. * `{:ok, rendered}` - the rendered resources with (optional) pagination in the `"meta"`. """ @spec index(t, params, %{required(:base_uri) => URI.t}) :: {:error, :timeout} | {:error, :unauthorized} | {:error, Document.t} | {:ok, rendered} def index(state = %__MODULE__{ ecto_schema_module: ecto_schema_module, subject: subject, view_module: view_module, }, params, %{base_uri: base_uri}) do with :ok <- can(state, :index, ecto_schema_module), :ok <- allow_sandbox_access(state, params), {:ok, list, pagination} <- list(state, params) do {authorized, authorized_pagination} = authorized(state, list, pagination) { :ok, view_module.index( authorized, %{base_uri: base_uri, pagination: authorized_pagination, params: params, subject: subject} ) } end end @doc """ [Shows resource](http://jsonapi.org/format/#fetching-resources) with the `"id"` in `params`. ## Steps 1. `allow_sandbox_acces/2` 2. `state` `resources_module` `get(id, ...)` 3. `state` `authorization_module` `can?(subject, :show, got)` 4. `state` `authorization_module` `filter_associations_can(got, subject, :show)` 5. `state` `view_module` `show(authorized, ...)` ## Returns * `{:error, {:not_found, "id"}}` - The "id" did not correspond to resource in the backing store * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - `state` `authorization_module` `can?(subject, :show, got)` returns `false` * `{:error, Alembic.Document.t}` - `params` is not valid JSONAPI * `{:ok, rendered}` - rendered resource """ @spec show(t, params) :: {:error, {:not_found, parameter}} | {:error, :ownership} | {:error, :unauthorized} | {:error, Document.t} | {:ok, rendered} def show(state = %__MODULE__{subject: subject, view_module: view_module}, params = %{"id" => _}) do with :ok <- allow_sandbox_access(state, params), {:ok, shown} <- get(state, params), :ok <- can(state, :show, shown) do authorized = authorized(state, shown) {:ok, view_module.show(authorized, %{params: params, subject: subject})} end end @doc """ [Shows a relationship](http://jsonapi.org/format/#fetching-relationships). ## Steps 1. Gets source 2. `state` `authorization_module` `can?(subject, :show, source)` 3. Get related 4. `state` `authorization_module` `can?(subject, :show, [related, source])` 5. `state` `authorization_module` `filter_associations_can(related, subject, :show)` 6. `state` `view_module` `show_relationship(authorized, ...)` ## Returns * `{:error, {:not_found, id_key}}` - The value of the `id_key` key in `params` did not correspond to a resource in the backing store. * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - if the either the source or related resource cannot be shown * `{:ok, rendered}` - rendered view of relationship """ @spec show_relationship(t, params, map) :: {:error, {:not_found, parameter}} | {:error, :ownership} | {:error, :unauthorized} | {:ok, rendered} def show_relationship( state = %__MODULE__{}, params, options = %{related: related} ) do related_property(state, params, put_in(options.related, Map.put(related, :property, :relationship))) end @doc """ [Updates a resource](http://jsonapi.org/format/#crud-updating) with the `"id"` in `params` ## Steps 1. `allow_sandbox_access/2` 2. `state` `resources_module` `get(id, ...)` 3. Check `params` are a valid JSONAPI document 4. `state` `authorization_module` `can?(subject, :update, Ecto.Changeset.t)` 5. `state` `resources_module` `update(Ecto.Changeset.t, ...)` 6. `state` `authorization_module` `filter_associations_can(updated, subject, :show)` 6. `state` `view_module` `show(authorized, ...)` ## Returns * `{:error, :bad_gateway}` - backing store as internal error that can't be represented in any other format. Try again later or call support. * `{:error, {:not_found, "id"}}` - get failed or update failed because the resource was deleted between the get and update. * `{:error, :ownership}` - connection to backing store was not owned by the calling process * `{:error, :unauthorized}` - the resource either can't be shown or can't be updated * `{:error, Alembic.Document.t}` - the `params` are not valid JSONAPI * `{:error, Ecto.Changeset.t}` - validations error when updating * `{:ok, rendered}` - the rendered updated resource """ @spec update(t, params) :: {:error, :bad_gateway} | {:error, {:not_found, parameter}} | {:error, :ownership} | {:error, :unauthorized} | {:error, Document.t} | {:error, Ecto.Changeset.t} | {:ok, rendered} def update(state = %__MODULE__{subject: subject, view_module: view_module}, params) do with :ok <- allow_sandbox_access(state, params), {:ok, updatable} <- get(state, params), :ok <- can(state, :show, updatable), {:ok, document} <- document(params, :update), updatable_params = insertable_params(state, document), {:ok, changeset} <- changeset(state, updatable, updatable_params), :ok <- can(state, :update, changeset), {:ok, updated} <- update_changeset(state, changeset, params) do # DO NOT `:ok <- can(state, :show, updated)` because user can update to attributees they can't view, but we need # to send back the updated resource authorized = authorized(state, updated) {:ok, view_module.show(authorized, %{params: params, subject: subject})} end end ## Private Functions defp alembic_fetch_to_associations_query_option( %__MODULE__{associations_by_include: associations_by_include}, %Alembic.Fetch{includes: includes} ) do Includes.to_preloads(includes, associations_by_include) end defp alembic_fetch_to_sorts_query_option( %__MODULE__{associations_by_include: associations_by_include, ecto_schema_module: ecto_schema_module}, fetch ) do Sorts.from_alembic_fetch( fetch, %{ associations_by_include: associations_by_include, ecto_schema_module: ecto_schema_module } ) end defp allow_sandbox_access( %__MODULE__{resources_module: resources_module}, %{ "meta" => %{ "beam" => encoded_beam_meta } }, true ) when is_binary(encoded_beam_meta) do encoded_beam_meta |> Meta.Beam.decode |> resources_module.allow_sandbox_access() end defp allow_sandbox_access(%__MODULE__{}, params, true) when is_map(params), do: {:error, :sandbox_token_missing} defp allow_sandbox_access(%__MODULE__{}, params, false) when is_map(params), do: :ok @spec authorized(t, [struct], Resources.pagination) :: {[struct], Resources.pagination} defp authorized(%__MODULE__{authorization_module: authorization_module, subject: subject}, unfiltered, pagination) when is_list(unfiltered) and (is_nil(pagination) or is_map(pagination)) do {shallow_filtered, filtered_pagination} = case authorization_module.filter_can(unfiltered, subject, :show) do ^unfiltered -> {unfiltered, pagination} filtered_can -> {filtered_can, pagination} end deep_filtered = authorization_module.filter_associations_can(shallow_filtered, subject, :show) {deep_filtered, filtered_pagination} end @spec changeset(t, insertable_params) :: {:ok, Ecto.Changeset.t} | {:error, Ecto.Changeset.t} defp changeset(%__MODULE__{resources_module: resources_module}, insertable_params) when not is_nil(resources_module) and is_atom(resources_module) and is_map(insertable_params) do insertable_params |> resources_module.changeset() |> status_changeset() end @spec create_changeset(t, Ecto.Changeset.t, params) :: {:ok, struct} | {:error, Document.t} | {:error, Ecto.Changeset.t} defp create_changeset(state = %__MODULE__{resources_module: resources_module}, changeset = %Ecto.Changeset{}, params) when not is_nil(resources_module) and is_atom(resources_module) do with {:ok, query_options} <- params_to_query_options(state, params), :ok <- allow_sandbox_access(state, params) do resources_module.insert(changeset, query_options) end end @spec delete_ecto_schema(t, Ecto.Schema.t) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t} defp delete_ecto_schema(%__MODULE__{resources_module: resources_module}, schema), do: resources_module.delete(schema) @spec document(params, FromJson.action) :: {:ok, Document.t} | {:error, Document.t} defp document(raw_params, action) do Document.from_json( raw_params, %Alembic.Error{ meta: %{ "action" => action, "sender" => :client }, source: %Source{ pointer: "" } } ) end @spec get(t, params) :: {:error, {:not_found, parameter}} | {:error, Document.t} | {:ok, Ecto.Schema.t} defp get(state = %__MODULE__{resources_module: resources_module}, params) do with {:ok, query_options} <- params_to_query_options(state, params) do get(resources_module, params, "id", query_options) end end @spec get_maybe_authorized_related(t, Ecto.Schema.t, atom) :: {:error, :unauthorized} | {:ok, nil} | {:ok, Ecto.Schema.t} | no_return defp get_maybe_authorized_related(state, source, association) do case get_related(source, association) do nil -> {:ok, nil} related -> with :ok <- can(state, :show, [related, source]) do {:ok, authorized(state, related)} end end end # Gets related as long as association is correct @spec get_related(Ecto.Schema.t, atom) :: [Ecto.Schema.t] | Ecto.Schema.t | nil defp get_related(source, association) do case Map.fetch(source, association) do :error -> raise ArgumentError, "%#{source.__struct__}{} does not have #{inspect association} associaton" {:ok, related} -> related end end @spec get_source(t, params, %{ required(:association) => association, required(:id_key) => String.t, }) :: {:error, {:not_found, parameter} | :timeout | term} | {:ok, Ecto.Schema.t} defp get_source(%{resources_module: resources_module}, params, %{association: association, id_key: id_key}) do get(resources_module, params, id_key, %{associations: [association]}) end @spec insertable_params(t, Document.t) :: insertable_params defp insertable_params(%__MODULE__{ecto_schema_module: ecto_schema_module}, document) do document |> Document.to_params |> ToParams.nested_to_foreign_keys(ecto_schema_module) end @spec list(t, params) :: {:ok, [Ecto.Schema.t], Resources.pagination} | {:error, :timeout} | {:error, Document.t} | {:error, reason :: term} defp list(state = %__MODULE__{resources_module: resources_module}, params) do with {:ok, query_options} <- params_to_query_options(state, params) do resources_module.list(query_options) end end defp params_to_filters_query_option(params), do: {:ok, Map.get(params, "filter", [])} defp params_to_page_query_option(params), do: Page.from_params(params) @spec params_to_query_options(t, params) :: {:ok, Resources.query_options} | {:error, Document.t} defp params_to_query_options(state = %__MODULE__{}, params) when is_map(params) do fetch = Fetch.from_params(params) with {:ok, associations} <- alembic_fetch_to_associations_query_option(state, fetch), {:ok, filters} <- params_to_filters_query_option(params), {:ok, page} <- params_to_page_query_option(params), {:ok, sorts} <- alembic_fetch_to_sorts_query_option(state, fetch) do {:ok, %{associations: associations, filters: filters, page: page, sorts: sorts}} end end @spec related_property(t, params, map) :: {:error, {:not_found, parameter}} | {:error, :unauthorized} | {:ok, rendered} defp related_property( state = %__MODULE__{subject: subject, view_module: view_module}, params, %{ related: related_option, source: source_option = %{ association: association } } ) do with {:ok, source} <- get_source(state, params, source_option), :ok <- can(state, :show, source), {:ok, authorized_related} <- get_maybe_authorized_related(state, source, association) do { :ok, view_related_property( state, %{ params: params, related: Map.put(related_option, :resource, authorized_related), source: Map.merge( source_option, %{resource: source, view_module: view_module} ), subject: subject } ) } end end defp status_changeset(changeset) do status = if changeset.valid? do :ok else :error end {status, changeset} end defp view_related_property( %__MODULE__{subject: subject, view_module: view_module}, %{ params: params, related: related = %{ property: property, resource: resource }, source: source } ) do function_name = case property do :relationship -> :show_relationship :resource -> :get_related_resource end apply( view_module, function_name, [ resource, %{ params: params, related: related, source: put_in(source.view_module, view_module), subject: subject } ] ) end end