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)