Deploying Elixir: Creating Your Own Elixir Package

Deploying Elixir: Creating Your Own Elixir Package

·

13 min read

During your journey as an Elixir developer, there is going to come to a point where you might want to publish your own package. This has happened to me a couple of times already, here are a couple of packages I have published.

Equip with the knowledge of what I needed to do to publish my own package, I thought I would take you on the same journey. Let's get started.

Our first stop is going to be the Hex website. Hex has a great guide for publishing your own package that we will be following.

https://hex.pm/docs/publish

Registering a Hex User

Before we can do anything, we will need to register our own user. When registering a user, you will be prompted for a username, your email, and a password. The email is used to confirm your identity during signup, as well as to contact you in case there is an issue with one of your packages. The email will never be shared with a third party.

$ mix hex.user register

Now it’s time to decide on a name for your package. In this guide I will be creating a new Ueberauth package. If you were to go on http://hex.pm and look at other Ueberauth packages, you notice there is a certain pattern followed. This will make the decision easy for us on what to call our Uberauth package that will implement the Patreon OAuth flow.

uberauth_patreon

Generating Our Project

Now that we know the name of our package let’s generate the project

If you’re curious how to generate a standard mix project, please visit the Elixir site here: https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html#our-first-project

mix new ueberauth_patreon

We should expect to see:

* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/ueberauth_patreon.ex
* creating test
* creating test/test_helper.exs
* creating test/ueberauth_patreon_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

    cd ueberauth_patreon
    mix test

Run "mix help" for more commands.

Let’s change the directory to our new project directory and let’s open the code in your editor of choice. For me I’ll be using Visual Studio Code, this is important later in the guide when I am making a test app.

Next, we will update the mix.exs file to have all the settings we know to start things off. I follow this section of the Hex publishing guide https://hex.pm/docs/publish#adding-metadata-to-code-classinlinemixexscode

def project do
    [
      app: :ueberauth_patreon,
      description: "Ueberauth strategy for Patreon OAuth.",
      version: "1.0.0",
      elixir: "~> 1.13",
      source_url: "https://github.com/talk2MeGooseman/ueberauth_patreon",
      homepage_url: "https://github.com/talk2MeGooseman/ueberauth_patreon",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      package: [
        links: %{"GitHub" => "https://github.com/talk2MeGooseman/ueberauth_patreon"},
        licenses: ["MIT"],
      ]
    ]
  end

I decided to set my package version at 1.0.0 but feel free to start it at 0.0.1 since its your first version ever.

Adding Documentation Generation

If you follow along in the Hex guide, they will suggest using ex_doc and there is no reason not to. It generates some beautiful documentation.

Following the HexDoc’s guide https://hexdocs.pm/ex_doc/readme.html. Let’s add it to our project dependencies in our mix.exs file.

def deps do
  [
        ## Other deps ...
    {:ex_doc, "~> 0.27", only: :dev, runtime: false},
  ]
end

Dont forget to install our new dependency.

mix deps.get

Now let's generate our docs to make sure everything is set up correctly

> mix docs
Generating docs...
View "html" docs at "doc/index.html"
View "epub" docs at "doc/ueberauth_patreon.epub"

If you have the latest NPM installed you can run the following command to see your docs in the browser:

npx serve doc/

This will kick off a little webserver without having to go install anything locally.

Let’s Get Coding

When creating a Ueberauth package, the project needs to be structured in a particular way. If you click on any of the listed strategies Ueberauth has you will get a lot of good examples of what you need to do. You can structure your project any way you like but in the case of Ueberauth it’s best we follow their expected structure.

https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies

Inside of lib, we need to create several files and folders.

mkdir lib/ueberauth
mkdir lib/ueberauth/strategy
touch lib/ueberauth/strategy/patreon.ex
mkdir lib/ueberauth/strategy/patreon
touch lib/ueberauth/strategy/patreon/oauth.ex

Since we're making an Ueberauth strategy we need to make sure to install Ueberauth and OAuth2 packages. The mix.exs file should start looking like this now.

defp deps do
    [
      {:ueberauth, "~> 0.7"},
      {:oauth2, "~> 2.0"},
      {:ex_doc, "~> 0.27", only: :dev, runtime: false}
    ]
end

Now let’s install those new dependencies.

mix deps.get

Now it's time to implement what we want our package to do. I am not going to explain how to implement an Uberauth package in this article, you can read my tutorial Creating Your Own Ueberauth Strategy on how. Instead, we'll just be copying and pasting some code in.

Inside lib/ueberauth/strategy/patreon.ex add the following:

defmodule Ueberauth.Strategy.Patreon do
  use Ueberauth.Strategy,
    oauth2_module: Ueberauth.Strategy.Patreon.OAuth

  alias Ueberauth.Auth.Info
  alias Ueberauth.Auth.Credentials
  alias Ueberauth.Auth.Extra

  @doc """
  Handles the initial redirect to the patreon authentication page.

  To customize the scope (permissions) that are requested by patreon include
  them as part of your url:

      "https://www.patreon.com/oauth2/authorize"
  """
  def handle_request!(conn) do
    scopes = conn.params["scope"] || option(conn, :default_scope)

    params =
      [scope: scopes]
      |> with_state_param(conn)

    module = option(conn, :oauth2_module)
    redirect!(conn, apply(module, :authorize_url!, [params]))
  end

  @doc """
  Handles the callback from Patreon.

  When there is a failure from Patreon the failure is included in the
  `ueberauth_failure` struct. Otherwise the information returned from Patreon is
  returned in the `Ueberauth.Auth` struct.
  """
  def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do
    module = option(conn, :oauth2_module)
    token = apply(module, :get_token!, [[code: code]])

    if token.access_token == nil do
      set_errors!(conn, [
        error(token.other_params["error"], token.other_params["error_description"])
      ])
    else
      fetch_user(conn, token)
    end
  end

  @doc false
  def handle_callback!(conn) do
    set_errors!(conn, [error("missing_code", "No code received")])
  end

  @doc """
  Cleans up the private area of the connection used for passing the raw Notion
  response around during the callback.
  """
  def handle_cleanup!(conn) do
    conn
    |> put_private(:patreon_token, nil)
    |> put_private(:patreon_user, nil)
  end

  @doc """
  Fetches the uid field from the Twitch response. This defaults to the option `uid_field` which in-turn defaults to `id`
  """
  def uid(conn) do
    %{"data" => user} = conn.private.patreon_user
    user["id"]
  end

  @doc """
  Includes the credentials from the Patreon response.
  """
  def credentials(conn) do
    token = conn.private.patreon_token

    %Credentials{
      token: token.access_token,
      token_type: token.token_type,
      refresh_token: token.refresh_token,
      expires_at: token.expires_at
    }
  end

  @doc """
  Fetches the fields to populate the info section of the `Ueberauth.Auth`
  struct.
  """
  def info(conn) do
    %{ "data" => %{
      "attributes" => %{
        "full_name" => full_name,
        "first_name" => first_name,
        "last_name" => last_name,
        "about" => about,
        "image_url" => image_url,
        "url" => url,
        "email" => email
      }
    }} = conn.private.patreon_user

    %Info{
      email: email,
      name: full_name,
      first_name: first_name,
      last_name: last_name,
      description: about,
      image: image_url,
      urls: %{
        profile: url
      }
    }
  end

  @doc """
  Stores the raw information (including the token) obtained from the Patreon
  callback.
  """
  def extra(conn) do
    %Extra{
      raw_info: conn.private.patreon_user
    }
  end

  defp fetch_user(conn, token) do
    conn = put_private(conn, :patreon_token, token)

    case Ueberauth.Strategy.Patreon.OAuth.get(
           token.access_token,
           "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=full_name,email,first_name,last_name,about,image_url,url"
         ) do
      {:ok, %OAuth2.Response{status_code: 401, body: _body}} ->
        set_errors!(conn, [error("token", "unauthorized")])

      {:ok, %OAuth2.Response{status_code: status_code, body: user}}
      when status_code in 200..399 ->
        put_private(conn, :patreon_user, user)

      {:error, %OAuth2.Error{reason: reason}} ->
        set_errors!(conn, [error("OAuth2", reason)])

      {:error, %OAuth2.Response{body: %{"message" => reason}}} ->
        set_errors!(conn, [error("OAuth2", reason)])

      {:error, _} ->
        set_errors!(conn, [error("OAuth2", "uknown error")])
    end
  end

  defp option(conn, key) do
    Keyword.get(options(conn), key, Keyword.get(default_options(), key))
  end
end

Inside lib/ueberauth/strategy/patreon/oauth.ex paste the following code:

defmodule Ueberauth.Strategy.Patreon.OAuth do
  @moduledoc """
  An implementation of OAuth2 for Patreon.

  To add your `:client_id` and `:client_secret` include these values in your
  configuration:

      config :ueberauth, Ueberauth.Strategy.Patreon.OAuth,
        client_id: System.get_env("PATREON_CLIENT_ID"),
        client_secret: System.get_env("PATREON_CLIENT_SECRET")

  """
  use OAuth2.Strategy

  @defaults [
    strategy: __MODULE__,
    site: "https://www.patreon.com/",
    authorize_url: "https://www.patreon.com/oauth2/authorize",
    token_url: "https://www.patreon.com/api/oauth2/token",
    token_method: :post
  ]

  @doc """
  Construct a client for requests to Patreon.

  Optionally include any OAuth2 options here to be merged with the defaults:

      Ueberauth.Strategy.Patreon.OAuth.client(
        redirect_uri: "http://localhost:4000/auth/patreon/callback"
      )

  This will be setup automatically for you in `Ueberauth.Strategy.Patreon`.

  These options are only useful for usage outside the normal callback phase of
  Ueberauth.
  """
  def client(opts \\ []) do
    config =
      :ueberauth
      |> Application.fetch_env!(Ueberauth.Strategy.Patreon.OAuth)
      |> check_credential(:client_id)
      |> check_credential(:client_secret)
      |> check_credential(:redirect_uri)

    client_opts =
      @defaults
      |> Keyword.merge(config)
      |> Keyword.merge(opts)

    json_library = Ueberauth.json_library()

    OAuth2.Client.new(client_opts)
    |> OAuth2.Client.put_serializer("application/json", json_library)
    |> OAuth2.Client.put_serializer("application/vnd.api+json", json_library)
  end

  @doc """
  Provides the authorize url for the request phase of Ueberauth.

  No need to call this usually.
  """
  def authorize_url!(params \\ [], opts \\ []) do
    opts
    |> client
    |> OAuth2.Client.authorize_url!(params)
  end

  def get(token, url, headers \\ [], opts \\ []) do
    client()
    |> put_header("authorization", "Bearer " <> token)
    |> put_header("accept", "application/json")
    |> OAuth2.Client.get(url, headers, opts)
  end

  def get_token!(params \\ [], options \\ []) do
    headers = Keyword.get(options, :headers, [])
    options = Keyword.get(options, :options, [])

    client_options = Keyword.get(options, :client_options, [])

    client = OAuth2.Client.get_token!(client(client_options), params, headers, options)

    client.token
  end

  # Strategy Callbacks
  def authorize_url(client, params) do
    OAuth2.Strategy.AuthCode.authorize_url(client, params)
  end

  def get_token(client, params, headers) do
    client =
      client
      |> put_param("grant_type", "authorization_code")
      |> put_header("Accept", "application/json")

    OAuth2.Strategy.AuthCode.get_token(client, params, headers)
  end

  defp check_credential(config, key) do
    check_config_key_exists(config, key)

    case Keyword.get(config, key) do
      value when is_binary(value) ->
        config

      {:system, env_key} ->
        case System.get_env(env_key) do
          nil ->
            raise "#{inspect(env_key)} missing from environment, expected in config :ueberauth, Ueberauth.Strategy.Patreon"

          value ->
            Keyword.put(config, key, value)
        end
    end
  end

  defp check_config_key_exists(config, key) when is_list(config) do
    unless Keyword.has_key?(config, key) do
      raise "#{inspect(key)} missing from config :ueberauth, Ueberauth.Strategy.Patreon"
    end

    config
  end

  defp check_config_key_exists(_, _) do
    raise "Config :ueberauth, Ueberauth.Strategy.Patreon is not a keyword list, as expected"
  end
end

It’s always a great idea to add doc headers to all your public functions you expect your package users to be using. This will help them immensely in figuring out what each function does and seeing examples of input and output. For this package, a user won't be using it directly but instead following specific setup steps, so our documentation reflects that.

Generate our documentation to see how things look now.

mix docs

And spin up a server to see how things.

npx serve docs/

Bonus: Add the README

This is more of a personal preference than anything else. I'm not sure if you noticed when browsing the Hex Docs for various packages. But sometimes you will come across a project that has pretty nice docs but the setup steps will be missing. If you actually visit the packages repository, you will find a great README with all the information on how to setup up the project.

GAHHHH I also need those steps in those docs! Well, you easily can tell ex_doc to include the README. Let’s update our project settings to do just that.

In our mix.exs file add the docs field, it should look something like this now.

def project do
    [
      app: :ueberauth_patreon,
      description: "Ueberauth strategy for Patreon OAuth.",
      version: "1.0.0",
      elixir: "~> 1.13",
      source_url: "https://github.com/talk2MeGooseman/ueberauth_patreon",
      homepage_url: "https://github.com/talk2MeGooseman/ueberauth_patreon",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      package: [
        links: %{"GitHub" => "https://github.com/talk2MeGooseman/ueberauth_patreon"},
        licenses: ["MIT"],
      ],
      docs: [ #### New docs field
        extras: ["README.md"] ### This is going to include the readme in the docs
      ]
    ]
  end

Now let’s go to our README and update it with the project setup steps.

# Überauth Patreon

[![Hex Version](https://cdn.hashnode.com/res/hashnode/image/upload/v1679633621626/fe2dc713-2811-4631-8d98-6abd29a2d38b.svg)](https://hex.pm/packages/ueberauth_patreon)

> Patreon OAuth2 strategy for Überauth.

## Installation

1. Setup your application in Patreon Development Dashboard https://www.patreon.com/portal/registration/register-clients

1. Add `:ueberauth_patreon` to your list of dependencies in `mix.exs`:

    ```elixir
    def deps do
      [{:ueberauth_patreon, "~> 1.0"}]
    end
  1. Add Patreon to your Überauth configuration:

     config :ueberauth, Ueberauth,
       providers: [
         patreon: {Ueberauth.Strategy.Patreon, [default_scope: "identity[email] identity"]},
       ]
    
  2. Update your provider configuration:

    config :ueberauth, Ueberauth.Strategy.Patreon.OAuth,
      client_id: System.get_env("PATREON_CLIENT_ID"),
      client_secret: System.get_env("PATREON_CLIENT_SECRET"),
      redirect_uri: System.get_env("PATREON_REDIRECT_URI")
    
  3. Include the Überauth plug in your router pipeline:

    defmodule TestPatreonWeb.Router do
      use TestPatreonWeb, :router
    
      pipeline :browser do
        plug Ueberauth
        ...
       end
    end
    
  4. Add the request and callback routes:

    scope "/auth", TestPatreonWeb do
      pipe_through :browser
    
      get "/:provider", AuthController, :request
      get "/:provider/callback", AuthController, :callback
    end
    
  5. Create a new controller or use an existing controller that implements callbacks to deal with Ueberauth.Auth and Ueberauth.Failure responses from Patreon.

       defmodule TestPatreonWeb.AuthController do
         use TestPatreonWeb, :controller
    
         def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
           conn
           |> put_flash(:error, "Failed to authenticate.")
           |> redirect(to: "/")
         end
    
         def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
           case UserFromAuth.find_or_create(auth) do
             {:ok, user} ->
               conn
               |> put_flash(:info, "Successfully authenticated.")
               |> put_session(:current_user, user)
               |> configure_session(renew: true)
               |> redirect(to: "/")
    
             {:error, reason} ->
               conn
               |> put_flash(:error, reason)
               |> redirect(to: "/")
           end
         end
       end
    

Calling

Once your setup, you can initiate auth using the following URL, unless you changed the routes from the guide:

/auth/patreon

Documentation

The docs can be found at ueberauth_patreon on Hex Docs.


Generate our documentation to see how things look now.

```bash
mix docs

And spin up a server to see how things.

npx serve docs/

Our new package is code complete but we don't know if it actually works.

Testing Our Package In A Project

I think I write half-decent code, but not enough to trust that it just works so I want to try testing it in an actual Phoenix project. Let’s spin one up to test it LIVE.

mix phx.new test_patreon

Short Detour: Using VS Code Run Our Project

We will need a database for our project, the easiest way I like to do this is using Visual Studio Code’s Remote - Containers feature. Using Docker and an existing template, I can spin up a dev environment that all the fixings I need to work on a Phoenix project.

  1. Start VS Code, run the Remote-Containers: Open Folder in Container... command from the Command Palette (F1) or quick actions Status bar item, and select the project folder you would like to set up the container for.

  2. Now select Show All Definitions... > Elixir, Phoenix, Node.js & PostgresSQL (Community)

  3. After picking the starting point for your container, VS Code will add the dev container configuration files to your project (.devcontainer/devcontainer.json).

  4. The VS Code window will reload and start building the dev container. A progress notification provides status updates. You only have to build a dev container the first time you open it; opening the folder after the first successful build will be much quicker.

  5. After the build completes, VS Code will automatically connect to the container.

Now your project is all set up with everything you need to run the project. You will most likely need to go into the docker-compose.yml file and update the database name to the name your project will be using.

Resuming: Testing Our Package In A Project

Let make sure our project setup works.

mix setup

In order to test our project, we don’t need to worry about publishing it to Hex. We can install it locally or install it through git. I have already pushed my project to Github so I want to show you how you can install any package hosted on Git or Github really easily.

Add the following to your deps. Your name or URL would be different if your project.

{:ueberauth_patreon, github: "talk2MeGooseman/ueberauth_patreon"}

As a personal rule of thumb, make sure to go through the setup guide you have for your package VERBATIM. This ensures you didn't miss any steps as part of the setup guide, because were the author sometimes we can accidentally gloss over an important.

Now install your package and test it out.

mix deps.get

Here is to hoping your project just works during testing it!

UH OH, My Package Has A Bug!

When the inevitable and your find a bug in your code there are a couple of things you can do.

  1. Make updates to your package project code and push the changes to Git. If you do this, in your test project make sure to run the following command to install the latest updates.
mix deps.update ueberauth_patreon
  1. Modify the package code directly inside your test project. All your installed packages can be found inside your deps/ folder and any changes you make to them can be recompiled so you can see how they affect your project. This can save you the trouble of having to go back to your package project, make code changes, and then push the code. Just run the following command to recompile your package.
mix deps.compile ueberauth_patreon

Make sure you copy the fixes you make back to your package project and commit them!

Submitting The Package

Now a package is complete and is ready to publish. Hex again does a great job detailing the steps you need to go through here https://hex.pm/docs/publish#submitting-the-package

Just run the command and confirm when you verify everything looks good.

> mix hex.publish
Building ueberauth_patreon 1.0.0
  Dependencies:
    ueberauth ~> 0.7 (app: ueberauth)
    oauth2 ~> 2.0 (app: oauth2)
  App: ueberauth_patreon
  Name: ueberauth_patreon
  Files:
    lib
    lib/ueberauth
    lib/ueberauth/strategy
    lib/ueberauth/strategy/patreon
    lib/ueberauth/strategy/patreon/oauth.ex
    lib/ueberauth/strategy/patreon.ex
    lib/ueberauth_patreon.ex
    .formatter.exs
    mix.exs
    README.md
  Version: 1.0.0
  Build tools: mix
  Description: Ueberauth strategy for Patreon OAuth.
  Licenses: MIT
  Links: 
    GitHub: https://github.com/talk2MeGooseman/ueberauth_patreon
  Elixir: ~> 1.13
Before publishing, please read the Code of Conduct: https://hex.pm/policies/codeofconduct

Publishing package to public repository hexpm.

Oh No I Messed Up My Docs!

Free not, for Hex has a way to directly publish doc updates. No need to make a new release just to update docs. Once you have made your docs updates just run the following command and you are good.

mix hex.publish docs