Hawk is an independent project developed on my own time, with support from Landfolk. Landfolk provided encouragement, a strong engineering environment, and AI tooling that helped make this work possible. The project direction, implementation choices, and any remaining rough edges are my own.
If you are interested in AI-assisted software development and thoughtful product engineering, see Landfolk Jobs.
Warning
Hawk is under heavy development. Until a stable release, expect breaking changes to APIs, generated code, conventions, and recommended patterns. Hawk prioritizes improving the design over backward compatibility.
Hawk is designed for the shape that appears in many backends: Ecto resources with role-aware reads and writes, a JSON:API surface for clients and integrations, and LiveView screens for internal administration. Hawk tries to make that repeated middle layer boring: resource contracts, policies, readers, writers, adapters, and tests follow one convention instead of being rebuilt per project.
Hawk depends on Ecto, Ecto SQL, Postgrex, and Phoenix (with Phoenix LiveView
and Phoenix Ecto). It does not define or supervise a concrete Ecto.Repo.
Applications provide their own Repo modules, database configuration, migrations,
authentication, and supervision tree.
Hawk is not published on Hex yet. Install it from GitHub while the API is still moving quickly:
def deps do
[
{:hawk, github: "Raphexion/hawk", tag: "v0.7.0"}
]
endThen fetch, compile, and run Hawk's contract gate in the host app:
mix deps.get
mix compile
mix hawk.validateIf you are trying Hawk in a fresh Phoenix app, start with one existing Ecto schema and build the resource siblings around it. Hawk expects the app to own the Repo, migrations, authentication, endpoint, and router; Hawk owns the reusable resource boundary on top.
A Hawk resource facade ties the resource parts together and follows convention by default:
defmodule MyApp.Courses do
use Hawk.Resource, model: MyApp.Course
endBy convention Hawk expects sibling modules such as MyApp.Courses.Reader,
MyApp.Courses.Policy, MyApp.Courses.Writer, MyApp.Courses.JsonApi, and
MyApp.Courses.LiveView. A missing conventional module emits a compile-time
warning (so a facade can compile before its siblings during incremental edits
or code generation), while a present but malformed module still fails fast.
Run mix hawk.validate as the authoritative, order-independent gate once the
whole resource set is written — it validates every discovered Hawk resource
in strict mode (missing siblings raise) plus the full adapter contract.
Intentional absence is explicit and disables the corresponding adapter entrypoint:
defmodule MyApp.CourseSummaries do
use Hawk.Resource,
model: MyApp.CourseSummary,
json_api: false,
live_view: false
endThe facade generates public reader/writer functions plus action/4, and exposes
resource introspection through __hawk_resource__/1. For JSON:API-enabled resources,
json_api_select_fields/2 returns the schema-field projection Hawk will use for a
role and sparse fieldset, giving tests a side-effect-free way to assert visibility
without attaching global repo telemetry. JSON:API rendering discovers
sibling adapter metadata from related models' resource facades, so each resource
has a single source of JSON:API truth. JSON:API controllers generate a stable
hawk_action/2 entrypoint; at runtime it returns not found when no matching
action module or action exists. Read actions are always available, create/update/delete
are always generated (the writer is a required sibling), and writes are gated by
the policy, not by the controller shape. It also validates adapter contracts
at compile time; JSON:API adapter source: entries must point at real model
fields or associations, writable fields must be declared, and LiveView fields /
filters must reference real model fields and declared reader filters.
A Hawk resource has four small modules plus Phoenix-facing helpers:
Modeldeclares the Ecto schema and association resource metadata.Policydeclares who can read/write.Readerowns filtering, sorting, pagination, and policy-aware preloads; cohesive filter groups may live inHawk.Reader.FilterSetmodules.Writerowns validation and mutations.Actionsis optional and declares imperative JSON:API custom actions under/-actions/.- JSON:API, OpenAPI, LiveView, and Plans helpers are generated from those declarations.
The model declares the Ecto schema and association resource metadata. The external JSON:API shape lives in the sibling adapter, not on the model.
defmodule MyApp.Course do
use Hawk.Model
model "courses" do
field(:title, :string)
belongs_to(:school, MyApp.School)
belongs_to(:teacher, MyApp.Teacher)
has_many(:grades, MyApp.Grade)
end
endAssociation resource metadata (:policy, :reader, :resource opts on
belongs_to/has_many/many_to_many) is still declared at the association
site so Hawk readers preload through the associated resource reader instead
of duplicating preload query logic.
Hawk assumes a resource's JSON:API id and member-lookup key is the model's
:id by default. Resources whose backing table has no :id — a database
view keyed by another column, a projection, a summary — declare a different
identity instead of working around the assumption in every adapter:
defmodule MyApp.CourseGradeSummaries do
use Hawk.Resource,
model: MyApp.CourseGradeSummary,
identity: :course_id
endDrop the surrogate primary key on the model with primary_key: false:
defmodule MyApp.CourseGradeSummary do
use Hawk.Model
model "course_grade_summaries", primary_key: false do
field(:course_id, :binary_id)
field(:grade_count, :integer)
field(:average_score, :float)
end
endThe declared identity drives the JSON:API id rendered by Document, the
member-lookup filter and short-id UUID range in the controller, and the
LiveView assign_show/delete lookup key — so a view-backed resource stops
forcing every adapter to special-case :id. Identity is a single field today
(composite keys are a future extension); the field must exist on the model,
enforced at compile time.
Hawk enforces that the identity field exists on the model, but uniqueness,
non-nullability, and a UUID index on it are the resource author's
responsibility — the same trust model Hawk already uses for the default :id
(primary key). Declare identity: on a column that is actually a stable
member key: a non-unique identity makes show ambiguous (the reader's one
raises on multiple matches), and a non-indexed identity makes the short-id
range query scan. Resources that cannot offer a unique member key should keep
the surrogate :id.
belongs_to relationship linkage is rendered from the foreign key value, so the
related resource's declared identity must equal the association's related_key
(the default :id). The common cases satisfy this: a belongs_to to a default
:id resource, or to a declared-identity resource whose foreign key is named
after the identity (e.g. course_id → a CourseRosters resource with
identity: :course_id). Divergence fails at compile time with a clear message,
because Hawk renders JSON:API relationship linkage as pure data and cannot load
the related record to read a non-FK identity — model such relationships as an
explicit writer/action workflow instead.
defmodule MyApp.Courses.Policy do
use Hawk.Policy
read do
role(:system, :all)
role(:public, :all)
role(:school_admin, scopes: [:school_id])
role(:teacher, scopes: [:school_id, :teacher_id])
end
write(roles: [:school_admin])
endpublic is anonymous readonly access. It is not system access and still goes
through the resource policy. Policies expose their read declarations for
contract validation, so ResourceContract can catch scoped policy filters that
are not declared by the reader. For simple ownership-based writes, pass
owned_by: to require model/changeset fields to match authority scopes:
write(roles: [:teacher], owned_by: [teacher_id: :teacher_id])Policy matrix tests can use Hawk.Policy.Assertions to keep role coverage
compact:
import Hawk.Policy.Assertions
assert_read_matrix(MyApp.Courses.Policy, [
{Hawk.Authority.system(), :all},
{Hawk.Authority.new(:teacher, 12, scopes: %{school_id: 7, teacher_id: 12}),
%{school_id: 7, teacher_id: 12}},
{Hawk.Authority.new(:teacher, 12, scopes: %{school_id: 7}), :none}
])defmodule MyApp.Courses.Reader do
use Hawk.Reader.Resource,
repo: MyApp.Repo,
schema: MyApp.Course,
default_page_size: 100,
max_page_size: 100
filter(:id)
filter(:school_id)
filter(:teacher_id)
sort(:id)
sort(:title)
preload(:teacher)
preload(:grades)
endReaders can extract cohesive search capabilities into resource-specific filter sets. A filter set owns its filters, attach rules, constants, and private helpers; the Reader continues to own policy, sorting, pagination, preloads, and its resource-wide scope:
defmodule MyApp.Courses.StudentFilters do
use Hawk.Reader.FilterSet, schema: MyApp.Course
attach :student, when_filter: [:student_name], preserves_roots: true do
join(query, :left, [root: course], student in assoc(course, :students), as: :student)
end
filter :student_name do
fn {:eq, name} -> dynamic([student: student], student.name == ^name) end
end
end
defmodule MyApp.Courses.Reader do
use Hawk.Reader.Resource,
repo: MyApp.Repo,
schema: MyApp.Course
filter(:id)
import_filters(MyApp.Courses.StudentFilters)
sort(:id)
endImported sets contribute to the Reader's normal filter_keys/0,
filter_handlers/0, coordinate_filters/0, and join_plan/0 metadata, so
JSON:API parsing, OpenAPI, policies, and cross-set boolean filter expressions use
the composed contract. Duplicate filter keys and join aliases are rejected
rather than silently overwritten, and a set can only be imported by a Reader for
the same schema. Imported attach rules run in filter-set import order before
resource-local attach rules. As with local Reader attaches, every rule triggered
by the active filter keys is applied before the boolean filter AST is compiled.
Attachments default to preserves_roots: false. Hawk rejects a filter when a
non-preserving attachment is active but some satisfiable OR path does not
require it, because applying that transformation first could silently remove a
valid result. Mark an attachment preserves_roots: true only when it keeps every
root row available to the query, such as a normal left join with no root-narrowing
predicate:
attach :student, when_filter: [:student_name], preserves_roots: true do
join(query, :left, [root: course], student in assoc(course, :students), as: :student)
endRoot preservation concerns inclusion, not uniqueness: a to-many left join may
still duplicate roots. Declare that fact with multiplies_roots: true; Hawk
always counts distinct root identities and exposes the metadata for pagination
diagnostics. A key declared in when_filter must semantically require
its attachment whenever that filter can match; custom handlers must not return
:all for such a value. If every satisfiable path requires the same attachment,
including through an enclosing policy or forced AND filter, a non-preserving
attachment remains valid. Sort-triggered attachments are Reader-owned, but
sorting does not make an otherwise unsafe filter attachment safe: the same
transformation still runs before the OR predicate and may remove roots. See
Understanding preserves_roots for a
beginner-oriented explanation, SQL examples, edge cases, and a testing
checklist.
When Hawk combines policy, forced, and caller filters, overlapping root-field
keys are preserved as boolean AND predicates unless the values are identical.
For example, a policy filter %{country: {:in, ["DK", "NO"]}} combined with a
caller filter %{country: "DK"} compiles as both predicates on the :root
binding instead of collapsing to :none. This matters for policy narrowing and
for joined filters: preserving the boolean shape keeps Filter.keys/1 visible to
join planning while still letting the final compiler apply every predicate after
attachments have been selected.
Filter sets are composed dynamically from one metadata snapshot per set. In a Phoenix development server, changing only a filter-set module is therefore visible to the next Reader call without caching stale declarations in the Reader. The generated handlers use external module captures so helpers remain local to the set and normal code reloading can replace their implementation.
For focused tests, apply a set to an existing root query:
query =
MyApp.Course
|> from(as: :root)
|> MyApp.Courses.StudentFilters.apply_to(%{student_name: "Ada"})
assert [%MyApp.Course{}] = MyApp.Repo.all(query)apply_to/2 validates against only that set and applies its attach rules and
filter compiler. It deliberately does not apply Reader policy, pagination,
preloads, default sorting, or resource scope; those remain Reader and transport
integration-test responsibilities. Readers still compose the complete filter
AST before compilation, preserving AND/OR expressions across sets.
Nested includes such as include=grades.student are turned into nested Ecto
preloads where every layer uses that resource's own reader and policy. Include
paths use external JSON:API relationship names; source: aliases are translated
to internal preload keys at every path segment, matching the generated OpenAPI
values. Opening courses does not accidentally open grades or students.
Compound documents
de-duplicate included resources and omit any resource already present in primary
data, including when an include path cycles back to the root.
Every reader preload/1 must be a relationship exposed by the resource's
JSON:API adapter — mix hawk.validate rejects a preload with no matching
relationship. A reader cannot preload an association it does not expose, so the
reader and the external surface stay a single set of relationships. Sparse
fieldsets are propagated into included-resource preload projections. Direct
has_many relationships can also opt into a per-parent bound:
preload(:images, limit: 50)The bound uses a partitioned window query, so it applies to every parent rather
than to the preload batch as a whole. It is intentionally limited to direct
has_many associations; many-to-many and through associations must use a
purpose-built relationship endpoint.
Readers apply default_page_size when the caller does not request a page size
and reject requests above max_page_size. Both default to 100 and can be
overridden per resource. Collection JSON:API responses include meta.page with
size, number, returned count, and has_more. Hawk fetches one internal
look-ahead row, trims it from the response, and emits next_cursor when another
page exists. Cursors are enabled and signed when
config :hawk, :cursor_secret, ... is set; use a stable application secret in
every environment. Without it, has_more still works and no cursor is emitted,
which keeps existing applications compatible. page[after] consumes that
opaque forward cursor and cannot be
combined with page[number]; page-number and shorthand page_size /
page_number parameters remain supported. For Hawk Reader-backed resources,
the internal look-ahead row changes only the SQL limit; numbered-page offsets
continue to use the requested page size. All sorts gain the resource identity
as a deterministic tie-breaker. Direct
has_many related-resource and relationship-linkage endpoints use the related
reader's pagination settings too, so GET /courses/:id/grades and
GET /courses/:id/relationships/grades do not preload an unbounded child
collection. When a client passes page[total]=true, Hawk Reader pages calculate
the authorized, unpaginated distinct-root total with a window count in the same
SQL query and include meta.page.total_count. An empty non-first page has no row
to carry the window result and falls back to the separate count query.
Hand-written readers that only implement all/1 and count/1 retain the same
fallback.
defmodule MyApp.Courses.Writer do
use Hawk.Writer.Resource,
model: MyApp.Course,
repo: MyApp.Repo,
policy: MyApp.Courses.Policy
create do
defaults(registration_state: "draft")
cast([:title, :teacher_id, :registration_state])
validate_required([:title, :teacher_id])
validate(&reject_reserved_title/1)
end
update do
cast([:title, :teacher_id])
validate(&reject_reserved_title/1)
end
delete(:default)
defp reject_reserved_title(context) do
case Ecto.Changeset.get_change(context.changeset, :title) do
"Forbidden" -> {:error, :title, "is reserved"}
_title -> :ok
end
end
endHawk.Writer.Resource generates change_create/2 / create/2 and
change_update/3 / update/3 from the same pipelines. change_* functions
return non-persisting changesets with action: :validate, which is the boundary
LiveView form helpers use for live validation errors. create/2 and update/3
keep owning persistence through the repository boundary. delete(:default)
generates a policy-checked delete/2 that crosses the same repository boundary.
A read-only resource still keeps a writer sibling and declares write(:never)
in its policy — writes are gated by the policy, not by omitting the writer, so a
mutation attempt returns 403 instead of a 404/500 from a missing delegate.
The create/update/delete blocks are required for that 403 path; the cast
field lists keep the writer ready if the policy is later relaxed. Run
mix hawk.gen.resource MyApp.Things MyApp.Thing --read-only to scaffold the
whole set.
Supported create/update DSL steps are defaults/1, cast/1, validate_required/1,2,
validate/1, validate_changeset/1, and constraint/2. Custom validate/1 functions
can be reused in create and update pipelines when domain validation is not just
standard Ecto changeset validation. Hand-written writers can expose the same form
boundary with change_create/2 and change_update/3 when they need custom
pipelines.
constraint/2 is the one-step way to declare the most common database constraints
without the validate_changeset(fn cs -> ... end) indirection. It desugars to the
matching Ecto.Changeset constraint validator and renders through the error
pipeline at the external JSON:API pointer:
create do
cast([:email, :user_id])
validate_required([:email])
constraint(:unique, :email, name: :email_user_id_unique)
constraint(:foreign_key, :user_id, name: :enrollments_user_id_fkey)
endkind is one of :unique, :foreign_key, :assoc, :check, or :exclusion;
opts are passed straight through to the Ecto constraint function.
Queries are Hawk's read-side extension point for derived resource collections that are not honest CRUD filters. The current Query API declares a source resource, its own read policy, and the small filter surface it can expose. It can fetch an authorized page by intersecting the Query policy with the source resource Reader policy. A constrained rank can order that source-backed page using source Reader sort keys plus an identity tie-breaker. Query routes render that source resource collection through Hawk's normal JSON:API document path, without an application controller. Broader collection features are added by later Query phases after the authorized-root contract is stable.
defmodule MyApp.SimilarCourses.Policy do
use Hawk.Policy
read do
role(:public, :all)
role(:school_admin, scopes: [:school_id])
end
end
defmodule MyApp.SimilarCourses do
use Hawk.Query,
name: :similar_courses,
source: MyApp.Courses,
transaction: true,
pagination: :offset
query_param(:source_course_id,
required: true,
source_filter: :similar_to_course_id
)
filter(:school_id)
rank(:similarity, source_scope: :similarity_order, tie_breaker: :id)
def cast_params(params), do: {:ok, params}
def prepare(repo, params, context) do
# Optional transaction-local setup when `transaction: true` is declared.
:ok
end
endThe Query policy is separate from the source resource policy. It controls who
may run the capability; the source resource policy still controls which rows of
the source resource are visible. Query policy scopes, static filters, and caller
filters must map to filters declared by the Query, and Query filters must map to
filters declared by the source Reader. query_param/2 declares Query-owned
inputs under query[...]; required params return a safe 400 when missing, and
present params can be mapped directly into source Reader filters with
:source_filter so the source Reader does not need a synthetic query-active
filter or side channel. Use source_filter: false for declared params that are
only consumed by cast_params/1, prepare/3, or a source Reader rank scope.
cast_params/1 remains the Query-owned validation/cast hook before mapped params
become source filters. A Query can declare one deterministic rank using source
Reader sort keys plus an identity tie-breaker, or
select a named source Reader rank scope for derived ordering that is not a public
source sort. Reader rank scopes run after authorization/filtering and before
pagination, receive the cast Query params and request opts, and ordinary source
reads do not select them unless a Query asks for one. If transaction: true is
declared, optional prepare/3 runs inside the Hawk-owned transaction before the
source page, count, and preload work. mix hawk.validate discovers both
resources and queries and reports their counts separately.
Expose a resource-result Query with Hawk's generated GET adapter. JSON:API
collection parameters keep their normal names (filter, include, fields,
page); Query-owned domain parameters live under query[...], are checked for
required declarations, and are passed to cast_params/1. The router macro emits
the route even if the Query module is not compiled yet; strict validation still
runs when the Query executes and through mix hawk.validate.
import Hawk.JsonApi.Router
hawk_query "/similar-courses", MyApp.SimilarCourses, public: trueOptional imperative actions live beside Reader and Writer in Actions.ex.
Actions is an orchestration layer above them: it should compose reads and
writes through resource readers and writers, passing the caller's authority
straight through. There is no separate action-level policy.
Warning
Action handlers are trusted application code. Hawk dispatches the handler and passes its authority, but cannot enforce how the handler uses them. Authors are responsible for routing every protected read and write through the appropriate policy-aware Reader or Writer with that authority. Direct Repo calls and other side effects are outside Hawk's authorization guarantees.
Actions are exposed under /-actions/ and keep command-style endpoints
separate from CRUD routes while staying JSON:API-compliant by accepting
parameters in meta.
defmodule MyApp.Courses.Actions do
use Hawk.Actions
alias MyApp.Courses.Writer
action "open-registration",
doc: "Open course registration.",
params: [
seat_count: [type: :integer, doc: "Seats available immediately.", example: 30],
waitlist_count: [type: :integer, doc: "Waitlist capacity.", example: 10]
]
def open_registration(course, params, authority) do
Writer.open_registration(course, params, authority)
end
endRoute action requests to the generated controller hawk_action/2 function, for
example. The resource facade dispatches through <Resource>.action/4; it does
not generate one public function per action.
post "/courses/:id/-actions/:action", CourseController, :hawk_actionRequest shape:
{
"meta": {
"seat_count": 30,
"waitlist_count": 10
}
}The top-level meta object is required, including for actions with no declared
parameters ({"meta": {}}). Missing or non-object meta returns 400; the
OpenAPI request schema carries the same requirement.
An action that composes multiple writers can opt into a validate phase by
declaring build: true (or build: :fn_name) in action/2 and writing a
build_<handler>/3 function that returns a Hawk.Multi of facade-call steps.
Hawk then generates <handler>_change/3 (validate without committing) and
<handler>_run/3 (commit) as projections of that one build_<handler>/3, so
the two phases cannot drift — the same shape that keeps a writer's
change_create/create in sync, lifted to a batch.
defmodule MyApp.Courses.Actions do
use Hawk.Actions
alias MyApp.{Courses, Grades}
action "submit-grade",
doc: "Create a grade and rename the course in one transaction.",
build: true,
params: [
score: [type: :integer, doc: "Numeric grade.", example: 7],
student_id: [type: :string, doc: "Student receiving the grade."]
]
def build_submit_grade(course, params, authority) do
Hawk.Multi.new()
|> Hawk.Multi.create(:grade, Grades, %{score: params.score, student_id: params.student_id, course_id: course.id, school_id: course.school_id}, authority)
|> Hawk.Multi.update(:course, Courses, course, %{title: course.title <> " (graded)"}, authority)
end
end<handler>_change/3 returns a map of step name to non-persisting changeset
(via Hawk.Multi.to_changesets/1); <handler>_run/3 commits the whole batch
in one transaction (via Hawk.Multi.execute/2). A Multi deliberately supports
one Repo only: every resource step must use the Repo passed to execute/3, and
Hawk raises before execution if they differ. Application-owned run/3 callbacks
must honor the same prerequisite because Hawk cannot inspect their side effects.
Hawk.Actions.dispatch/5 routes a two-phase action's commit to
<handler>_run/3 automatically.
JSON:API and LiveView share the same action: POST /-actions/submit-grade
commits, and POST /-actions/submit-grade with dry-run: true validates and
returns a JSON:API error document without committing. In a LiveView,
hawk_validate_action/6 drives live form validation and hawk_action/6
commits, returning the full results map (every step's model) to an on_success
callback.
A multi containing :action or :run steps cannot be validated without
committing (their effects depend on execution), so to_changesets/1 raises for
them. An action built around such steps is run-only: keep the hand-written
<handler>/3 (no build:), and the LiveView validates what it can from the
underlying writers' change_* directly. The contract accepts that some
actions are run-only and cannot be live-validated.
Hawk does not authenticate users itself. Apps can use the small session/assign convention helpers to carry an already-resolved authority through controllers and LiveViews:
authority = MyAppWeb.Auth.authority_for(conn)
conn = Hawk.Authority.Plug.call(conn, resolver: fn _conn -> authority end)
session_authority = Hawk.Authority.Session.dump(authority)
authority = Hawk.Authority.Session.authority_or_public(session)Hawk.PhoenixAuth is the phx.gen.auth-specific bridge. In Plug pipelines it can
read an existing current_scope or a URL-safe Base64 Bearer session token,
convert that scope to a Hawk authority, and assign :hawk_authority for JSON:API
controllers. In LiveView on_mount, use it after the generated UserAuth hook
has assigned current_scope.
Hawk.Authority.Plug / Hawk.Authority.Session are lower-level generic helpers
for apps that are not using the phx.gen.auth scope shape. JSON:API controllers
read their :hawk_authority assign directly. Missing authority falls back to
readonly public access, not system access.
New resources should keep JSON:API exposure in a sibling adapter module:
defmodule MyApp.Courses.JsonApi do
use Hawk.JsonApi.Resource
type("courses")
doc("A course taught by a teacher.")
attribute(:title,
writable: true,
doc: "Human-readable course title.",
example: "Math"
)
attribute(:slug,
source: :public_slug,
creatable: true,
updatable: false
)
relationship(:teacher,
writable: true,
doc: "The teacher responsible for the course.",
example: %{type: "teachers", id: "..."}
)
visibility do
role(:public, hide: [:slug])
end
endField visibility rules are subtractive: the adapter declares the full resource
shape once, and each role may only remove declared attributes or relationships
with hide:. Hawk applies the same visibility rules when parsing sparse
fieldsets/includes, rendering JSON:API documents, and selecting root query
columns, so hidden schema fields are not read just to be discarded later. Visible
computed attributes should declare their source: when they depend on a backing
schema field.
writable: true means both creatable and updatable. Use creatable: and
updatable: when create/update capabilities differ. source: maps the external
JSON:API name to the internal model/writer attr for both rendering and request
payloads. Read-only relationships can expose normal Ecto associations, including
many_to_many projections over internal join schemas. Writable relationships
must be belongs_to associations because Hawk maps them to the owning foreign
key passed into the writer. Mutating has_many, has_one, or many_to_many
relationships should be modeled as explicit writer/action workflows.
When the controller points at a Hawk.Resource facade, Hawk infers the model from the resource:
defmodule MyAppWeb.CourseController do
use Hawk.JsonApi.Controller,
resource: MyApp.Courses,
public: true
endThe backing model is resolved from the facade; the controller does not accept
a :model opt.
Responses use the exact JSON:API media type application/vnd.api+json without a
charset parameter. When a request sends Content-Type, it must use that media
type with only the JSON:API ext/profile parameters; Hawk returns 415 for an
unsupported media type or parameter. When a request sends Accept, it must allow
JSON:API directly or through a wildcard; Hawk returns 406 when no acceptable
media range remains.
Generated controller modules implement the Phoenix controller/Plug contract, so they can be targeted directly by Phoenix routes without a host-application adapter.
Generated actions follow resource capabilities:
index/2show/2create/2(the writer is a required sibling; writes are gated by the policy)update/2(the writer is a required sibling; writes are gated by the policy)delete/2(the writer is a required sibling; writes are gated by the policy)relationship/2forGET .../:id/relationships/:relationshiprelated/2forGET .../:id/:relationshiphawk_action/2forPOST .../:id/-actions/:action
JSON:API update documents must include both data.type and data.id; the body
ID must match the full UUID in the request path. Hawk rejects missing or
conflicting update identity with 400 rather than silently ignoring it.
Hawk.JsonApi.Routes.routes/2 returns the same capability-aware route specs for
framework/router integration:
Hawk.JsonApi.Routes.routes(MyApp.Courses, path_prefix: "/api/v1")Routers can use the macro adapter to emit ordinary get/3, post/3, patch/3,
and delete/3 calls from those specs:
import Hawk.JsonApi.Router
hawk_json_api MyApp.Courses, MyAppWeb.CourseController,
path_prefix: "/api/v1"The macro validates that every emitted route points at an exported controller action, so capability drift fails while the router compiles.
Controller errors use canonical %Hawk.Error{} structs internally and render
JSON:API documents at the adapter boundary:
- invalid include/filter/sort/page:
400 - invalid request document shape, resource type, attributes, or relationships:
400 - authorization failure:
403 - missing record, custom action, or relationship endpoint:
404 - validation failure:
422 - successful deletion:
204 No Contentwith an empty body
Validation error source.pointer values map back to the external JSON:API name
a client sent. An attribute declared with source: (e.g. attribute(:name, source: :title)) renders a validation error at /data/attributes/name, not the
internal /data/attributes/title; a belongs_to foreign key renders at
/data/relationships/{external}. Hawk.JsonApi.Schema.external_pointer/2 owns
that reverse mapping.
show/2 accepts either a full UUID or a short ID, defined as the first
8 hexadecimal characters of a UUID. Short IDs are a human convenience for simple
read-only member lookups only:
GET /courses/:idaccepts full UUIDs and short IDs.- mutations (
PATCH,DELETE), custom actions, relationship endpoints, and request body relationship identifiers require full UUIDs.
Hawk keeps this deliberately prudent because short IDs are prefixes, not stable
identifiers. A short ID lookup resolves through an indexed UUID range, bounded to
2 rows: no match returns 404, exactly one match returns that resource, and
multiple matches return 400 with an ambiguous-prefix error. Mutating through a
prefix would make ambiguity dangerous, so write paths require full UUIDs.
The OpenAPI contract reflects this split: the show id parameter documents
that it accepts short IDs, while PATCH/DELETE/-actions/relationship/
related operations declare id with format: "uuid", so clients know which
lookups are lenient and which are strict.
The range lookup is designed for PostgreSQL UUID primary keys: Hawk turns the
8-character prefix into a lower/upper UUID bound and queries the id field with
>= / <=, so the normal btree UUID index can be used. Hawk intentionally avoids
id::text LIKE 'prefix%', which is easier to write but can force scans or require
a separate functional index.
Hawk rejects unknown query parameter names made only from lowercase ASCII letters
with 400; JSON:API reserves that namespace for specification parameters. Host
applications can use implementation-specific parameter names containing a
non-letter separator (for example analytics_mode) and process them before Hawk;
Hawk ignores those custom parameters.
Collection requests accept JSON:API's ordered, comma-separated sort syntax.
Every field must be declared by the Reader; prefix a field with - for descending
order:
/api/v1/courses?sort=title,-id
Collection requests support filters explicitly declared by the Reader. Declaring an integer schema field once keeps the query surface deliberate while enabling exact and range filtering automatically:
defmodule MyApp.Courses.Reader do
use Hawk.Reader.Resource,
repo: MyApp.Repo,
schema: MyApp.Course
filter(:credit_count)
endResource callers use bare values for equality and operator tuples for comparisons:
MyApp.Courses.all(authority: authority, filter: %{credit_count: 2})
MyApp.Courses.all(authority: authority, filter: %{credit_count: {:gt, 5}})
MyApp.Courses.all(authority: authority, filter: %{credit_count: {:lt, 3}})The equivalent JSON:API query parameters are:
/api/v1/courses?filter[credit_count]=2
/api/v1/courses?filter[credit_count][gt]=5
/api/v1/courses?filter[credit_count][lt]=3
gt/lt are strict comparisons; gte/lte are inclusive. Integer filters
also support eq, neq, in, and not_in. Hawk casts query-string operands
to integers before building the Ecto query, returns 400 for invalid integer
values, and rejects text-only operators such as like/ilike on integer fields.
Filter names are Reader keys; JSON:API attribute source: aliases do not rename
the filter contract. Custom filter/2 handlers continue to own their operand
semantics. The same nested query shape applies to other declared filters:
/api/v1/courses?filter[school_id]=school-1&filter[active][eq]=true&filter[name][ilike]=%25math%25
Custom Reader filters can explicitly opt into structured JSON:API objects:
filter :score_range, value: :object do
fn {:eq, %{"minimum" => minimum, "maximum" => maximum}} ->
minimum = String.to_integer(minimum)
maximum = String.to_integer(maximum)
dynamic(
[root: grade],
grade.score >= ^minimum and grade.score <= ^maximum
)
end
end/grades?filter[score_range][minimum]=7&filter[score_range][maximum]=12
The opt-in is available only to custom filters with a handler block. Hawk
requires the outer value to be an object, recursively preserves nested maps,
lists, and scalar leaves, and keeps caller-controlled nested keys as strings.
After normal filter normalization, the handler receives {:eq, object} exactly
as shown above. The application remains responsible for required members, member
value types, and domain semantics. Filters without value: :object retain the
strict scalar-or-single-operator contract.
Coordinate filtering is an opt-in read capability over an indexed PostGIS
geography(Point, 4326) column. Hawk does not make PostGIS mandatory for
resources that do not declare coordinate filters. Applications using the
capability add Hawk's optional GeoPostGIS dependency directly:
{:geo_postgis, "~> 3.7"}Configure the Postgrex extension as described by GeoPostGIS:
Postgrex.Types.define(
MyApp.PostgresTypes,
[Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(),
json: Jason
)
config :my_app, MyApp.Repo, types: MyApp.PostgresTypesThe host application owns the PostGIS extension, geography column, and GiST index. Keep the column itself as geography — Hawk deliberately does not cast the indexed column inside the query:
execute "CREATE EXTENSION IF NOT EXISTS postgis"
execute "ALTER TABLE campuses ADD COLUMN location geography(Point, 4326)"
execute "CREATE INDEX campuses_location_gist_index ON campuses USING GIST (location)"Declare the Ecto field and an explicit Reader maximum:
# Model. Geo.Point coordinates use {longitude, latitude} order.
field(:location, Geo.PostGIS.Geometry)
# Reader
filter(:location, type: :coordinates, max_radius_meters: 100_000)Resource callers use the near operator:
MyApp.Campuses.all(
authority: authority,
filter: %{
location:
{:near, %{lat: 55.6761, lng: 12.5683, radius_meters: 10_000}}
}
)The equivalent JSON:API request is:
/campuses?filter[location][near][lat]=55.6761&filter[location][near][lng]=12.5683&filter[location][near][radius_meters]=10000
Hawk validates latitude (-90..90), longitude (-180..180), a positive radius,
the declared maximum, required keys, and unknown keys before querying. Invalid
JSON:API input returns 400; rows with null locations do not match. near
filters with PostGIS
ST_DWithin, whose geography
radius is measured in meters and whose bounding-box check can use the GiST index.
It filters only — it does not implicitly sort by distance or expose distance in
the response.
This slice is intentionally read-only. The host application owns coordinate
writes and JSON:API attribute serialization, including construction of
%Geo.Point{coordinates: {lng, lat}, srid: 4326} values.
Sparse fieldsets use standard JSON:API fields[type] params on collection,
member, and related-resource responses. Fieldsets apply independently per
resource type, including included resources:
/api/v1/courses?include=teacher&fields[courses]=title,teacher&fields[teachers]=name
LiveView exposure belongs in a sibling adapter too. Hawk handles data plumbing; your templates still own the markup.
defmodule MyApp.Courses.LiveView do
use Hawk.LiveView.Resource
as(:course)
plural_as(:courses)
index do
filter(:teacher_id)
search(:title, operator: :ilike)
sort(:id)
sort(:title)
table do
column(:title, label: "Course")
column(:registration_state)
column(:teacher_name, label: "Teacher", source: [:teacher, :name])
end
end
show do
field(:title)
field(:registration_state, label: "State")
field(:teacher_name, label: "Teacher", source: [:teacher, :name])
end
create_form do
field(:title, label: gettext("Course"))
field(:teacher_id, label: dgettext("courses", "Teacher"))
end
update_form do
field(:title, label: gettext("Course"))
end
enduse Hawk.LiveView, resource: MyApp.Courses reads as and plural_as from
the LiveView adapter when present, then falls back to model-based convention.
The default "hawk:validate", "hawk:save", and "hawk:delete" event
handlers are generated from the writer sibling (which is always present); a
read-only resource keeps the writer and refuses writes in its policy, so the
handlers exist but never persist. Show pages can load by natural keys when the
reader declares the filter:
socket = CourseLive.assign_show(socket, authority, short_id, lookup: :short_id)LiveView index params are caller-provided narrowing/presentation only;
pass them as params: %{"filter" => ..., "search" => ..., "sort" => ..., "page" => ...}
to assign_index/3. Hawk accepts only filters/searches/sorts declared in the
LiveView adapter and validated against the Reader. Search declarations can turn a
text field into an :ilike filter, so %{"search" => %{"title" => "histo"}}
becomes %{title: {:ilike, "%histo%"}}. Sort and search changes reset the page
number to 1; page changes keep the current query state. Policies remain the
security boundary and are shared with JSON:API reads.
A column or field (index and show) accepts a source: path to reach a
field through an association: column(:teacher_name, source: [:teacher, :name]).
The LiveView adapter declares the shape; the reader owns loading (it must
preload the association); mix hawk.validate enforces that every association a
source: path reaches is a declared reader preload — the same invariant that
holds reader preloads to JSON:API relationships, applied to the LiveView
adapter. assign_index and assign_show derive the preloads from the adapter,
so there is no runtime preloads: option to keep in sync; the caller-supplied
preloads: opt is rejected.
A single-element path (field(:students, source: [:students])) declares a
whole association to preload and display (typically a collection the template
iterates). After loading, Hawk verifies declared source-path associations are
actually available before rendering. If a policy-aware preload filters out a
belongs_to association whose foreign key is set, Hawk raises a targeted
LiveView error that names the field, source path, associated resource, and role
instead of letting the template fail later with Ecto.Association.NotLoaded or
nil.
A deeper path whose leaf is an association
(field(:grades, source: [:grades, :student])) produces a nested preload
(grades: [:student]) for a collection whose elements reach their own
associations.
Form fields (create_form/update_form) do not accept source: paths — they
bind to root-model attrs the writer casts, mirroring JSON:API's
writable-belongs_to→FK rule. A read-only display beside a form uses a show
field, not a form field.
For simple single-resource pages:
defmodule MyAppWeb.CourseIndexLive do
use Hawk.LiveView,
resource: MyApp.Courses
endWhen resource: is a Hawk.Resource facade, Hawk infers the singular/plural assign names from the model (from the LiveView adapter as/plural_as when declared, else the model name). Pass as:/plural_as: only to override those inferred names.
This provides helpers such as assign_index/3, assign_show/4, keyed form
helpers such as assign_new_form/2, and default "hawk:validate",
"hawk:save", and "hawk:delete" event handlers. The form handlers route live
validation and persistence through the writer boundary and map errors into
LiveView-friendly assigns.
A boring generated form can stay almost empty:
defmodule MyAppWeb.CourseLive do
use MyAppWeb, :live_view
use Hawk.LiveView,
resource: MyApp.Courses
def mount(_params, _session, socket) do
{:ok, assign_new_form(socket, current_authority(socket))}
end
end<.form for={@course_form} phx-change="hawk:validate" phx-submit="hawk:save">
<.input field={@course_form[:title]} />
<.button>Save</.button>
</.form>When the app needs custom flow, keep Hawk's helpers and own the events:
defmodule MyAppWeb.CourseLive do
use MyAppWeb, :live_view
use Hawk.LiveView,
resource: MyApp.Courses,
events: false
def mount(_params, _session, socket) do
{:ok, assign_new_form(socket, current_authority(socket))}
end
def handle_event("hawk:validate", params, socket), do: hawk_validate(params, socket)
def handle_event("hawk:save", params, socket) do
hawk_save(params, socket,
on_success: fn socket, course ->
push_patch(socket, to: ~p"/courses/#{course.id}")
end
)
end
endFor richer workspace pages that coordinate related resources:
defmodule MyAppWeb.CourseWorkspaceLive do
use Hawk.LiveView.Page,
resources: [
course: [resource: MyApp.Courses],
students: [resource: MyApp.Students],
grades: [resource: MyApp.Grades]
],
sections: [
basics: [label: "Basics", path: "/courses/:id"],
students: [label: "Students", path: "/courses/:id/students"],
grades: [label: "Grades", path: "/courses/:id/grades"]
]
endhawk_page_sections/0 exposes %{id:, label:, path:} maps for app-owned
workspace/tab navigation. Hawk owns the metadata; your LiveView owns the markup.
Then load the page with per-resource read specs:
CourseWorkspaceLive.assign_page(socket, authority,
course: {:one, filter: %{id: course_id}, preloads: [:teacher]},
students: {:all, filter: %{school_id: school_id}},
grades: {:all, filter: %{course_id: course_id}, preloads: [:student]}
)Each resource still goes through its own reader and policy. The page helper just composes the reads and shared mutation events; it does not create a new bypass around Hawk's authorization model.
When the resource writer exposes form changeset helpers, use Hawk.LiveView
also generates keyed form helpers:
socket = CourseLive.assign_new_form(socket, authority)
socket = CourseLive.assign_edit_form(socket, course, authority)These assign :course_form and :course_form_fields by default and track form
state under :hawk_form_states. create_form fields are assigned for new forms;
update_form fields are assigned for edit forms. Read-only/admin display forms can use
assign_read_form(socket, course); it assigns the update-form fields when present,
falls back to show fields, and records mode: :read without generating save or
validation behavior. Label metadata can use
gettext("...") or dgettext("domain", "...") descriptors without translating
at compile time. Hawk does not own UI translation; by default hawk_field_label/1
returns the descriptor's message id or a humanized field name. Apps that want a
shared resolver can opt in with a small label module:
defmodule MyAppWeb.HawkLabels do
import MyAppWeb.Gettext
def field_label({:gettext, msgid}), do: gettext(msgid)
def field_label({:dgettext, domain, msgid}), do: dgettext(domain, msgid)
end
use Hawk.LiveView,
resource: MyApp.Courses,
label_resolver: MyAppWeb.HawkLabels<.input field={@course_form[field.name]} label={hawk_field_label(field)} />For read/show surfaces, hawk_field_value(model, field) resolves :source and
applies optional formatter functions, so templates can keep display projections
near the LiveView adapter metadata.
Hawk also generates hawk_validate/2 and hawk_save/2,3.
Default handle_event("hawk:validate", ...), handle_event("hawk:save", ...),
and handle_event("hawk:delete", ...) clauses call those helpers unless
events: false is set. Delete events accept only the resource id; authority
is resolved from the server-owned :hawk_authority socket assign (falling back
to public authority), never from browser event parameters. hawk_validate/2
rebuilds a non-persisting validation changeset through change_create/2 or
change_update/3, then assigns a Phoenix to_form(changeset, as: :course) on a
real Phoenix.LiveView.Socket. hawk_save/2 uses the same state to call
create/2 or update/3; validation failures keep the keyed form assigned with
action: :insert or :update, authorization failures assign :hawk_error, and
successful saves assign the saved model under :course. Use hawk_save/3 with
on_success: fn socket, course -> ... end when the app needs post-save behavior
such as navigation while still reusing Hawk's save plumbing. Form helpers
build a Phoenix.HTML.Form through to_form/2 on a real
Phoenix.LiveView.Socket.
Known server-side values can be forced into a form without trusting hidden client inputs:
socket =
CourseLive.assign_new_form(socket, authority,
forced_attrs: %{teacher_id: current_teacher.id},
hidden: [:teacher_id]
)forced_attrs are merged after client params during validation and save, so the
server value wins even if the browser submits a different teacher_id. hidden
removes fields from the assigned form-field metadata; the writer remains the
final acceptance boundary.
Hawk can push a write from one source (the JSON:API controller, another
LiveView, a background job) into every LiveView screen that shows the same
resource, without a reload. Real-time is opt-in per writer and built over
the host application's own Phoenix.PubSub — Hawk does not start or supervise a
PubSub, just as it does not own your Ecto.Repo.
Declare :pubsub on the writer. Every successful create/update/delete
then broadcasts a Hawk.PubSub.Event:
defmodule MyApp.Grades.Writer do
use Hawk.Writer.Resource,
model: MyApp.Grade,
repo: MyApp.Repo,
policy: MyApp.Grades.Policy,
pubsub: MyApp.PubSub # add this line
...
endThe optional :topics opt selects a topic strategy (defaults to
Hawk.PubSub.DefaultTopics). Omit :pubsub for no broadcast.
A Hawk.PubSub.Event — not the model. Each subscriber re-queries through
its own authority, so the resource read_filter/1 is never bypassed. The
writer's view of the record is never pushed to readers; a viewer whose role
hides the record re-queries and simply does not see it.
Broadcasts fire after the write's transaction commits, so a cross-process
subscriber re-querying immediately sees the committed row. A direct broadcasting
Writer must own the outer transaction; Hawk rejects calling it inside an
application-owned Repo.transaction/1 because it cannot observe that outer
transaction's commit. Compose transactional Hawk writes with Hawk.Multi
instead: it queues its writers' events and flushes them only after the transaction
it owns commits, while failed multis and plan previews discard the queue. This
prevents both premature and silently missing events. A no-op update (empty
changes) and an unauthorized/invalid write do not broadcast.
Subscribe is routing, not authorization: it picks which channel a screen
joins from the socket's assigns. Authorization happens later, in refresh/3,
which re-queries through the socket's authority. Set the routing context
(e.g. the current school id) in mount, subscribe, then match the event in
handle_info and call Hawk.LiveView.refresh/3:
defmodule MyAppWeb.GradesLive do
use MyAppWeb, :live_view
use Hawk.LiveView, resource: MyApp.Grades
def mount(_params, _session, socket) do
socket =
socket
|> assign(:current_school_id, current_school(socket))
|> assign(:hawk_authority, current_authority(socket))
{:ok, Hawk.LiveView.subscribe(socket, MyApp.Grades)}
end
def handle_info(%Hawk.PubSub.Event{resource: MyApp.Grades}, socket) do
{:noreply, Hawk.LiveView.refresh(socket, MyApp.Grades)}
end
endsubscribe/2 reads :pubsub and the topic strategy from the resource, so the
author passes only the resource. refresh/3 detects the current screen (index
vs show) from existing assigns and re-runs the read through the socket's
authority (from opts[:authority], the :hawk_authority assign, or
Hawk.Authority.public() as a fallback). Index refreshes preserve both the
interactive filter/page/sort state and caller-supplied base reader options such
as fixed filters or context. A delete degrades gracefully: the
index no longer includes the record, and a show screen re-assigns :hawk_error
because the record is gone.
The default topic strategy (Hawk.PubSub.DefaultTopics) broadcasts to a shared
resource topic and a per-record instance topic:
- Resource topic —
"hawk:grades"— every create/update/delete. - Instance topic —
"hawk:grades:<identity-value>"— changes to one record.
That shared resource topic reaches every subscriber. For a multi-tenant SaaS,
that means a write at tenant A makes a LiveView at tenant B re-query — no data
leaks (B's read_filter scopes its own results), but the write crosses tenants
unnecessarily.
The :topics opt is the escape hatch. Implement Hawk.PubSub.TopicStrategy
(two callbacks: broadcast_topics/3 from the model, subscribe_topics/2 from
the socket assigns) and scope topics by tenant:
defmodule MyApp.PubSub.Topics do
@behaviour Hawk.PubSub.TopicStrategy
@impl true
def broadcast_topics(_resource, _operation, model) do
["hawk:grades:school:#{model.school_id}"]
end
@impl true
def subscribe_topics(_resource, assigns) do
["hawk:grades:school:#{assigns[:current_school_id]}"]
end
endThen wire it on the writer:
use Hawk.Writer.Resource,
model: MyApp.Grade,
repo: MyApp.Repo,
policy: MyApp.Grades.Policy,
pubsub: MyApp.PubSub,
topics: MyApp.PubSub.TopicsBoth sides live in one app-owned module, so the broadcast and subscribe topics
stay in lockstep — there is no two-sided Hawk split to drift. A teacher at
school A subscribes to hawk:grades:school:<A> and only receives their own
school's writes: no wasted re-query, no cross-tenant delivery. A cross-tenant
role (e.g. a principal with :all) can subscribe to a different topic or the
bare resource topic.
The broadcast side reads the tenant off the model; the subscribe side
reads it off the socket assigns (the screen's routing context, set in
mount). Authorization stays in refresh/3 through the socket's authority —
subscribe never authorizes.
The shared resource topic (the default strategy) lets every subscriber learn
that a write occurred and the changed record's identity value, even when the
subscriber's read_filter/1 would hide that record. The visible data never
leaks — a subscriber whose authority hides the record re-queries and does not
see it — but the event metadata (a write happened, plus an opaque id) is
observable to everyone on the topic. A tenant-scoped topic strategy removes
this for the multi-tenant case: subscribers only join their own tenant's topic.
The generated delete(:default) and the generated create/update already
broadcast. If you write a delete/2 by hand, inherit broadcast by passing the
writer's own options to the boundary:
def delete(%MyApp.Grade{} = grade, authority) do
Hawk.MutationContext.delete(grade, authority)
|> Hawk.MutationContext.validate_policy(&MyApp.Grades.Policy.delete?/1)
|> Hawk.RepositoryBoundary.delete(MyApp.Repo, __hawk_writer_opts__())
enddefmodule MyAppWeb.OpenApiController do
use Hawk.OpenApi.Controller,
title: "My API",
version: "1.0.0",
path_prefix: "/api/v1",
resources: [MyApp.Courses, MyApp.Grades],
servers: [%{url: "https://api.example.com"}],
security: [%{"bearerAuth" => []}],
security_schemes: %{
bearerAuth: %{type: "http", scheme: "bearer", bearerFormat: "JWT"}
}
endThe controller serves the spec as application/json (an OpenAPI document is
JSON, not a JSON:API resource). It passes :title, :version, :path_prefix,
:license, :servers, :security, and :security_schemes straight through to
Hawk.OpenApi.spec/2.
Hawk.OpenApi.spec/2 takes Hawk.Resource facades. Facades with json_api: false
are omitted because this OpenAPI generator documents the JSON:API surface only.
The controller exposes spec/0 and show/2. The specification is composed from
Hawk resource declarations and the same Hawk.JsonApi.Routes route specs used for
capability-aware routing. Response schemas require the JSON:API members Hawk
always emits (data, resource type/id, and error-document errors) so
generated clients do not type them as optional. Success documents also describe
optional top-level links, compound-document included, pagination meta.page,
resource links, and relationship links using reusable component schemas. The
spec also includes JSON:API adapter schemas, request bodies, error documents,
406/415 media-type negotiation responses, sort parameters, pagination
parameters, valid include paths, declared
/-actions/ operations, relationship routes, the optional path_prefix, and
optional resource organization metadata.
Custom actions automatically appear in the OpenAPI/Swagger spec as POST
operations under paths such as /api/v1/courses/{id}/-actions/open-registration.
Their request bodies are documented as JSON:API documents with a meta object,
and successful responses use the normal resource schema.
Add tag/1 and group/1 inside the JSON:API adapter to make Swagger UI
easier to navigate:
defmodule MyApp.Courses.JsonApi do
use Hawk.JsonApi.Resource
type("courses")
tag("Academics")
group("Courses")
doc("A course taught by a teacher at a school.")
endtag/1 becomes the OpenAPI operation tag and top-level tag entry. group/1 is
emitted as x-resource-group; Hawk also emits x-resource-type so downstream
clients and docs can keep related JSON:API resources together without guessing
from path names. Pass tag/2 with a :description to populate the top-level
tag's description (Swagger UI shows it next to the group), which keeps the spec
free of tag-description warnings:
tag("Academics", description: "Academic resources: courses, grades, and enrollments.")Multiple resources sharing a tag name collapse to one top-level tag entry; when any of them declares a description, that description wins.
Relationship schemas are typed from the model association: a belongs_to/
has_one relationship renders as a to-one data identifier or null, and a
has_many/many_to_many relationship renders as a data array of identifiers.
Request relationship objects require data, and each non-null identifier
requires the related JSON:API type and an id, matching request validation.
The target type is resolved from the association (or the related model's adapter
by convention), so no per-relationship :resource opt is needed.
Frontend teams can generate TypeScript from that OpenAPI contract with their preferred tooling, for example:
npx openapi-typescript http://localhost:4000/openapi.json -o src/api/types.tsHawk intentionally stays centered on the backend contract instead of owning a frontend generator or client runtime.
Plans are a human-in-the-loop execution mode over the existing JSON:API/Action resource surface. An external AI composes a proposed batch of resource operations for a specific problem, and a non-technical human reviews the rendered effects and approves it before it touches production data — with the whole batch executed atomically under the reviewer's authority, no AI in the loop at execution.
The plan language is a sequence of resource-shaped operations (:create,
:update, :delete, :action) that the AI composes against the resource
spec (generated by Hawk.Plans.Spec, the resource-shaped equivalent of OpenAPI).
The executor (Hawk.Plans.run/2) runs the batch in a single Hawk.Multi
transaction under the reviewer's authority, all-or-nothing. The dry-run
(Hawk.Plans.preview/2) executes in a transaction and rolls back, giving the
human a full-fidelity effects preview.
Hawk owns the plan struct, the spec renderer, and the execution invariants
(Hawk.Plan, Hawk.Plans.Spec, Hawk.Plans, Hawk.Multi). The host app owns
plan storage (its own plans table + migration) and plan-lifecycle auth
(who can create/approve a plan — a product decision). This mirrors how Hawk
treats the Repo: Hawk does not define the plans table; the app provides it.
mix hawk.plans.spec -o tmp/plans.json # generate the plan operation manifestHawk does not emit its own controller telemetry. Generated JSON:API controllers
run behind standard Phoenix endpoints, so request-level latency and status come
from Phoenix's built-in [:phoenix, :endpoint, :start/:stop] and
[:phoenix, :router_dispatch, :start/:stop] events. Attach Telemetry.Metrics
to those the same way you would for any Phoenix controller.
For a quick skeleton around an existing Ecto schema, use:
mix hawk.gen.resource MyApp.Courses MyApp.Course \
--repo MyApp.Repo \
--attributes title,code \
--relationships school,teacher \
--filters school_id,teacher_id \
--preloads school,teacherThis creates the facade, policy, reader, JSON:API adapter, LiveView adapter, and
writer skeleton. Pass --read-only to gate writes with write(:never) in the
policy; the writer skeleton is still emitted, so the routes and handlers exist
and refuse writes. Pass --web MyAppWeb to also generate a Phoenix JSON:API controller,
clickable LiveView index/show modules and templates, and a router snippet file
beside the generated web files. The generated LiveViews use
Hawk.Authority.Session.authority_or_public/1, so they work for public demos and
can later pick up a session-backed authority. The generator is intentionally
conservative: it gives you the standard Hawk shape, then you tighten policy,
filters, labels, docs, and writer rules by hand.
use Hawk.Resource validates at compile time, but a missing sibling emits a
warning rather than raising, so a facade can compile before its siblings during
incremental edits or code generation. A present but malformed sibling still
fails fast — that is real contract drift, not a write-order artifact.
mix hawk.validate is the authoritative, order-independent gate. It validates
every discovered Hawk resource in strict mode (missing siblings raise) and runs
the full Hawk.ResourceContract cross-checks. The mix test alias runs it
first, so mix test is the complete local gate — contract validation plus the
suite, same path CI takes:
mix test # mix hawk.validate + the test suite
mix hawk.validate # discover and validate all Hawk resources
mix hawk.validate MyApp.Courses # validate explicit resource(s)mix hawk.openapi writes an OpenAPI spec from every discovered Hawk facade
(json_api: false resources are omitted by Hawk.OpenApi.spec/2), so the spec
stays in sync with the resources that actually exist — no hand-maintained list
to drift:
mix hawk.openapi -o tmp/openapi.json --title "My API" --version 1.0.0 \
--path-prefix /api/v1
mix hawk.openapi MyApp.Courses -o spec.json --title "My API" # override discovery
mix hawk.openapi -o spec.json --title "My API" --license "Apache-2.0" \
--license-url "https://www.apache.org/licenses/LICENSE-2.0"--title is required: Hawk is a library and does not name the host app's API.
info.license is the host application's choice — Hawk does not pick a license
for you. Pass --license (and optionally --license-url) to render it; omit
both to leave info.license out of the spec.
defmodule MyApp.CoursesContractTest do
use Hawk.ResourceContractCase,
resource: MyApp.Courses,
model: MyApp.Course
endThe contract test checks that JSON:API attributes, relationships,
creatable/updatable fields, reader preloads, sorts, filters, and scoped policy
filters agree with the model, reader, and policy declarations. For resources
where every exposed relationship is expected to be include/preloadable, call
Hawk.ResourceContract.validate!/3 with require_relationship_preloads: true.
defmodule MyAppWeb.CoursesControllerTest do
use Hawk.JsonApiControllerCase,
controller: MyAppWeb.CoursesController,
resource: MyApp.Courses,
model: MyApp.Course,
repo: MyApp.Repo
authorities do
school_id = "00000000-0000-0000-0000-000000000007"
teacher_id = "00000000-0000-0000-0000-000000000012"
%{
school_admin: Hawk.Authority.new(:school_admin, "00000000-0000-0000-0000-000000000001", scopes: %{school_id: school_id}),
teacher: Hawk.Authority.new(:teacher, teacher_id, scopes: %{school_id: school_id, teacher_id: teacher_id})
}
end
pre_sample authorities do
%{
school: %MyApp.School{id: authorities.school_admin.scopes.school_id},
teacher: %MyApp.Teacher{id: authorities.teacher.identity, school_id: authorities.teacher.scopes.school_id}
}
end
sample _authorities, known, index do
%MyApp.Course{
id: "00000000-0000-0000-0000-00000000000#{index}",
title: "Course #{index}",
school_id: known.school.id,
teacher_id: known.teacher.id
}
end
test "specific business rule still fits beside the generated matrix" do
item_1 = generate_sample(1)
item_2 = generate_sample(2)
assert item_1.school_id == item_2.school_id
end
endThe generated matrix exercises each configured authority against index,
paginated index, show, create, update, and delete. :public is always
included even when not listed in authorities, and its expected result comes
from the resource policy. Expected access is derived from the resource reader
and policy, while mutation payloads are built from JSON:API examples unless
create_params / update_params are supplied.
Controller cases define authorities and samples with small DSL blocks:
authorities do ... endreturns a map of namedHawk.Authorityvalues. Hawk still adds:publicautomatically when omitted.pre_sample(authorities) do ... endoptionally builds shared context once, such as parent records. The default returns%{}.sample(authorities, known, index) do ... endbuilds deterministic resource samples from the authorities and known context. This callback is required; if it is missing, Hawk raises a clear error explaining which function to add.
The matrix uses the generated samples for collection and pagination coverage,
and sample_model() — the first generated sample — for show/update/delete. The
default generated collection size is 3; only pass sample_count: when a
resource needs a larger collection.
Specific business-rule tests can call generate_sample(index) or
generate_samples(count) directly. These helpers reuse the same authorities,
pre_sample, and sample definitions as the generated matrix. pre_sample is
cached per test process, so it also works when it creates real PostgreSQL data
through fixtures or factories and several generated samples need to share those
records.
Applications should import Hawk's formatter settings so the DSL stays tidy:
# .formatter.exs
[
import_deps: [:hawk],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]Hawk macros generate stable entrypoints, not snapshots of sibling metadata. The safe boundary is: bake module references and explicit local options; call functions to read sibling metadata when the generated code runs.
That means:
Hawk.LiveViewgenerated helpers pass the resource facade and explicit local options. Runtime helpers readresource.__hawk_resource__(:live_view)and__hawk_live_view__/0when assigning tables, fields, forms, and default assign names.<Resource>.action/4is the only facade action entrypoint. It resolves the currentActionsmodule metadata and dispatches by name at runtime; facades do not generate one public function per action.- JSON:API controllers generate a stable
hawk_action/2function and let runtime dispatch decide whether an action exists.
When a macro-generated function consumes sibling metadata, bake how to ask,
not the answer. Macro.escape/1 is fine for values owned by the macro call
itself, such as literal options or a DSL block compiled into the same module. It
is not fine for metadata owned by another sibling module, such as
__hawk_live_view__/0, __hawk_actions__/0, or __hawk_json_api__/0.