defmodule Inngest.Function do
@moduledoc """
Module to be used within user code to setup an Inngest function.
Making it servable and invokable.
Creating an Inngest function is as easy as using `Inngest.Function`. It creates
the necessary attributes and handlers it needs to work with Inngest.
defmodule MyApp.Inngest.SomeJob do
use Inngest.Function
alias Inngest.{FnOpts, Trigger}
@func %FnOpts{id: "my-func", name: "some job"}
@trigger %Trigger{event: "job/foobar"}
@impl true
def exec(ctx, input) do
{:ok, "hello world"}
end
# Optional handler to handle failures when the function fails
# after all retries are exhausted.
def handle_failure(ctx, %{step: step} = _args) do
_ = step.run(ctx, "handle-failure", fn ->
"Do something here"
end)
{:ok, "error handled"}
end
end
## Function Options
Assign `Inngest.FnOpts` to `@func` to configure the function. See `Inngest.FnOpts`
to see what options are available.
## Trigger
A trigger is causes a function to run. One of the following is required, and
they're mutually exclusive.
#### `event` - `string` and/or `expression` - `string`
The name of the event that will trigger this event to run.
We recommend it to name it with a prefix so it's a easier pattern to identify
what it's for.
e.g. `auth/signup.email.send`
#### `cron` - `string`
A [unix-cron](https://crontab.guru/) compatible schedule string.
Optional timezone prefix, e.g. `TZ=Europe/Paris 0 12 * * 5`.
"""
alias Inngest.{Config, Trigger}
alias Inngest.Function.{Context, Input}
@doc """
Returns the function's human-readable ID, such as "sign-up-flow"
"""
@callback slug() :: String.t()
@doc """
Returns the function name
"""
@callback name() :: String.t()
@doc """
Returns the event name or schedule that triggers the function
"""
@callback trigger() :: Trigger.t()
@doc """
The method to be called when the Inngest function starts execution.
Only this method needs to be provided.
"""
@callback exec(Context.t(), Input.t()) :: {:ok, any()} | {:error, any()}
defmacro __using__(_opts) do
quote location: :keep do
alias Inngest.{Client, FnOpts, Trigger}
Enum.each(
[:func, :trigger],
&Module.register_attribute(__MODULE__, &1, persist: true)
)
@behaviour Inngest.Function
@impl true
def slug() do
fn_opts()
|> Map.get(:id)
end
@impl true
def name() do
case fn_opts() |> Map.get(:name) do
nil -> slug()
name -> name
end
end
@impl true
def trigger() do
__MODULE__.__info__(:attributes)
|> Keyword.get(:trigger)
|> List.first()
end
def slugs() do
failure = if failure_handler_defined?(), do: [failure_slug()], else: []
[slug()] ++ failure
end
def serve(path) do
handler =
if failure_handler_defined?() do
id = failure_slug()
[
%{
id: id,
name: "#{name()} (failure)",
triggers: [
%Trigger{
event: "inngest/function.failed",
expression: "event.data.function_id == \"#{slug()}\""
}
],
steps: %{
step: %Inngest.Function.Step{
id: :step,
name: "step",
runtime: %{
type: "http",
url: "#{Config.app_host() <> path}?fnId=#{id}&step=step"
},
retries: %{
attempts: 0
}
}
}
}
]
else
[]
end
[
%{
id: slug(),
name: name(),
triggers: [trigger()],
steps: %{
step: %Inngest.Function.Step{
id: :step,
name: "step",
runtime: %{
type: "http",
url: "#{Config.app_host() <> path}?fnId=#{slug()}&step=step"
},
retries: %{
attempts: retries()
}
}
}
}
|> maybe_debounce()
|> maybe_priority()
|> maybe_batch_events()
|> maybe_rate_limit()
|> maybe_idempotency()
|> maybe_concurrency()
|> maybe_cancel_on()
] ++ handler
end
defp retries(), do: fn_opts() |> Map.get(:retries)
defp maybe_debounce(config),
do: fn_opts() |> Inngest.FnOpts.validate_debounce(config)
defp maybe_priority(config),
do: fn_opts() |> Inngest.FnOpts.validate_priority(config)
defp maybe_batch_events(config),
do: fn_opts() |> Inngest.FnOpts.validate_batch_events(config)
defp maybe_rate_limit(config),
do: fn_opts() |> Inngest.FnOpts.validate_rate_limit(config)
defp maybe_idempotency(config),
do: fn_opts() |> Inngest.FnOpts.validate_idempotency(config)
defp maybe_concurrency(config),
do: fn_opts() |> Inngest.FnOpts.validate_concurrency(config)
defp maybe_cancel_on(config),
do: fn_opts() |> Inngest.FnOpts.validate_cancel_on(config)
defp fn_opts() do
case __MODULE__.__info__(:attributes) |> Keyword.get(:func) |> List.first() do
nil -> %Inngest.FnOpts{}
val -> val
end
end
defp failure_handler_defined?() do
__MODULE__.__info__(:functions) |> Keyword.get(:handle_failure) == 2
end
defp failure_slug(), do: "#{slug()}-failure"
end
end
@doc false
@spec validate_datetime(any()) :: {:ok, binary()} | {:error, binary()}
def validate_datetime(%DateTime{} = datetime),
do: Timex.format(datetime, "{YYYY}-{0M}-{0D}T{h24}:{m}:{s}Z")
# TODO:
# def validate_datetime(%Date{} = date), do: nil
def validate_datetime(datetime) when is_binary(datetime) do
with {:error, _} <- Timex.parse(datetime, "{RFC3339}"),
{:error, _} <- Timex.parse(datetime, "{YYYY}-{MM}-{DD}T{h24}:{mm}:{ss}"),
{:error, _} <- Timex.parse(datetime, "{RFC1123}"),
{:error, _} <- Timex.parse(datetime, "{RFC822}"),
{:error, _} <- Timex.parse(datetime, "{RFC822z}"),
# "Monday, 02-Jan-06 15:04:05 MST"
{:error, _} <- Timex.parse(datetime, "{WDfull}, {D}-{Mshort}-{YY} {ISOtime} {Zname}"),
# "Mon Jan 02 15:04:05 -0700 2006"
{:error, _} <- Timex.parse(datetime, "{WDshort} {Mshort} {DD} {ISOtime} {Z} {YYYY}"),
{:error, _} <- Timex.parse(datetime, "{UNIX}"),
{:error, _} <- Timex.parse(datetime, "{ANSIC}"),
# "Jan _2 15:04:05"
# "Jan _2 15:04:05.000"
{:error, _} <- Timex.parse(datetime, "{Mshort} {_D} {ISOtime}"),
# {:error, _} <- Timex.parse(datetime, "{Mshort} {_D} {ISOtime}"),
{:error, _} <- Timex.parse(datetime, "{ISOdate}") do
{:error, "Unknown format for DateTime"}
else
{:ok, _val} ->
{:ok, datetime}
_ ->
{:error, "Unknown result"}
end
end
def validate_datetime(_), do: {:error, "Expect valid DateTime formatted input"}
end