View Source Electric.Phoenix.Plug (Electric Phoenix v0.2.0)

Provides an configuration endpoint for use in your Phoenix applications.

Rather than configuring your Electric Typescript client directly, you instead configure a route in your application with a pre-configured Electric.Client.ShapeDefinition and then retreive the URL and other configuration for that shape from your client via a request to your Phoenix application.

In your Phoenix application, add a route to Electric.Phoenix.Plug specifying a particular shape:

defmodule MyAppWeb.Router do
  scope "/shapes" do
    pipe_through :browser

    get "/todos", Electric.Phoenix.Plug,
      shape: Electric.Client.shape!("todos", where: "visible = true")
  end
end

Then in your client code, you retrieve the shape configuration directly from the Phoenix endpoint:

import { ShapeStream } from '@electric-sql/client'

const endpoint = `https://localhost:4000/shapes/todos`
const response = await fetch(endpoint)
const config = await response.json()

// The returned `config` has all the information you need to subscribe to
// your shape
const stream = new ShapeStream(config)

stream.subscribe(messages => {
  // ...
})

You can add additional authentication/authorization for shapes using Phoenix's pipelines or other plug calls.

Plug.Router

For pure Plug-based applications, you can use Plug.Router.forward/2:

defmodule MyRouter do
  use Plug.Router

  plug :match
  plug :dispatch

  forward "/shapes/items",
    to: Electric.Phoenix.Plug,
    shape: Electric.Client.shape!("items")

  match _ do
    send_resp(conn, 404, "oops")
  end
end

Parameter-based shapes

As well as defining fixed-shapes for a particular url, you can request shape configuration using parameters in your request:

defmodule MyAppWeb.Router do
  scope "/" do
    pipe_through :browser

    get "/shape", Electric.Phoenix.Plug, []
  end
end
import { ShapeStream } from '@electric-sql/client'

const endpoint = `https://localhost:4000/shape?table=items&namespace=public&where=visible%20%3D%20true`
const response = await fetch(endpoint)
const config = await response.json()

// as before

The parameters are:

  • table - The Postgres table name (required).
  • namespace - The Postgres schema if not specified defaults to public.
  • where - The where clause to filter items from the shape.

Custom Authentication

The Electric.Client allows for generating authentication headers via it's authenticator configuration but if you want to include parameters from the request in the authentication tokens, for example including the currently logged in user id, then you can set an authenticator directly in the Electric.Phoenix.Plug configuration.

First you must define a function that accepts the %Plug.Conn{} of the request, the %Electric.Client.ShapeDefinition{} of the endpoint and some (optional) config and returns a map of additional request headers:

defmodule MyAuthModule do
  def shape_auth_headers(conn, shape, _opts \\ []) do
    user_id = conn.assigns.user_id
    signer = Joken.Signer.create("HS256", "my-deep-secret")
    claims = %{
      user_id: user_id,
      table: [shape.namespace, shape.table],
      where: shape.where
    }
    token = Joken.generate_and_sign!(Joken.Config.default_claims(), claims, signer)
    %{"authorization" => "Bearer #{token}"}
  end
end

Now configure the Shape configuration endpoint to use your authentication function:

forward "/shapes/tasks/:project_id",
  to: Electric.Plug,
  authenticator: {MyAuthModule, :shape_auth_headers, _opts = []},
  shape: [
    from(t in Task, where: t.active == true),
    project_id: :project_id
  ]

Or you can use a capture:

forward "/shapes/tasks/:project_id",
  to: Electric.Plug,
  authenticator: &MyAuthModule.shape_auth_headers/2,
  shape: [
    from(t in Task, where: t.active == true),
    project_id: :project_id
  ]

Summary

Functions

Callback implementation for Plug.call/2.

Send the client configuration for a given shape to the browser.

Defines a shape based on a root Ecto query plus some filters based on the current request.

Types

@type conn_param_spec() :: param_name() | [{op(), param_name()}]
@type dynamic_shape_param() :: {table_column(), conn_param_spec()} | table_column()
Link to this type

dynamic_shape_params()

View Source
@type dynamic_shape_params() :: [dynamic_shape_param()]
@type op() :: :== | :!= | :> | :< | :>= | :<=
@type param_name() :: atom()
@type table_column() :: atom()

Functions

Callback implementation for Plug.call/2.

Link to this function

send_configuration(conn, shape_or_queryable, client \\ Electric.Phoenix.client!())

View Source
@spec send_configuration(
  Plug.Conn.t(),
  Electric.Phoenix.shape_definition(),
  Client.t()
) ::
  Plug.Conn.t()

Send the client configuration for a given shape to the browser.

Example

get "/my-shapes/messages" do
  user_id = get_session(conn, :user_id)
  shape = from(m in Message, where: m.user_id == ^user_id)
  Electric.Phoenix.Plug.send_configuration(conn, shape)
end

get "/my-shapes/tasks/:project_id" do
  project_id = conn.params["project_id"]

  if user_has_access_to_project?(project_id) do
    shape = where(Task, project_id: ^project_id)
    Electric.Phoenix.Plug.send_configuration(conn, shape)
  else
    send_resp(conn, :forbidden, "You do not have permission to view project #{project_id}")
  end
end

Defines a shape based on a root Ecto query plus some filters based on the current request.

forward "/shapes/tasks/:project_id",
  to: Electric.Plug,
  shape: Electric.Phoenix.Plug.shape!(
    from(t in Task, where: t.active == true),
    project_id: :project_id
  )

The params describe the way to build the where clause on the shape from the request parameters.

For example, [id: :user_id] means that the id column on the table should match the value of the user_id parameter, equivalent to:

from(
  t in Table,
  where: t.id == ^conn.params["user_id"]
)

If both the table column and the request parameter have the same name, then you can just pass the name, so:

Electric.Phoenix.Plug.shape!(Table, [:visible])

is equivalent to:

from(
  t in Table,
  where: t.visible == ^conn.params["visible"]
)

If you need to match on something other than == then you can pass the operator in the params:

Electric.Phoenix.Plug.shape!(Table, size: [>=: :size])

is equivalent to:

from(
  t in Table,
  where: t.size >= ^conn.params["size"]
)

Instead of calling shape!/2 directly in your route definition, you can just pass a list of [query | params] to do the same thing:

forward "/shapes/tasks/:project_id",
  to: Electric.Plug,
  shape: [
    from(t in Task, where: t.active == true),
    project_id: :project_id
  ]