Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

GenServer examples

ch_3.3_genserver_examples.livemd

GenServer examples

Navigation

Home Building a GenServerOther GenServer functions

Password Manager

Our objective is to create a straightforward password manager GenServer that can save user’s passwords in its state.

Notice how we have developed an API with functions like save_password/3, get_password/1, and delete_password/1. This API facilitates easy communication with the GenServer without needing to directly call GenServer functions like GenServer.call/3 or GenServer.cast/2.

Also notice how we have ensured that the GenServer code is kept to a minimum and have placed our password validation logic in a separate module. This approach of separating the logic into a purely functional module makes testing easier since the buissness logic can be tested in isolation without dealing with the GenServer.

defmodule Password do
  defstruct url: nil, username: nil, password: nil, inserted_at: nil

  @doc """
  Check if a password entry is valid
  """
  def validate_entry(%Password{url: url, username: username, password: password}) do
    with {:ok, _url} <- URI.new(url),
         {:ok, _username} <- validate_username(username),
         {:ok, _password} <- validate_password(password) do
      {:ok,
       %Password{
         url: url,
         username: username,
         password: password,
         inserted_at: DateTime.utc_now()
       }}
    end
  end

  # Helper functions
  defp validate_username(username) do
    cond do
      not is_binary(username) -> {:error, "Invalid username"}
      String.length(username) == 0 -> {:error, "Username is empty"}
      true -> {:ok, username}
    end
  end

  defp validate_password(password) do
    cond do
      not is_binary(password) -> {:error, "Invalid password"}
      String.length(password) < 3 -> {:error, "Password must be atleast 3 character long"}
      true -> {:ok, password}
    end
  end
end

defmodule PasswordManager do
  use GenServer

  # Public APIs

  def start_link(_opts) do
    GenServer.start_link(
      __MODULE__,
      %{},
      # Use the module name as the name of the GenServer Process
      name: __MODULE__
    )
  end

  def save_password(url, username, password) do
    entry = %Password{
      url: url,
      username: username,
      password: password
    }

    GenServer.call(__MODULE__, {:save_password, entry})
  end

  def get_password(url) do
    GenServer.call(__MODULE__, {:get_password, url})
  end

  def delete_password(url) do
    GenServer.cast(__MODULE__, {:delete_password, url})
  end

  def stop(), do: GenServer.stop(__MODULE__)

  # Callbacks

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call({:save_password, new_password}, _from, state) do
    case Password.validate_entry(new_password) do
      {:ok, entry} -> {:reply, :saved, Map.put(state, entry.url, entry)}
      {:error, reason} -> {:reply, {:error, reason}, state}
    end
  end

  @impl true
  def handle_call({:get_password, url}, _from, state) do
    case Map.get(state, url) do
      nil -> {:reply, :not_found, state}
      entry -> {:reply, entry, state}
    end
  end

  @impl true
  def handle_cast({:delete_password, url}, state) do
    state = Map.delete(state, url)
    {:noreply, state}
  end
end
# Start the Password Manager Genserver
{:ok, _pid} = PasswordManager.start_link(nil)

PasswordManager.save_password("gmail.com", "john_doe@gmail.com", "12345")
|> IO.inspect(label: "Saving Gmail creds")

PasswordManager.save_password("spotify.com", "music4life", "ab")
|> IO.inspect(label: "Saving Spotify creds")

PasswordManager.save_password("apple.com", "iuser", "ilife")
|> IO.inspect(label: "Saving Apple creds")

PasswordManager.get_password("gmail.com") |> IO.inspect(label: "Gmail creds")
PasswordManager.get_password("spotify.com") |> IO.inspect(label: "Spotify creds")

PasswordManager.delete_password("gmail.com") |> IO.inspect(label: "Deleting Gmail")
PasswordManager.get_password("gmail.com") |> IO.inspect(label: "Gmail creds")

PasswordManager.stop()

Testing GenServer

Now lets try to write some tests for the above GenServer.

ExUnit.start()

defmodule PasswordManagerTest do
  use ExUnit.Case

  describe "save_password/3" do
    test "saves password if password entry is valid" do
      {:ok, _pid} = PasswordManager.start_link(nil)
      assert :saved == PasswordManager.save_password("gmail.com", "john_doe@gmail.com", "12345")

      assert %Password{
               url: "gmail.com",
               username: "john_doe@gmail.com",
               password: "12345",
               inserted_at: _
             } = PasswordManager.get_password("gmail.com")
    end

    test "does not save password if password entry is invalid" do
      {:ok, _pid} = PasswordManager.start_link(nil)

      assert {:error, "Password must be atleast 3 character long"} ==
               PasswordManager.save_password("gmail.com", "john_doe@gmail.com", "12")

      assert :not_found == PasswordManager.get_password("gmail.com")
    end
  end

  describe "delete_password/3" do
    test "deletes password if password found" do
      {:ok, _pid} = PasswordManager.start_link(nil)
      assert :saved == PasswordManager.save_password("gmail.com", "john_doe@gmail.com", "12345")
      assert :ok == PasswordManager.delete_password("gmail.com")

      assert :not_found = PasswordManager.get_password("gmail.com")
    end
  end
end

ExUnit.run()

Here the password validation logic can be tested independently, without having to start the GenServer in the tests. This method of testing is preferable since testing pure functions is generally much easier than testing async GenServer code.

Cron Job

Lets see another example of building a GenServer. In Elixir, you can easily create a basic CRON job using GenServers to execute a task periodically.

defmodule CronJob do
  use GenServer

  # Every 10 seconds
  @interval :timer.seconds(10)

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{})
  end

  def init(state) do
    schedule_work()
    {:ok, state}
  end

  def handle_info(:work, state) do
    work()
    schedule_work()
    {:noreply, state}
  end

  defp schedule_work() do
    Process.send_after(self(), :work, @interval)
  end

  defp work() do
    IO.inspect("Working...")
  end
end

CronJob.start_link(nil)

This works fine for simple use cases however, if you require more advanced functionality consider using a library such as Quantum.

Resources

Navigation

Home Building a GenServerOther GenServer functions