JSON API with Ash requires attributes from wrong action

I’m working on an email API service, Already got the Ash action to send email working in iex.
My send_email action is a custom action that will perfom some non standard CRUD. Got 2 create actions and the calls to api ask for attributes from wrong create action. As for now just trying to get the Ok atom

My resource

defmodule Eppa.Accounts.Servicios do
  use Ash.Resource,
    otp_app: :eppa,
    domain: Eppa.Accounts,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshJsonApi.Resource]

  json_api do
    type "servicios"
  end

  postgres do
    table "servicios"
    repo Eppa.Repo

    references do
      reference :user, index?: true, on_delete: :delete
    end
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:tipo, :creditos_disponibles, :vigencia]

      change relate_actor(:created_by, allow_nil?: true)
      change relate_actor(:updated_by, allow_nil?: true)
    end

    update :update do
      primary? true
      accept [:creditos_disponibles, :vigencia]

       change relate_actor(:updated_by, allow_nil?: true)
    end

    create :send_email do

      # Definición de argumentos que se esperan recibir vía JSON
      argument :user_email, :ci_string, allow_nil?: false
      argument :destinatario, :ci_string, allow_nil?: false
      argument :cc, {:array, :ci_string}, default: []
      argument :asunto, :string, allow_nil?: false
      argument :contenido_html, :ci_string, allow_nil?: false
      argument :attachments, {:array, :map}, default: []
      argument :plantilla_id, :ci_string, allow_nil?: true

      # Testing - "
      {:ok, "ok"}
    end
  end

  attributes do
    uuid_v7_primary_key :id

    attribute :tipo, :atom do
      constraints one_of: [:correos, :sms]
      allow_nil? false
      public? true
    end

    attribute :creditos_disponibles, :integer do
      allow_nil? false
      default 0
      public? true
    end

    attribute :vigencia, :datetime do
      allow_nil? false
      public? true
    end

    attribute :user_id, :uuid do
    end

    create_timestamp :inserted_at
    update_timestamp :updated_at
  end

  relationships do
    belongs_to :created_by, Eppa.Accounts.User
    belongs_to :updated_by, Eppa.Accounts.User

    belongs_to :user, Eppa.Accounts.User do
      allow_nil? false
    end
  end
end

My domain

defmodule Eppa.Accounts do
  use Ash.Domain, otp_app: :eppa, extensions: [AshJsonApi.Domain]

  json_api do
    routes do
      base_route "/servicios", Eppa.Accounts.Servicios do
        post :send_email
      end
     end
  end

  resources do
    resource Eppa.Accounts.Token

    resource Eppa.Accounts.User do
      define :create_user, action: :register_with_password
      define :update_user, action: :update_user
      define :get_users, action: :read
      define :get_user_by_id, action: :read, get_by: :id
      define :get_user_by_email, action: :read, get_by: :email
      define :get_users_with_services, action: :read
    end

    resource Eppa.Accounts.Servicios do
      define :get_servicios, action: :read
      define :create_servicio, action: :create
      define :send_email, action: :send_email
      define :get_servicio_by_id, action: :read, get_by: :id
    end

    resource Eppa.Accounts.Profile do
      define :create_perfil, action: :create
      define :update_perfil, action: :update
      define :get_profiles, action: :read
      define :get_perfil_by_id, action: :read, get_by: :id
    end
  end

  authorization do
    # disable using the authorize?: false flag when calling actions
    authorize :always
  end
end

Router

defmodule EppaWeb.Router do
  use EppaWeb, :router

  use AshAuthentication.Phoenix.Router

  import AshAuthentication.Plug.Helpers

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {EppaWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :load_from_session
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug :load_from_bearer
    plug :set_actor, :user
  end



  scope "/api/json" do
    pipe_through [:api]

    forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
      path: "/api/json/open_api",
      default_model_expand_depth: 4

    forward "/", EppaWeb.AshJsonApiRouter
  end

  scope "/", EppaWeb do
    pipe_through :browser

    ash_authentication_live_session :authenticated_routes do
      live "/dashboard", DashboardLive, :index

      live "/users", UserLive.Index, :index
      live "/users/new", UserLive.Index, :new
      live "/users/:id/edit", UserLive.Index, :edit

      live "/users/:id", UserLive.Show, :show
      live "/users/:id/show/edit", UserLive.Show, :edit
      live "/users/:id/assign_profile", UserLive.Index, :assign_profile
      live "/users/:id/servicios", EppaWeb.UserLive.Servicios, :index

      live "/profiles", ProfileLive.Index, :index
      live "/profiles/new", ProfileLive.Index, :new
      live "/profiles/:id/edit", ProfileLive.Index, :edit

      live "/profiles/:id", ProfileLive.Show, :show
      live "/profiles/:id/show/edit", ProfileLive.Show, :edit

      # live "/services", ServiceAssignmentLive.Index, :index
      # live "/services/new", ServiceAssignmentLive.Index, :new
      live "/services", ServicesLive.Index, :index
      live "/services/:id/creditos", ServiceLive.Creditos, :creditos

      # in each liveview, add one of the following at the top of the module:
      #
      # If an authenticated user must be present:
      # on_mount {EppaWeb.LiveUserAuth, :live_user_required}
      #
      # If an authenticated user *may* be present:
      # on_mount {EppaWeb.LiveUserAuth, :live_user_optional}
      #
      # If an authenticated user must *not* be present:
      # on_mount {EppaWeb.LiveUserAuth, :live_no_user}
    end
  end

  scope "/", EppaWeb do
    pipe_through :browser
    # live "/sign-in", SignInLive, :index
    get "/", RedirectController, :to_sign_in
    # get "/", PageController, :home
    auth_routes AuthController, Eppa.Accounts.User, path: "/auth"
    sign_out_route AuthController

    # Remove these if you'd like to use your own authentication views
    sign_in_route register_path: "/register",
                  reset_path: "/reset",
                  auth_routes_prefix: "/auth",
                  on_mount: [{EppaWeb.LiveUserAuth, :live_no_user}],
                  overrides: [EppaWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default]

    # Remove this if you do not want to use the reset password feature
    reset_route auth_routes_prefix: "/auth",
                overrides: [EppaWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default]
  end

  # Other scopes may use custom stacks.
  # scope "/api", EppaWeb do
  #   pipe_through :api
  # end

  # Enable LiveDashboard and Swoosh mailbox preview in development
  if Application.compile_env(:eppa, :dev_routes) do
    # If you want to use the LiveDashboard in production, you should put
    # it behind authentication and allow only admins to access it.
    # If your application does not have an admins-only section yet,
    # you can use Plug.BasicAuth to set up some basic authentication
    # as long as you are also using SSL (which you should anyway).
    import Phoenix.LiveDashboard.Router

    scope "/dev" do
      pipe_through :browser

      live_dashboard "/dashboard", metrics: EppaWeb.Telemetry
      forward "/mailbox", Plug.Swoosh.MailboxPreview
    end
  end
end

API CALL

{
	"data":{
		"type":"servicios",
			"attributes": {
				"user_email": "knd_rt@hotmail.com",
				"destinatario": "blitzlepe@gmail.com" ,
				"cc":[
					"cande.lepe@agustindeiturbide.com"
					],
				"asunto":"Prueba desde Insomnia",
				"contenido_html":"Su pinche madre, si jalo !!!",
				"attachments":[]
				
			}
	}
}

Response

{
	"errors": [
		{
			"code": "required",
			"id": "c5e78bdc-fc81-421e-a9b9-cf71c85256bb",
			"meta": {},
			"status": "400",
			"title": "Required",
			"source": {
				"pointer": "/data/attributes/tipo"
			},
			"detail": "is required"
		},
		{
			"code": "required",
			"id": "c6c953b2-0925-4ada-9c8f-fd682c248b99",
			"meta": {},
			"status": "400",
			"title": "Required",
			"source": {
				"pointer": "/data/attributes/vigencia"
			},
			"detail": "is required"
		}
	],
	"jsonapi": {
		"version": "1.0"
	}
}```

Looking at this, I think you need to revisit your action basics before working on AshJsonApi. Actions don’t have “return values” (except for generic actions, but that is inside the run function) i.e that {:ok, "ok"} is not the return of the action (it just does nothing in fact, its evaluated at compile time).

The arguments also on their own don’t do anything, you need to do something with them like change set_attribute(:some_attr, arg(:some_arg)) (as an example).

The reason you are getting errors about those fields being required is because those attributes are themselves required, and your action is not doing anything to set them:

    attribute :creditos_disponibles, :integer do
      allow_nil? false
      default 0
      public? true
    end

    attribute :vigencia, :datetime do
      allow_nil? false
      public? true
    end

Thanks, I got api working (it receives json payload and persists it to postgres db, really cool!) Now, I want to execute some logic before_action and after_action, but not sure if my approach is correct, should I move this to a generic action??:

defmodule Eppa.Accounts.ApiServices do
  use Ash.Resource,
    otp_app: :eppa,
    domain: Eppa.Accounts,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshJsonApi.Resource]

  alias Eppa.Correos
  alias Eppa.Accounts
  alias Eppa.Accounts.Credits

  json_api do
    type "api_services"
  end

  postgres do
    table "apiservicios"
    repo Eppa.Repo
  end

  actions do
    create :send_email do
      argument :user_email, :string, allow_nil?: false
      argument :destinatario, :string, allow_nil?: false
      argument :cc, {:array, :string}, default: []
      argument :asunto, :string
      argument :contenido_html, :string
      argument :attachments, {:array, :map}, default: []
      argument :plantilla, :string

      before_action(fn changeset ->
        # Got no console print at all
        IO.inspect(changeset, label: "before_action")
        user_email = Ash.Changeset.get_argument(changeset, :user_email)
        user = Eppa.Accounts.get_user_by_email!(user_email, actor: nil, load: [:servicios])

        service = :correos

        case Credits.check_credit(user.id, service) do
          {:ok, credit_service} ->
            case Credits.deduct_credit(credit_service, user, 1) do
              {:ok, updated_service} ->
                IO.inspect(updated_service, label: "Crédito descontado")
                Ash.Changeset.put_context(changeset, :credit_service, updated_service)

              {:error, errors} ->
                IO.inspect(errors, label: "Error al descontar crédito")

                Ash.Changeset.add_error(
                  changeset,
                  :credit,
                  "Error al descontar crédito: #{inspect(errors)}"
                )
            end

          {:error, reason} ->
            IO.inspect(reason, label: "Créditos insuficientes")
            Ash.Changeset.add_error(changeset, :credit, "Créditos insuficientes: #{reason}")
        end
      end)

      change set_attribute(:user_email, arg(:user_email))
      change set_attribute(:destinatario, arg(:destinatario))
      change set_attribute(:cc, arg(:cc))
      change set_attribute(:asunto, arg(:asunto))
      change set_attribute(:contenido_html, arg(:contenido_html))
      change set_attribute(:attachments, arg(:attachments))
      change set_attribute(:plantilla, arg(:plantilla))

      after_action(fn changeset, result ->
        IO.inspect("Ejecutando after_action", label: "DEBUG")
        IO.inspect(result, label: "Resultado antes del envío")

        destinatario = result.destinatario
        cc = result.cc
        asunto = result.asunto
        contenido_html = result.contenido_html
        attachments = result.attachments
        plantilla = result.plantilla

        email_response =
          Eppa.Correos.enviar_email!(
            destinatario,
            cc,
            asunto,
            contenido_html,
            attachments,
            plantilla
          )

        IO.inspect(email_response, label: "Respuesta del envío de correo")

        updated_changeset =
          result
          |> Ash.Changeset.for_update(:send_email, %{email_response: email_response})

        case Eppa.Repo.update(updated_changeset) do
          {:ok, updated_result} ->
            IO.inspect(updated_result, label: "Registro actualizado con email_response")
            :ok

          {:error, err} ->
            IO.inspect(err, label: "Error al actualizar email_response")
        end

        changeset
      end)
    end
  end

  attributes do
    uuid_v7_primary_key :id

    attribute :user_email, :string do
      allow_nil? false
      public? true
    end

    attribute :destinatario, :string do
      allow_nil? false
      public? true
    end

    attribute :cc, {:array, :string} do
      default []
      public? true
    end

    attribute :asunto, :string do
      public? true
    end

    attribute :contenido_html, :string do
      public? true
    end

    attribute :attachments, {:array, :string} do
      default []
      public? true
    end

    attribute :plantilla, :string do
      public? true
    end

    attribute :email_response, :map, default: %{}
    create_timestamp :inserted_at
    update_timestamp :updated_at
  end
end

Api call

{
	"data":{
		"type":"api_services",
			"attributes": {
				"user_email": "knd_rt@hotmail.com",
				"destinatario": "blitzlepe@gmail.com" ,
				"cc":[
					"cande.lepe@agustindeiturbide.com"
					],
				"asunto":"Prueba desde Insomnia",
				"contenido_html":"Su pinche madre, si jalo !!!",
				"attachments":[],
				"plantilla": "UUIDPLANTILLA"
				
			}
	}
}


Response

{
	"data": {
		"attributes": {
			"cc": [
				"cande.lepe@agustindeiturbide.com"
			],
			"asunto": "Prueba desde Insomnia",
			"attachments": [],
			"contenido_html": "Su pinche madre, si jalo !!!",
			"destinatario": "blitzlepe@gmail.com",
			"plantilla": "UUIDPLANTILLA",
			"user_email": "knd_rt@hotmail.com"
		},
		"id": "01963a43-ae7f-775c-b7fa-660b74aad105",
		"links": {},
		"meta": {},
		"type": "api_services",
		"relationships": {}
	},
	"links": {
		"self": "http://localhost:4000/api/json/apiservices"
	},
	"meta": {},
	"jsonapi": {
		"version": "1.0"
	}
}```

Nope, that’s fine to do that way :slight_smile:

I would suggest consolidating it to a change module, where you can add both hooks in one place.

defmodule YourChange do
  use Ash.Resource.Change

  def change(changeset, _, _) do
     changeset
     |> Ash.Changeset.before_action(fn changeset -> ... end)
     |> Ash.Changeset.after_action(fn changeset -> ... end)
  end
end

Ok, looking for another way to do it, it looks I can´t pipe the “after_action”, if I do the response is:

  * ** (BadArityError) #Function<1.94767432/1 in Eppa.Accounts.Changes.EmailChangeLogic.change/3> with arity 1 called with 2 arguments (#Ash.Changeset<domain: Eppa.Accounts, action_type: :create, action: :send_email, attributes: %{id: 
defmodule Eppa.Accounts.Changes.EmailChangeLogic do
  use Ash.Resource.Change
  alias Eppa.Correos
  alias Eppa.Accounts
  alias Eppa.Accounts.Credits

  def change(changeset, _, _) do
    changeset
    |> Ash.Changeset.before_action(fn changeset ->
      IO.inspect(changeset, label: "before_action")
      user_email = Ash.Changeset.get_argument(changeset, :user_email)
      user = Eppa.Accounts.get_user_by_email!(user_email, actor: nil, load: [:servicios])

      service = :correos

      case Credits.check_credit(user.id, service) do
        {:ok, credit_service} ->
          case Credits.deduct_credit(credit_service, user, 1) do
            {:ok, updated_service} ->
              IO.inspect(updated_service, label: "Crédito descontado")

              Ash.Changeset.put_context(changeset, :credit_service, updated_service)

            {:error, errors} ->
              IO.inspect(errors, label: "Error al descontar crédito")

              Ash.Changeset.add_error(
                changeset,
                :credit,
                "Error al descontar crédito: #{inspect(errors)}"
              )
          end

        {:error, reason} ->
          IO.inspect(reason, label: "Créditos insuficientes")
          Ash.Changeset.add_error(changeset, :credit, "Créditos insuficientes: #{reason}")
      end
    end)
    #this throws BadArityError
    # |> Ash.Changeset.after_action(fn changeset ->
    #   IO.inspect("Ejecutando after_action", label: "DEBUG")
    #   IO.inspect(changeset, label: "DEBUG")
    # end)
  end
end

The function in after_action takes two arguments, Ash.Changeset.after_action(changeset, fn changeset, result -> end)

Cool, you really helped me, thanks!!

In the end I just used after_action, the only “issue” was to update an attribute that was just created but not persisted to db yet.
This did the magic

   |> Ash.Changeset.force_set_argument(:email_response, email_response)
          case Mailer.deliver(email) do
                {:ok, resp} ->
                  case resp do
                    {status, body} when is_integer(status) and is_binary(body) ->
                      email_response = %{status: status, response: Jason.decode!(body)}

                      updated_changeset =
                        Ash.Changeset.for_update(result, :update, %{})
                        |> Ash.Changeset.force_set_argument(:email_response, email_response)

                      IO.inspect(updated_changeset)

                      case Eppa.Accounts.update(updated_changeset) do
                        {:ok, updated_result} ->
                          IO.inspect(updated_result,
                            label: "Registro actualizado con email_response"
                          )

                          {:ok, result}

                        {:error, err} ->
                          IO.inspect(err, label: "Error al actualizar email_response")
                          {:error, err}
                      end

                    other ->
                      other
                  end

                {:error, reason} ->
                  IO.inspect(reason, label: "Error al enviar correo")
                  {:ok, %{error: reason}}
              end