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