# `Ankusa.Routes.Route`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/routes/route.ex#L1)

One route definition: which requests the edge captures when route management
is on.

A route is a **capture gate** that is *owned* by a tenant and may be *bound* to
a source. It decides method and path matching and, optionally, which client
addresses may use it; `Ankusa.RouteResolver` still maps the accepted request
to a `source_id` and `tenant_id`, and the route then has to agree with that
answer (`Ankusa.Edge.Ingest.lookup/3`):

  * `tenant_id` (default `"default"`) — the owning tenant. A route owned by a
    tenant admits hooks only into that tenant: a request whose resolver names
    another tenant, or whose source belongs to another tenant, is a `404`.
    The tenant `"default"` is the shared one (as it is for a source): such a
    route is not tied to a tenant. A route owned by a tenant, matched on a
    shared source with no tenant in the URL, files the hook under the route's
    tenant.
  * `source_id` (default `nil`) — the one source the route admits. A request
    the resolver maps to another source is a `404`; `nil` leaves it to the
    resolver.

## Path patterns

Segments are `/`-separated and one of three things:

  * a **literal** (`hooks`, `stripe.com`, `2024-01-01`) — matched with `==`;
  * a **param** (`:tenant`, `:source_id`) — exactly one segment, any value;
  * the **wildcard** `*`, legal only as the last segment — one *or more*
    remaining segments, so `/hooks/shopify/*` matches `/hooks/shopify/a/b` and
    not `/hooks/shopify`.

No regex: it invites ReDoS in a component that runs before any authentication,
and neither the `:tenant` nor the `*` form needs it.

## Validation

Every rule lives in `from_attrs/2`, so the admin API, the config seed, and a
Redis-stored definition are all parsed by the same code and produce the same
error tuples. `{:invalid, field, message}` names the offending field and, where
the field is a list, the index.

# `ip_rule`

```elixir
@type ip_rule() :: %{
  action: :allow | :deny,
  cidr: %CIDR{first: term(), hosts: term(), last: term(), mask: term()}
}
```

# `t`

```elixir
@type t() :: %Ankusa.Routes.Route{
  enabled: boolean(),
  id: String.t(),
  inserted_at: DateTime.t(),
  ip_rules: [ip_rule()],
  metadata: map(),
  methods: [String.t()],
  path: String.t(),
  revision: pos_integer(),
  source_id: String.t() | nil,
  tenant_id: String.t(),
  updated_at: DateTime.t()
}
```

# `from_attrs`

```elixir
@spec from_attrs(map() | keyword(), keyword()) ::
  {:ok, t()} | {:error, {:invalid, String.t(), String.t()}}
```

Build a route from client or config attributes.

`opts[:id]` wins over `attrs["id"]`: on `PUT`/`PATCH` the id in the URL is the
one being addressed, and a body naming a different one must not silently move
the route. An id is generated when neither is present.

Attrs are a map or keyword list with string or atom keys; `inserted_at` and
`updated_at` are not settable, so an update cannot forge its own history.

# `from_json`

```elixir
@spec from_json(map()) :: {:ok, t()} | {:error, {:invalid, String.t(), String.t()}}
```

The inverse of `to_json/1`, for a definition read back from a store.

# `new_timestamp`

```elixir
@spec new_timestamp() :: DateTime.t()
```

A UTC timestamp truncated to the second: the granularity these ids keep.

# `parse_ip_rules`

```elixir
@spec parse_ip_rules(term()) ::
  {:ok, %{default: :allow | :deny, rules: [ip_rule()]}}
  | {:error, {:invalid, String.t(), String.t()}}
```

Parse a global rule list — the admin API's `PUT /admin/ip-rules` body and the
Redis store's `ip_rules` value are both this shape — into the store form.

`default` and `rules` are both required. This replaces a security control
wholesale, so nothing is filled in on the caller's behalf: an omitted `default`
used to mean `allow`, which quietly turned a deny-by-default list into an open
one.

# `parse_rules`

```elixir
@spec parse_rules(term()) :: {:ok, [ip_rule()]} | {:error, String.t()}
```

Parse an ordered rule list into `t:ip_rule/0`s, naming the index of the first
bad rule.

Shared by a route's own `ip_rules`, `config.routes.ip_rules.rules`, and the
admin API's `PUT /admin/ip-rules` body, so a rule that is accepted in one place
is accepted in all three and the message is the same.

# `rule_json`

```elixir
@spec rule_json(ip_rule()) :: map()
```

The JSON view of one IP rule, for the admin API.

# `rules_json`

```elixir
@spec rules_json(%{default: :allow | :deny, rules: [ip_rule()]}) :: map()
```

The JSON view of a whole global rule list (`%{default: ..., rules: [...]}`).

The admin API's `/admin/ip-rules` body and the Redis store's `ip_rules` value
are this shape, so a rule renders identically wherever it is read from.

# `stringify`

```elixir
@spec stringify(term(), String.t()) ::
  {:ok, %{required(String.t()) =&gt; term()}}
  | {:error, {:invalid, String.t(), String.t()}}
```

Coerce a map or keyword list into a string-keyed map. `field` names the value
in error tuples: a non-keyword list is `"must be a map"` (the stricter
`ip_rules` message), anything else non-map is `"must be a map or keyword
list"`.

# `to_json`

```elixir
@spec to_json(t()) :: map()
```

The JSON view of a route: what the admin API returns and Redis stores.

# `to_stored_json`

```elixir
@spec to_stored_json(t()) :: map()
```

The JSON a store persists: `to_json/1` without the binding fields a route
that has none (`tenant_id: "default"`, `source_id: nil`) leaves out, so a
node that predates them still reads every definition written before a tenant
route exists (`from_json/1` gives the omitted fields their defaults).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
