My advice: stay away from “railway-oriented programming”. It can be very expressive in the right context, but mostly just leads to twisty code that’s hard to read.
The other downside to approaches like the one in UrlContentSyncWorker
is that the path taken in subsequent steps is highly dependent on what’s happened to data
before; for instance, the way that maybe_make_commit_request
’s code path depends on what happened in make_default_branch_request
because data
will have different keys.
Here’s my refactoring of the file (as of cancel_if_missing_data/1 · notesclub/notesclub@f714e8d · GitHub):
# snip
def perform(%Oban.Job{args: %{"notebook_id" => notebook_id}}) do
with {:ok, notebook} <- load_notebook(notebook_id),
{:ok, urls} <- get_urls(notebook),
{:ok, attrs} <- attrs_for_update(notebook, urls)
do
update_notebook(notebook, attrs)
end
end
defp load_notebook(notebook_id) do
case Notebooks.get_notebook(notebook_id, preload: [:user, :repo]) do
nil ->
{:cancel, "notebook does NOT exist"}
%Notebook{user: nil} ->
{:cancel, "user is nil"}
%Notebook{repo: %Repo{default_branch: nil} = repo} ->
{:ok, _job} =
%{repo_id: repo.id}
|> RepoSyncWorker.new()
|> Oban.insert()
{:cancel, "No default branch. Enqueueing RepoSyncWorker."}
notebook ->
{:ok, notebook}
end
end
defp get_urls(%Notebook{} = notebook) do
case Urls.get_urls(notebook) do
{:ok, urls} ->
{:ok, urls}
{:error, error} ->
{:cancel, "get_urls/1 returned '#{error}'; notebook id: #{notebook.id}"}
end
end
defp attrs_for_update(notebook, urls) do
case ReqTools.make_request(urls.raw_default_branch_url) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, %{content: body, url: urls.default_branch_url}}
{:ok, %Req.Response{status: 404}} ->
attrs_from_commit(notebook, urls)
_ ->
# Retry several times
raise "request to notebook default branch url failed"
end
end
defp attrs_from_commit(notebook, urls) do
case ReqTools.make_request(urls.raw_commit_url) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, %{content: body, url: nil}}
{:ok, %Req.Response{status: 404}} ->
{:cancel,
"Neither notebook default branch url or commit url exists. The notebook id: #{notebook.id} was deleted or moved on Github"}
_ ->
# Retry job several times
raise "request to notebook commit url failed"
end
end
defp update_notebook(notebook, attrs) do
case Notebooks.update_notebook(notebook, attrs) do
{:ok, _notebook} ->
{:ok, :synced}
{:error, _} ->
# Retry job several times
{:error, "Error saving the notebook id #{notebook.id}, attrs: #{inspect(attrs)}"}
end
end
end
IMO perform
’s control flow is clearer in this shape:
- all the responsibility for forming error-shapes is still left to the helper functions
- the dependencies of each helper function (
load_notebook
/get_urls
/attr_for_update
) are clearly labeled