DEV Community

Lincoln Rodrigues
Lincoln Rodrigues

Posted on

SFDCQuery — Building and publishing my first Elixir Library

Image description

Working with Salesforce, after each feature delivered, I usually need to investigate how are the Accounts and Leads, but, when having access to the account, you can see the data in the Developer console except for my use-case which remains to me fetching the data through Query API and Bulk Search API since in I only have the OAuth Credentials of the instances stored (encrypted of course). To improve productivity while developing or investigating any possible issue with the customer's instances, I built the library SFDCQuery.

Before sharing the steps for building it, here's where you can find it:
https://github.com/linqueta/sfdc-query
https://hex.pm/packages/sfdc_query
https://hexdocs.pm/sfdc_query/readme.html

All right, first of all, having mix installed, it's time to create the project:

 mix new sfdc_query           
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/sfdc_query.ex
* creating test
* creating test/test_helper.ex
* creating test/sfdc_query_test.exs

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

    cd sfdc_query
    mix test

Run "mix help" for more commands.
Enter fullscreen mode Exit fullscreen mode

Having the project created, we need to figure out its shape allowing the clients to create their implementations, it means, Elixir behaviors!

Building the Client

For connecting to a Salesforce application, the first step is to execute the oAuth which can be done using the Salesforce CLI, and once it's done, you have the following information:

 sfdx force:auth:web:login -r https://test.salesforce.com

Successfully authorized me@linqueta.com with org ID ABC102030

 sfdx force:org:display --o me@linqueta.com

=== Org Description

 KEY              VALUE                                                                                                            
 ──────────────── ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── 
 Access Token     YOUR_CURRENT_ACCESS_TOKEN
 Api Version      60.0                                                                                                             
 Client Id        CLIENT_ID                                                                                                      
 Connected Status Connected                                                                                                        
 Id               ABC102030                                                                                    
 Instance Url     https://linqueta.my.salesforce.com                                                                 
 Username         me@linqueta.com
Enter fullscreen mode Exit fullscreen mode

The most important data for connecting to the Query API are the fields Access Token, Api Version, and Instance Url. To hold this info, the module SFDCQuery.Config was created as:

defmodule SFDCQuery.Config do
  @moduledoc """
  The configuration of the SFDCQuery library.
  """

  alias SFDCQuery.Config

  defstruct [:instance_url, :access_token, :version, :logs]

  @type t :: %__MODULE__{
          instance_url: String.t(),
          access_token: String.t(),
          version: String.t(),
          logs: boolean()
        }

  @spec new(%{
          required(:instance_url) => String.t(),
          required(:access_token) => String.t(),
          required(:version) => String.t() | integer(),
          optional(:logs) => boolean()
        }) :: t()
  def new(%{instance_url: nil}), do: raise(ArgumentError, "instance_url is required")
  def new(%{access_token: nil}), do: raise(ArgumentError, "access_token is required")
  def new(%{version: nil}), do: raise(ArgumentError, "version is required")

  def new(%{instance_url: instance_url, access_token: access_token, version: version} = args) do
    %Config{
      instance_url: instance_url,
      access_token: access_token,
      version: parse_version(version),
      logs: parse_boolean(args[:logs])
    }
  end

  defp parse_boolean(nil), do: false
  defp parse_boolean(val) when is_binary(val), do: String.downcase(val) == "true"
  defp parse_boolean(bool), do: bool

  defp parse_version(version) when is_integer(version), do: "#{version}.0"
  defp parse_version(version) when is_binary(version), do: version
end
Enter fullscreen mode Exit fullscreen mode

But, each client probably needs to have their way of building their Config, so, the Client layer allows to have it easily:

defmodule SFDCQuery.Client.Behaviour do
  @callback create(any()) :: SFDCQuery.Config.t()
end

defmodule SFDCQuery.Client.Default do
  @behaviour SFDCQuery.Client.Behaviour

  alias SFDCQuery.Config

  @doc """
  Builds a new client configuration with the given arguments.
  It fetches the values from the environment variables if they are not provided.
    SFDC_QUERY_INSTANCE_URL
    SFDC_QUERY_ACCESS_TOKEN
    SFDC_QUERY_REFRESH_TOKEN
    SFDC_QUERY_VERSION
    SFDC_QUERY_LOGS_ENABLE
  """
  @impl true
  def create(args \\ %{}) do
    Config.new(%{
      instance_url: args[:instance_url] || fetch_env("SFDC_QUERY_INSTANCE_URL"),
      access_token: args[:access_token] || fetch_env("SFDC_QUERY_ACCESS_TOKEN"),
      version: args[:version] || fetch_env("SFDC_QUERY_VERSION"),
      logs: args[:logs] || fetch_env("SFDC_QUERY_LOGS_ENABLED")
    })
  end

  defp fetch_env(key) do
    case System.get_env(key) do
      "" -> nil
      value -> value
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

It means that the library client can create their own SFDC Client by implementing the Client's behavior. Here's an example:

defmodule MyApp.CustomerSFDCClient do
  @behaviour SFDCQuery.Client.Behaviour

  alias SFDCQuery.Config

  alias MyApp.Ecto

  import Ecto.Query

  @impl true
  def create(customer_id) do
    credentials = from(c in Credentials, where: c.customer_id == ^customer_id) |> Repo.one()

    Config.new(%{
      instance_url: credentials.salesforce_instance_url,
      access_token: credentials.salesforce_access_token,
      version: credentials.salesforce_version,
      logs: false
    })
  end
end
Enter fullscreen mode Exit fullscreen mode

Querying Salesforce data

Once we have the instance's credentials in memory, we need to call SFDC Query API. To allow making HTTP requests, I choose Req since it's simple enough for just one API endpoint:

Req.request(
  method: :get,
  url: "#{config.instance_url}/services/data/v#{config.version}/query",
  headers: [{"Authorization", "Bearer #{config.access_token}"}],
  params: [q: "Select Id, Name from Account LIMIT 10"]
)
Enter fullscreen mode Exit fullscreen mode

The module SFDCQuery.RestAPI makes the Req request exampled above and parses the response handling possible errors:

defp handle({:ok, %Req.Response{status: 200, body: %{"records" => records}}}), do: {:ok, records}
defp handle({:ok, %Req.Response{status: _, body: body}}), do: {:error, body}
defp handle({:error, _} = error), do: error
Enter fullscreen mode Exit fullscreen mode

Here's the shape of a successful query response:

{:ok,
 %Req.Response{
   status: 200,
   body: %{
     "done" => true,
     "records" => [
        %{"Id" => "001U8000005CeutIAC"},
        %{"Id" => "001U8000005cJN0IAM"},
        %{"Id" => "001U8000005cRAnIAM"},
        %{"Id" => "001U8000005oz2rIAA"}
      ],
     "totalSize" => 4
   }
 }}
Enter fullscreen mode Exit fullscreen mode

Parsing the data

SFDCQuery allows to query SFDC through the Query API but it's not the most important thing in this library. Parsing the response and printing it if makes sense improves a lot the productivity of the developer who needs to check or handle the records returned. To parse the response, SFDCQuery implements a parser for a Map, a JSON, and a CSV.

# Map
SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.Parser.Map.parse()

{:ok,
 [
    %{
      attributes: %{
        "type" => "Account",
        "url" => "/services/data/v60.0/sobjects/Account/001U8000005CeutIAC"
      },
      Name: "Page",
      Id: "001U8000005CeutIAC",
      Website: nil
    },
    %{
      attributes: %{
        "type" => "Account",
        "url" => "/services/data/v60.0/sobjects/Account/001U8000005cJN0IAM"
      },
      Name: "Nike",
      Id: "001U8000005cJN0IAM",
      Website: "https://www.nike.com/"
    },
    %{
      attributes: %{
        "type" => "Account",
        "url" => "/services/data/v60.0/sobjects/Account/001U8000005cRAnIAM"
      },
      Name: "Google",
      Id: "001U8000005cRAnIAM",
      Website: "google.com"
    }
  ]
}

# CSV
SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.Parser.CSV.parse()

{:ok, "Id,Name,Website\n001U8000005CeutIAC,Page,\n001U8000005cJN0IAM,Nike,https://www.nike.com/\n001U8000005cRAnIAM,Google,google.com"}

# JSON
SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.Parser.JSON.parse()

{:ok, "[{\"attributes\":{\"type\":\"Account\",\"url\":\"/services/data/v60.0/sobjects/Account/001U8000005CeutIAC\"},\"Name\":\"Page\",\"Id\":\"001U8000005CeutIAC\",\"Website\":null},{\"attributes\":{\"type\":\"Account\",\"url\":\"/services/data/v60.0/sobjects/Account/001U8000005cJN0IAM\"},\"Name\":\"Nike\",\"Id\":\"001U8000005cJN0IAM\",\"Website\":\"https://www.nike.com/\"},{\"attributes\":{\"type\":\"Account\",\"url\":\"/services/data/v60.0/sobjects/Account/001U8000005cRAnIAM\"},\"Name\":\"Google\",\"Id\":\"001U8000005cRAnIAM\",\"Website\":\"google.com\"}]"}
Enter fullscreen mode Exit fullscreen mode

The library client can implement their parser using SFDCQuery.Parser.Behaviour :

defmodule MyApp.SFDCFileParser do
  @behaviour SFDCQuery.Parser.Behaviour

  alias SFDCQuery.Query

  @impl true
  def parse({:error, _} = error), do: error
  def parse({:ok, %Query{records: records}}), do: {:ok, save_file(records)}

  defp save_file(records) do
    ...
  end
end
Enter fullscreen mode Exit fullscreen mode

Showing the data

Here the magic happens! After querying the API, you can check the data as a view, first using the table implementation:

SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.View.Table.show()

SELECT Id, Name, Website From Account LIMIT 3
-------------------------------------------------------
| Id                 | Name   | Website               |
-------------------------------------------------------
| 001U8000005CeutIAC | Page   |                       |
| 001U8000005cJN0IAM | Nike   | https://www.nike.com/ |
| 001U8000005cRAnIAM | Google | google.com            |
Enter fullscreen mode Exit fullscreen mode

You can check JSON and CSV too:

SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.View.JSON.show()

SELECT Id, Name, Website From Account LIMIT 3
-------------------------------------------------------
[
  {
    "attributes": {
      "type": "Account",
      "url": "/services/data/v60.0/sobjects/Account/001U8000005CeutIAC"
    },
    "Name": "Page",
    "Id": "001U8000005CeutIAC",
    "Website": null
  },
  {
    "attributes": {
      "type": "Account",
      "url": "/services/data/v60.0/sobjects/Account/001U8000005cJN0IAM"
    },
    "Name": "Nike",
    "Id": "001U8000005cJN0IAM",
    "Website": "https://www.nike.com/"
  },
  {
    "attributes": {
      "type": "Account",
      "url": "/services/data/v60.0/sobjects/Account/001U8000005cRAnIAM"
    },
    "Name": "Google",
    "Id": "001U8000005cRAnIAM",
    "Website": "google.com"
  }
]

SFDCQuery.Client.Default.create(args)
|> SFDCQuery.query("SELECT Id, Name, Website From Account LIMIT 3")
|> SFDCQuery.View.CSV.show()

SELECT Id, Name, Website From Account LIMIT 3
-------------------------------------------------------
Id,Name,Website
001U8000005CeutIAC,Page,
001U8000005cJN0IAM,Nike,https://www.nike.com/
001U8000005cRAnIAM,Google,google.com
Enter fullscreen mode Exit fullscreen mode

The behaviour SFDCQuery.View.Behaviour allows the client to define new views as they want.

Publishing the library

Once the project is tested, it's time to publish it to hex.pm. The steps to do it are simple:

Before publishing, you need to append some package details to the mix.env file:

def package do
  [
    name: "sfdc_query",
    files: ~w(lib .credo.exs .formatter.exs mix.exs README*),
    licenses: ["Apache-2.0"],
    links: %{"GitHub" => "https://github.com/linqueta/sfdc-query"}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Create an account at hex.pm
In your terminal, step a login into your account defining a local password: mix hex.user whoami
Publish the package: mix hex.publish
And it's ready for being used in other projects being delivered by hex.pm: https://hex.pm/packages/sfdc_query

Documentation

The library ExDoc allows to your project have the docs at hex.pm based on the documentation inside the modules and function-related. To install it, in you mix.env you need to add a docs section and later execute it with mix docs . Once call mix hex.publish , ExDoc automatically triggers to build new docs for the version selected and publishes it to hex.pm docs

defp docs do
  [
    source_ref: "v#{@version}",
    main: "readme",
    formatters: ["html"],
    extras: ["README.md"]
  ]
end
Enter fullscreen mode Exit fullscreen mode

And here it is: https://hexdocs.pm/sfdc_query/readme.html

Top comments (0)