diff --git a/elixir/SKILL.md b/elixir/SKILL.md index 4bb390e..388fad5 100644 --- a/elixir/SKILL.md +++ b/elixir/SKILL.md @@ -6,6 +6,7 @@ |------|-------| | Current Version | **Elixir 1.19.5** (target for all new code) | | Required OTP | **OTP 27+** (OTP 28 supported) | +| Phoenix Version | **1.8.5** (latest — requires `:formats` on controllers) | | Cortex Status | Elixir **not yet installed** on cortex.hydrascale.net | | AI Agent Tooling | `usage_rules` hex package (~> 1.2) — **always include** | | Production Framework | **Ash Framework** for substantial projects | @@ -655,11 +656,612 @@ end --- +## Phoenix Framework — v1.8.5 (Current) + +Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment. + +### What's New in Phoenix 1.8 + +- **Scopes** in generators — secure data access by default (e.g., `current_user` automatically applied) +- **Magic links** (passwordless auth) and **"sudo mode"** in `mix phx.gen.auth` +- **daisyUI** integration — light/dark/system mode support out of the box +- **Simplified layouts** — single `root.html.heex` wraps everything; dynamic layouts are function components +- **`use Phoenix.Controller` now requires `:formats`** — must specify `formats: [:html]` or `formats: [:html, :json]` +- **Updated security headers** — `content-security-policy` with `base-uri 'self'; frame-ancestors 'self'`; dropped deprecated `x-frame-options` and `x-download-options` +- **`config` variable removed** from `Phoenix.Endpoint` — use `Application.compile_env/3` instead +- **Deprecated**: `:namespace`, `:put_default_views`, layouts without modules, `:trailing_slash` in router + +### Project Setup + +```bash +# New project (Phoenix Express — quickest path) +curl https://new.phoenixframework.org/my_app | sh + +# Traditional setup +mix phx.new my_app +mix phx.new my_app --no-ecto # Without database +mix phx.new my_app --no-html # API only +mix phx.new my_app --database sqlite3 # SQLite instead of Postgres +``` + +### Directory Structure + +``` +my_app/ +├── lib/ +│ ├── my_app/ # Business logic (contexts, schemas) +│ │ ├── application.ex # Supervision tree +│ │ ├── repo.ex # Ecto Repo +│ │ └── catalog/ # Context: Catalog +│ │ ├── product.ex # Schema +│ │ └── catalog.ex # Context module +│ └── my_app_web/ # Web layer +│ ├── endpoint.ex # HTTP entry point +│ ├── router.ex # Routes + pipelines +│ ├── components/ +│ │ ├── core_components.ex # Shared UI components +│ │ └── layouts.ex # Layout components +│ ├── controllers/ +│ │ ├── page_controller.ex +│ │ └── page_html/ # Templates for controller +│ │ └── home.html.heex +│ └── live/ # LiveView modules +│ └── counter_live.ex +├── config/ +│ ├── config.exs # Compile-time config +│ ├── dev.exs / prod.exs +│ └── runtime.exs # Runtime config (env vars, secrets) +├── priv/ +│ ├── repo/migrations/ +│ └── static/ # Static assets +├── test/ +│ ├── support/ +│ │ ├── conn_case.ex # Test helpers for controllers +│ │ └── data_case.ex # Test helpers for data layer +│ └── my_app_web/ +└── mix.exs +``` + +### Router & Pipelines + +```elixir +defmodule MyAppWeb.Router do + use MyAppWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {MyAppWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", MyAppWeb do + pipe_through :browser + + get "/", PageController, :home + live "/counter", CounterLive # LiveView route + resources "/products", ProductController # RESTful CRUD + end + + scope "/api", MyAppWeb do + pipe_through :api + + resources "/items", ItemController, except: [:new, :edit] + end +end +``` + +### Verified Routes — `~p` Sigil + +**Always use `~p` instead of string paths.** Compile-time verified against your router. + +```elixir +# In templates (HEEx) +~H""" +View +Edit +""" + +# In controllers +redirect(conn, to: ~p"/products/#{product}") + +# With query params +~p"/products?page=#{page}&sort=name" +~p"/products?#{%{page: 1, sort: "name"}}" + +# URL generation (includes host) +url(~p"/products/#{product}") +# => "https://example.com/products/42" +``` + +### Controllers (1.8 Style) + +```elixir +defmodule MyAppWeb.ProductController do + use MyAppWeb, :controller + + # REQUIRED in 1.8: specify formats + # This is typically set in MyAppWeb :controller function + + def index(conn, _params) do + products = Catalog.list_products() + render(conn, :index, products: products) + end + + def show(conn, %{"id" => id}) do + product = Catalog.get_product!(id) + render(conn, :show, product: product) + end + + def create(conn, %{"product" => product_params}) do + case Catalog.create_product(product_params) do + {:ok, product} -> + conn + |> put_flash(:info, "Product created.") + |> redirect(to: ~p"/products/#{product}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end +end +``` + +**View module naming:** For `ProductController`, Phoenix looks for `ProductHTML` (for `:html` format) and `ProductJSON` (for `:json` format). + +```elixir +# lib/my_app_web/controllers/product_html.ex +defmodule MyAppWeb.ProductHTML do + use MyAppWeb, :html + embed_templates "product_html/*" # Loads .heex files from directory +end + +# lib/my_app_web/controllers/product_json.ex +defmodule MyAppWeb.ProductJSON do + def index(%{products: products}), do: %{data: for(p <- products, do: data(p))} + def show(%{product: product}), do: %{data: data(product)} + defp data(product), do: %{id: product.id, title: product.title, price: product.price} +end +``` + +### Components and HEEx + +```elixir +defmodule MyAppWeb.CoreComponents do + use Phoenix.Component + + # Declare attributes with types and docs + attr :type, :string, default: "button" + attr :class, :string, default: nil + attr :rest, :global # Passes through all other HTML attrs + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + # Table component with slots + attr :rows, :list, required: true + slot :col, required: true do + attr :label, :string + end + + def table(assigns) do + ~H""" + + + + + + + + + + + +
{col[:label]}
{render_slot(col, row)}
+ """ + end +end +``` + +**HEEx syntax notes:** +- `{@var}` — render assign (curly braces, not `<%= %>`) +- `:if={condition}` — conditional rendering on any tag +- `:for={item <- list}` — iteration on any tag +- `<.component_name />` — call function component with dot notation +- `` — call remote component with module name +- `{render_slot(@inner_block)}` — render slot content +- `<:slot_name>content` — named slot content + +### LiveView + +```elixir +defmodule MyAppWeb.SearchLive do + use MyAppWeb, :live_view + + def mount(_params, _session, socket) do + {:ok, assign(socket, query: "", results: [])} + end + + def handle_params(%{"q" => query}, _uri, socket) do + {:noreply, assign(socket, query: query, results: search(query))} + end + + def handle_params(_params, _uri, socket), do: {:noreply, socket} + + def handle_event("search", %{"query" => query}, socket) do + {:noreply, + socket + |> assign(query: query, results: search(query)) + |> push_patch(to: ~p"/search?q=#{query}")} + end + + def render(assigns) do + ~H""" + +
+ + +
+ +
+

{result.title}

+

{result.summary}

+
+
+ """ + end + + defp search(query), do: MyApp.Search.find(query) +end +``` + +**LiveView lifecycle:** `mount/3` → `handle_params/3` → `render/1`. Events via `handle_event/3`. Server pushes via `handle_info/2`. + +**Key patterns:** +- `assign/2,3` — set socket assigns +- `push_navigate/2` — navigate to new LiveView (full mount) +- `push_patch/2` — update URL without full remount (calls `handle_params`) +- `push_event/3` — push event to client JS hooks +- `stream/3,4` — efficient list rendering for large collections (inserts/deletes without re-rendering entire list) +- `async_assign/3` + `assign_async/3` — async data loading with loading/error states + +### Channels — Real-Time WebSocket + +```elixir +# In endpoint.ex +socket "/socket", MyAppWeb.UserSocket, + websocket: true, + longpoll: false + +# lib/my_app_web/channels/user_socket.ex +defmodule MyAppWeb.UserSocket do + use Phoenix.Socket + + channel "room:*", MyAppWeb.RoomChannel + + def connect(%{"token" => token}, socket, _connect_info) do + case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do + {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)} + {:error, _} -> :error + end + end + + def id(socket), do: "users_socket:#{socket.assigns.user_id}" +end + +# lib/my_app_web/channels/room_channel.ex +defmodule MyAppWeb.RoomChannel do + use MyAppWeb, :channel + + def join("room:" <> room_id, _params, socket) do + {:ok, assign(socket, :room_id, room_id)} + end + + def handle_in("new_msg", %{"body" => body}, socket) do + broadcast!(socket, "new_msg", %{ + body: body, + user_id: socket.assigns.user_id + }) + {:noreply, socket} + end +end +``` + +**Force disconnect all sessions for a user:** +```elixir +MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) +``` + +### Contexts — Business Logic Boundary + +Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain. + +```elixir +# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal +defmodule MyApp.Catalog do + import Ecto.Query + alias MyApp.Repo + alias MyApp.Catalog.Product + + def list_products do + Repo.all(Product) + end + + def get_product!(id), do: Repo.get!(Product, id) + + def create_product(attrs \\ %{}) do + %Product{} + |> Product.changeset(attrs) + |> Repo.insert() + end + + def update_product(%Product{} = product, attrs) do + product + |> Product.changeset(attrs) + |> Repo.update() + end + + def delete_product(%Product{} = product) do + Repo.delete(product) + end + + def change_product(%Product{} = product, attrs \\ %{}) do + Product.changeset(product, attrs) + end +end +``` + +**Context design principles:** +- One context per bounded domain (Catalog, Accounts, Orders) +- Contexts own their schemas — other contexts reference by ID, not struct +- Cross-context calls go through the public context API, never access another context's Repo directly +- Contexts can nest related schemas (Comments under Posts) + +### Authentication — `mix phx.gen.auth` + +```bash +mix phx.gen.auth Accounts User users +``` + +Phoenix 1.8 generates: +- **Magic links** (passwordless) — email-based login links +- **"Sudo mode"** — re-authentication for sensitive actions +- Session-based auth with secure token handling +- Email confirmation and password reset flows +- `require_authenticated_user` plug for protected routes + +### Scopes (New in 1.8) + +Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user. + +```bash +mix phx.gen.live Posts Post posts title body:text --scope current_user +``` + +This generates code that automatically passes `current_user` to context functions, ensuring users only see their own data. + +### Ecto — Database Layer + +```elixir +# Schema +defmodule MyApp.Catalog.Product do + use Ecto.Schema + import Ecto.Changeset + + schema "products" do + field :title, :string + field :price, :decimal + field :status, Ecto.Enum, values: [:draft, :published, :archived] + has_many :reviews, MyApp.Reviews.Review + belongs_to :category, MyApp.Catalog.Category + timestamps(type: :utc_datetime) + end + + def changeset(product, attrs) do + product + |> cast(attrs, [:title, :price, :status, :category_id]) + |> validate_required([:title, :price]) + |> validate_number(:price, greater_than: 0) + |> unique_constraint(:title) + |> foreign_key_constraint(:category_id) + end +end + +# Queries +import Ecto.Query + +# Composable queries +def published(query \\ Product) do + from p in query, where: p.status == :published +end + +def recent(query, days \\ 7) do + from p in query, where: p.inserted_at > ago(^days, "day") +end + +def with_reviews(query) do + from p in query, preload: [:reviews] +end + +# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all() +``` + +### Telemetry — Built-in Observability + +Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box. + +```elixir +# lib/my_app_web/telemetry.ex (auto-generated) +defmodule MyAppWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def metrics do + [ + summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}), + summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}), + summary("my_app.repo.query.total_time", unit: {:native, :millisecond}), + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + ] + end +end +``` + +Integrates with **PromEx** for Prometheus/Grafana dashboards (see Monitoring section). + +### Phoenix Security Best Practices + +**Never pass untrusted input to:** `Code.eval_string/3`, `:os.cmd/2`, `System.cmd/3`, `System.shell/2`, `:erlang.binary_to_term/2` + +**Ecto prevents SQL injection by default** — the query DSL parameterizes all inputs. Only `Ecto.Adapters.SQL.query/4` with raw string interpolation is vulnerable. + +**Safe deserialization:** +```elixir +# UNSAFE — even with :safe flag +:erlang.binary_to_term(user_input, [:safe]) + +# SAFE — prevents executable terms +Plug.Crypto.non_executable_binary_to_term(user_input, [:safe]) +``` + +**CSRF protection** is built into the `:browser` pipeline via `protect_from_forgery`. **Content Security Policy** is set by `put_secure_browser_headers`. + +### Testing Phoenix + +```elixir +# Controller test +defmodule MyAppWeb.ProductControllerTest do + use MyAppWeb.ConnCase + + test "GET /products", %{conn: conn} do + conn = get(conn, ~p"/products") + assert html_response(conn, 200) =~ "Products" + end + + test "POST /products creates product", %{conn: conn} do + conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99}) + assert redirected_to(conn) =~ ~p"/products/" + end +end + +# LiveView test +defmodule MyAppWeb.CounterLiveTest do + use MyAppWeb.ConnCase + import Phoenix.LiveViewTest + + test "increments counter", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/counter") + assert html =~ "Count: 0" + + assert view + |> element("button", "+1") + |> render_click() =~ "Count: 1" + end +end + +# Channel test +defmodule MyAppWeb.RoomChannelTest do + use MyAppWeb.ChannelCase + + test "broadcasts new messages" do + {:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby") + + push(socket, "new_msg", %{"body" => "hello"}) + assert_broadcast "new_msg", %{body: "hello"} + end +end +``` + +### Phoenix Generators Cheat Sheet + +```bash +# HTML CRUD (controller + views + templates + context + schema + migration) +mix phx.gen.html Catalog Product products title:string price:decimal + +# LiveView CRUD +mix phx.gen.live Catalog Product products title:string price:decimal + +# JSON API +mix phx.gen.json Catalog Product products title:string price:decimal + +# Context + schema only (no web layer) +mix phx.gen.context Catalog Product products title:string price:decimal + +# Schema + migration only +mix phx.gen.schema Product products title:string price:decimal + +# Authentication +mix phx.gen.auth Accounts User users + +# Channel +mix phx.gen.channel Room + +# Presence +mix phx.gen.presence + +# Release files (Dockerfile, release.ex, overlay scripts) +mix phx.gen.release +mix phx.gen.release --docker # Include Dockerfile +``` + +### Phoenix Release & Deployment + +```bash +# Generate release infrastructure +mix phx.gen.release --docker + +# Build release +MIX_ENV=prod mix deps.get --only prod +MIX_ENV=prod mix compile +MIX_ENV=prod mix assets.deploy +MIX_ENV=prod mix release + +# Release commands +_build/prod/rel/my_app/bin/server # Start with Phoenix server +_build/prod/rel/my_app/bin/migrate # Run migrations +_build/prod/rel/my_app/bin/my_app remote # Attach IEx console +``` + +**Runtime config (`config/runtime.exs`):** +```elixir +import Config + +if config_env() == :prod do + database_url = System.fetch_env!("DATABASE_URL") + secret_key_base = System.fetch_env!("SECRET_KEY_BASE") + + config :my_app, MyApp.Repo, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + + config :my_app, MyAppWeb.Endpoint, + url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"], + http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")], + secret_key_base: secret_key_base +end +``` + +--- + ## Common Libraries | Library | Purpose | Hex | |---------|---------|-----| -| Phoenix | Web framework + LiveView | `{:phoenix, "~> 1.7"}` | +| Phoenix | Web framework + LiveView | `{:phoenix, "~> 1.8"}` | | Ecto | Database wrapper + query DSL | `{:ecto_sql, "~> 3.12"}` | | Ash | Declarative resource framework | `{:ash, "~> 3.0"}` | | Oban | Background job processing | `{:oban, "~> 2.18"}` |