Advanced Multi-tenancy for Elixir Applications Using Ecto

iamaestimo

Aestimo K.

Posted on December 19, 2023

Advanced Multi-tenancy for Elixir Applications Using Ecto

Welcome to part two of this series. In the previous tutorial, we learned about multi-tenancy, including different multi-tenancy implementation strategies. We also started building a multi-tenant Phoenix link shortening app and added basic user authentication.

In this final part of the series, we'll build the link resource, associate users to links, and set up the redirect functionality in our app. As we finalize the app build, we'll learn about features (like Ecto custom types) that make Phoenix such a powerful framework for Elixir.

Let's now turn our attention to link shortening.

Adding Link Resources

As a reminder, here's how link shortening will happen in our app:

  • Logged-in user enters a long URL
  • A random string is generated and associated with the long URL
  • Whenever this short URL is visited, the app will keep a tally of the number of visits

We'll get started by generating a controller, schema, migration, and views for a Link resource:

mix phx.gen.html Links Link links url:string visits:integer account_id:references:accounts
Enter fullscreen mode Exit fullscreen mode

This should generate most of the boilerplate code we need to work with the Link resource.

You may notice that we haven't included a field for the short random string referencing the long URL. We'll cover this next.

Creating Short URLs Using the Ecto Custom Type in Phoenix

By default, Ecto models have an id field used as a model's primary key. Usually, it's in the form of an integer or, in some cases, a binary ID. In either case, when present in a model, this field is automatically incremented for every additional model created in an app.

A core function of our app is generating short, unique strings to represent long URLs. We could write a custom function to generate such strings, but since we are on a quest to learn Elixir, let's use a more creative approach.

Let's substitute the id primary key in the Link model with a custom Ecto type called HashId.

Using this approach, we'll:

  • Learn how to create and use Elixir custom types.
  • Automatically generate unique string hashes to form the short URL field for links. Ecto will manage the process whenever a new link model is created.

First, create a new file to represent this new type:

# lib/ecto/hash_id.ex

defmodule Urlbot.Ecto.HashId do
  @behaviour Ecto.Type
  @hash_id_length 8

  # Called when creating an Ecto.Changeset
  @spec cast(any) :: Map.t
  def cast(value), do: hash_id_format(value)

  # Accepts a value that has been directly placed into the ecto struct after a changeset
  @spec dump(any) :: Map.t
  def dump(value), do: hash_id_format(value)

  # Changes a value from the default type into the HashId type
  @spec load(any) :: Map.t
  def load(value), do: hash_id_format(value)

  # A callback invoked by autogenerate fields
  @spec autogenerate() :: String.t
  def autogenerate, do: generate()

 # The Ecto type that is being converted
  def type, do: :string

  @spec hash_id_format(any) :: Map.t
  def hash_id_format(value) do
    case validate_hash_id(value) do
      true -> {:ok, value}
      _ -> {:error, "'#{value}' is not a string"}
    end
  end

  # Validation of given value to be of type "String"
  def validate_hash_id(string) when is_binary(string), do: true
  def validate_hash_id(_other), do: false

  # The function that generates a HashId
  @spec generate() :: String.t
  def generate do
    @hash_id_length
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64
    |> binary_part(0, @hash_id_length)
  end
end
Enter fullscreen mode Exit fullscreen mode

Check out the Ecto custom types documentation, which covers the subject in a much more exhaustive way than we could in this tutorial.

For now, what we've just done allows us to use the custom data type HashId when defining a field's data type.

Let's use this new data type to define the short URL field for the Link resource next.

Using the Custom Ecto Type in Phoenix

Open up the Link schema and edit it to reference the new Ecto type we've just defined:

# lib/urlbot/links/link.ex

defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId # Add this line

  @primary_key {:hash, HashId, [autogenerate: true]} # Add this line
  @derive {Phoenix.Param, key: :hash} # Add this line

  ...
end
Enter fullscreen mode Exit fullscreen mode

Before moving on, let's briefly explain the use of @derive in the code above.

In Elixir, a protocol defines an API and its specific implementations. Phoenix.Param is a protocol used to convert data structures into URL parameters. By default, this protocol is used for integers, binaries, atoms, and structs. The default key :id is usually used for structs, but it's possible to define other parameters. In our case, we use the parameter hash and make its implementation derivable to actually use it.

Let's modify the links migration to use this new parameter type:

# priv/repo/migrations/XXXXXX_create_links.exs

defmodule Urlbot.Repo.Migrations.CreateLinks do
  use Ecto.Migration

  def change do
    create table(:links, primary_key: false) do
      add :hash, :string, primary_key: true
      add :url, :string
      add :visits, :integer
      add :account_id, references(:accounts, on_delete: :delete_all)

      timestamps()
    end

    create index(:links, [:account_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Then run the migration to create the links table:

mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

We now need to associate Link and Account— but before we do, let's ensure that only an authenticated user can make CRUD operations for Link.

Updating Link Routes in Phoenix for Elixir

In the router, we need to create a new pipeline to process routes that only authenticated users can access.

Go ahead and add this pipeline:

# lib/urlbot_web/router.ex

defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router

  ...

  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
  end

  ...
end
Enter fullscreen mode Exit fullscreen mode

Then add the scope to handle Link routes:

# lib/urlbot_web/router.ex

defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router

  ...

  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
  end

  scope "/", UrlbotWeb do
    pipe_through [:protected, :browser]

    resources "/links", LinkController
  end

  ...
end
Enter fullscreen mode Exit fullscreen mode

Now, if you try to access any of the protected routes, Pow should redirect you to a login page:

Trying to access a protected route

Our app build is progressing well. Next, let's associate Link and Account since this forms the basis of tenant separation in our app.

Assigning Link to Account in Elixir

Take a look at the Link schema below. We want to make sure account_id is included, since this will form the link to Account:

# lib/urlbot/links/link.ex

defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId

  ...

  schema "links" do
    field :url, :string
    field :visits, :integer
    field :account_id, :id

    timestamps()
  end
  ...

end
Enter fullscreen mode Exit fullscreen mode

Next, edit the changeset block, adding account_id to the list of allowed attributes and those that will go through validation:

# lib/urlbot/links/link.ex

defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId

  ...

  def changeset(link, attrs) do
    link
    |> cast(attrs, [:url, :visits, :account_id])
    |> validate_required([:url, :visits, :account_id])
  end

end
Enter fullscreen mode Exit fullscreen mode

Then, edit the Account schema and add a has_many relation as follows:

# lib/urlbot/accounts/account.ex

defmodule Urlbot.Accounts.Account do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Users.User
  alias Urlbot.Links.Link

  schema "accounts" do
    field :name, :string

    has_many :users, User
    has_many :links, Link # Add this line

    timestamps()
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

We also need to edit the Link context to work with Account. Remember, scoping a resource to a user or account is the foundation of any multi-tenant structure.

Edit your file as shown below:

# lib/urlbot/links.ex

defmodule Urlbot.Links do
  ...

  def list_links(account) do
    from(s in Link, where: s.account_id == ^account.id, order_by: [asc: :id])
    |> Repo.all()
  end

  def get_link!(account, id) do
    Repo.get_by!(Link, account_id: account.id, id: id)
  end

  def create_link(account, attrs \\ %{}) do
    Ecto.build_assoc(account, :links)
    |> Link.changeset(attrs)
    |> Repo.insert()
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

Adding a Current_Account

Our edits to the Link context show that most related controller methods use the account variable. This variable needs to be extracted from the currently logged-in user's account_id and passed on to the respective controller action.

So we need to make a custom plug to add to the conn. Create a new file — lib/plugs/set_current_account.ex — and edit its content as shown below:

# lib/plugs/set_current_account.ex

defmodule UrlbotWeb.Plugs.SetCurrentAccount do
  import Plug.Conn
  alias Urlbot.Repo
  alias Urlbot.Users.User

  def init(options), do: options

  def call(conn, _opts) do
    case conn.assigns[:current_user] do
      %User{} = user ->
        %User{account: account} = Repo.preload(user, :account)
        assign(conn, :current_account, account)
      _ ->
        assign(conn, :current_account, nil)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Next, let's use this new plug by adding it to the router (specifically to the protected pipeline since user sessions are handled there):

# lib/urlbot_web/router.ex

defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router

  ...

  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
    plug UrlbotWeb.Plugs.SetCurrentAccount # Add this line
  end

  ...

  scope "/", UrlbotWeb do
    pipe_through [:protected, :browser]

    resources "/links", LinkController
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

Then, edit the Link controller to pass the current_account in every action where it's required, like so:

# lib/urlbot_web/controllers/link_controller.ex

defmodule UrlbotWeb.LinkController do
  use UrlbotWeb, :controller

  alias Urlbot.Links
  alias Urlbot.Links.Link

  def index(conn, _params) do
    current_account = conn.assigns.current_account  # Add this line
    links = Links.list_links(current_account)
    render(conn, :index, links: links)
  end
  def create(conn, %{"link" => link_params}) do
    current_account = conn.assigns.current_account # Add this line
    case Links.create_link(current_account, link_params) do
      {:ok, link} ->
        conn
        |> put_flash(:info, "Link created successfully.")
        |> redirect(to: ~p"/links")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def edit(conn, %{"id" => id}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)
    changeset = Links.change_link(link)
    render(conn, :edit, link: link, changeset: changeset)
  end

  def update(conn, %{"id" => id, "link" => link_params}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)

    case Links.update_link(link, link_params) do
      {:ok, link} ->
        conn
        |> put_flash(:info, "Link updated successfully.")
        |> redirect(to: ~p"/links/#{link}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, link: link, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)
    {:ok, _link} = Links.delete_link(link)

    conn
    |> put_flash(:info, "Link deleted successfully.")
    |> redirect(to: ~p"/links")
  end

end
Enter fullscreen mode Exit fullscreen mode

That's it! Now, any created Link will be associated with a currently logged-in user's account.

At this point, we have everything we need for users to create links that are properly scoped to their respective accounts. You can see this in action when you consider the list index action:


defmodule UrlbotWeb.LinkController do
  use UrlbotWeb, :controller

  alias Urlbot.Links
  alias Urlbot.Links.Link

  def index(conn, _params) do
    current_account = conn.assigns.current_account
    links = Links.list_links(current_account)
    render(conn, :index, links: links)
  end

  ...
end
Enter fullscreen mode Exit fullscreen mode

Here, a user should only see links that are associated with them.

For example, this is a list index view for one user:

First user's link index view

And this is a view for a different user (notice the logged-in user's email and the different link URLs):

Second user's link index view

Next, let's build the link redirection feature.

Redirecting Links and Incrementing Link Views in Phoenix

Let's begin by adding a new controller to handle the redirect. This controller will have one show action:

# lib/urlbot_web/controllers/redirect_controller.ex

defmodule UrlbotWeb.RedirectController do
  use UrlbotWeb, :controller

  alias Urlbot.Links

  def show(conn, %{"id" => id}) do
    short_url = Links.get_short_url_link!(id)
    Links.increment_visits(short_url)
    redirect(conn, external: short_url.url)
  end

end
Enter fullscreen mode Exit fullscreen mode

We also need to modify the Link context with a custom get function that does not reference the current user:

# lib/urlbot/links.ex

defmodule Urlbot.Links do
  ...

  def get_short_url_link!(id) do
    Repo.get!(Link, id: id)
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

Next, modify the router accordingly:

# lib/urlbot_web/router.ex

defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router

  ...

  scope "/", UrlbotWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/links/:id", RedirectController, :show # Add this line
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

Finally, to handle the link view visits, we'll add a custom function to the Link context:

# lib/urlbot/links.ex

defmodule Urlbot.Links do
  ...

  def increment_visits(%Link{} = link) do
    from(s in Link, where: s.id == ^link.hash, update: [inc: [visits: 1]])
    |> Repo.update_all([])
  end

end
Enter fullscreen mode Exit fullscreen mode

That's it! Now, when we visit a link like http://localhost:4000/links/rMbzTz3o, this should redirect to the original long URL and increment the link's view counter.

Wrapping Up

This two-part series has taken you on a journey to build a multi-tenant Elixir app using the Phoenix web framework. You've learned how to implement a simple tenant separation system using foreign keys and related models within a shared database and shared schema.

Although we've tried to be as exhaustive as possible, we couldn't possibly capture the whole app-building process, including testing, deployment options, or even the use of the amazing LiveView.

All the same, we hope this tutorial provides a foundation for you to build your very own Elixir app that helps solve serious problems for your users.

Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

💖 💪 🙅 🚩
iamaestimo
Aestimo K.

Posted on December 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related