Advanced Multi-tenancy for Elixir Applications Using Ecto
Aestimo K.
Posted on December 19, 2023
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
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
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
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
Then run the migration to create the links table:
mix ecto.migrate
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
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
Now, if you try to access any of the protected routes, Pow should redirect you to a login page:
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
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
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
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
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
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
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
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
Here, a user should only see links that are associated with them.
For example, this is a list index view for one user:
And this is a view for a different user (notice the logged-in user's email and the different link URLs):
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
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
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
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
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!
Posted on December 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.