Pharmacist is an "inversion of control" library for data access: given multiple sources of data, it finds the fastest way to fetch them all, optionally caches results, retries failing fetches, handles errors in a unified way, and optionally validates, transforms, and coerces data on the way back. Pharmacist helps you isolate the mechanics of fetching data, and allows your data processing functions to reach their full potential as pure consumers of Clojure data, no longer mired in imperative HTTP mechanics, global data structures, tedious error handling and retries, or mapping.
Pharmacist helps you:
Pharmacist coordinates data fetches, it does not actually implement fetching. It can fetch anything for you by coordinating your functions - it can work with both synchronous and asynchronous sources. Some relevant examples include:
NB! The goal is for Pharmacist to become a stable library worthy of your
trust, which never intentionally breaks backwards compatibility. Currently,
however, it is still under development, and slight changes may occur. This will
be the case for as long as the version is prefixed with a 0
.
With tools.deps:
cjohansen/pharmacist {:mvn/version "0.2019.02.03-3"}
With Leiningen:
[cjohansen/pharmacist "0.2019.02.03-3"]
To fetch data with Pharmacist you implement data sources - functions that fetch data from somewhere, provide a prescription - a description of how to invoke and coordinate data source functions, and finally select some or all of the described data.
A data source is any function that accepts a data source description and returns a Pharmacist result:
(require '[pharmacist.data-source :as data-source]
'[pharmacist.result :as result]
'[clj-http.client :as http])
(defn spotify-playlists [{::data-source/keys [params]}]
(let [res (http/get "https://api.spotify.com/playlists"
{:oauth-token (:token params)
:throw-exceptions false})]
(if (http/success? res)
(result/success (:body res))
(result/failure {:error "Failed to fetch playlists" :res res}))))
While you certainly could make good of use it with a single data source, Pharmacist is designed to alleviate the pain of coordinating multiple sources, so a meaningful hello world will need at least two sources. Let's define another one, which fetches an API token:
(defn spotify-auth [{::data-source/keys [params]}]
(let [res (http/post "https://accounts.spotify.com/api/token"
{:throw-exceptions false
:form-params {:grant_type "client_credentials"}
:basic-auth [(:spotify-api-user params)
(:spotify-api-password params)]})]
(if (http/success? res)
(result/success (:body res))
(result/failure {:error "Failed to acquire token" :res res}))))
To make things interesting, we will tell Pharmacist to use the token from the auth data source as a parameter to the one fetching playlists. This is what the prescription is for:
(def prescription
{::auth {::data-source/fn #'spotify-auth
::data-source/params {:spotify-api-user "username"
:spotify-api-password "password"}}
::playlists {::data-source/fn #'spotify-playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]}}})
Inlining the username and password is a terrible idea, we'll see a better solution for this shortly.
Passing quoted vars (#'spotify-auth
) instead of function values
(spotify-auth
) makes your code reloadable, and enables Pharmacist to infer the
function name in a predictable manner.
Notice the value of the :token
parameter in ::playlists
. The metadata
informs Pharmacist that this value will be provided by the ::auth
data source.
Pharmacist now knows to fetch ::auth
first, to extract :access_token
from
its resulting data, and pass it as the :token
parameter to ::playlists
. If
there had been no such dependency, Pharmacist would've retrieved both items in
parallel (or possibly only the one you selected).
To fetch data, pass the prescription to pharmacist.prescription/fill
, and then
pharmacist.prescription/select
the desired keys:
(require '[pharmacist.prescription :as p]
'[pharmacist.result :as result]
'[clojure.core.async :refer [go-loop <!]])
(let [ch (p/select (p/fill prescription) [::playlists])]
(go-loop []
(when-let [item (<! ch)]
(prn (::result/path item) (::result/success? item) (::result/data item))
(recur))))
fill
returns a lazy collection of your data. You may select
from it multiple
times, but it will always give you the same data back. select
returns a
core.async
channel that receives a message every time a single data source is
attempted fetched. If you'd rather get everything in one go, you can use
collect
:
(require '[pharmacist.prescription :as p]
'[clojure.core.async :refer [go <!]])
(go
(let [res (p/collect (p/select (p/fill prescription) [::playlists]))]
(<! res)))
collect
returns a channel that emits a single message containing an overall
success indicator (::result/succes?
), a combined map of {path data}
for all
successfully fetched data sources (::result/data
), and a list of all sources
with their individual results (including for sources that where never attempted
fetched due to failing dependencies). For the above example, ::result/data
in
the message from the collect
channel would contain:
{::auth {:token "..."}
::playlists {...}}
You can also fetch the whole thing synchronously:
(require '[pharmacist.prescription :as p]
'[clojure.core.async :refer [<!!]])
(println (<!! (p/collect (p/select (p/fill prescription) [::playlists]))))
See the section on async vs sync fetch for more information on the return value from collect.
Pharmacist dependencies can be used to string data from one source to another.
But what about parameters for the initial fetches? Sometimes, you can get away
by providing them inline in the prescription, but this limits the reusability of
the prescription. Depending on where you are calling fill
from, you might have
input parameters from a number of sources:
These can be provided to fill
as initially resolved data sources. Here's the
updated prescription:
;; Now with externalized configuration
(def prescription
{::auth {::data-source/id :spotify/auth
::data-source/params {:spotify-api-user ^::data-source/dep [:config :spotify-api-user]
:spotify-api-password ^::data-source/dep [:config :spotify-api-user]}}
::playlists {::data-source/id :spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]}}})
And this is how you fill it with runtime configuration:
(require '[pharmacist.prescription :as p]
'[pharmacist.result :as result]
'[clojure.core.async :refer [<!!]])
(def env (System/getenv))
(println
(-> prescription
(p/fill {:params {:config {:spotify-api-user (get env "SPOTIFY_USER")
:spotify-api-password (get env "SPOTIFY_PASS")}}})
(p/select [::playlists])
p/collect
<!!))
There are no magical or conventional names here. Any key inside the map passed
to :params
will be available as a dependency in your prescription, and you
access information from in just like other data sources.
If all of a source's params are already neatly available as a map from another resource, declare the full map as a dependency:
(def prescription
{::auth {::data-source/id :spotify/auth
::data-source/params ^::data-source/dep [:config]}
::playlists {::data-source/id :spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]}}})
HTTP and other network protocols don't always work exactly as planned. Sometimes, problems go away if you just try again. To instruct Pharmacist to retry a source, specify the maximum number of retries on the prescription:
(def prescription
{::auth {::data-source/id :spotify/auth
::data-source/params {:spotify-api-user "username"
:spotify-api-password "password"}
::data-source/retries 3}})
With this addition, the token will be retried 3 times before causing an error -
if the result can be retried, a decision made by the fetch implementation. You
will still be notified via the channel returned from select
of failed requests
that are being retried, the message will look like:
{::result/success? false
::result/data {:error "Network failure"}
::result/attempts 1
::result/retrying? true}
::result/retrying? true
means the source is going to be retried.
Some errors won't go away no matter how many times you retry. The result returned from the data source can inform Pharmacist on whether or not it is worth trying again:
(defn spotify-auth [{::data-source/keys [params]}]
(let [res (http/post "https://accounts.spotify.com/api/token"
{:throw-exceptions false
:form-params {:grant_type "client_credentials"}
:basic-auth [(:spotify-api-user params)
(:spotify-api-password params)]})]
(if (http/success? res)
(result/success (:body res))
(result/failure {:error "Failed to acquire token" :res res}
{::result/retryable? (not= 401 (:status %))}))))
When ::result/retryable?
is false
, Pharmacist will not retry the fetch, even
if the maximum number of retries is not exhausted. If ::result/retryable?
is
not set, its default value is true
.
If you are using caching, it might not be worth retrying a fetch with the same (possibly stale) set of dependencies - you might need to refresh some or all of them. To continue the example of the authentication token, a 403 response from a service could be worth retrying, but only with a fresh token.
Given the following prescription:
(def prescription
{::auth {::data-source/id :spotify/auth
::data-source/params ^::data-source/dep [:config]}
::playlists {::data-source/id :spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]}}})
The playlist resource can indicate that it might be worth a retry with a new token the following way:
(defn spotify-playlists [{::data-source/keys [params]}]
(let [res (http/get "https://api.spotify.com/playlists"
{:oauth-token (:token params)
:throw-exceptions false})]
(if (http/success? res)
(result/success (:body res))
(result/failure {:error "Failed to fetch playlists" :res res}
(when (= 403 (:status res))
{::result/retryable? true
::result/refresh [:token]})))))
Pharmacist will know that the :token
parameter came from the ::auth
source,
fetch it again (bypassing the cache), and then retry the playlist with a fresh
token.
Caching of data sources is primarily handled by two functions:
(defn get [path prescription])
(defn put [path prescription result])
get
attempts to get a cached copy of the data source. It will be passed the
path to the source (e.g. [::auth]
and [::playlists]
in the above example),
and the individual data source with fully resolved ::data-source/params
(e.g.
values will be filled in for inter-source dependencies). If this function
returns a non-nil value, the value will be used in place of fetching the remote
source, and put
will never be called.
If the value does not exist in the cache, the data will be fetched from the
source, and if successful, put
will be called with the same arguments as
get
, along with the fetched result.
You pass these functions to fill
as the :cache
parameter:
(require '[clojure.string :as str]
'[pharmacist.data-source :as data-source]
'[pharmacist.prescription :as prescription])
(defn cache-get [cache path prescription]
(get @cache (data-source/cache-key prescription)))
(defn cache-put [cache path prescription value]
(swap! cache assoc (data-source/cache-key prescription) value))
(def cache (atom {}))
(prescription/fill
prescription
{:cache {:get (partial cache-get cache)
:put (partial cache-put cache)}})
As demonstrated above, you can rely on Pharmacist to generate cache keys for
you - the default implementation will combine ::data-source/id
with all
::data-source/params
(after resolving dependencies) in a vector.
Because the cache key requires the fully resolved parameters, all of a source's parameters must be fully resolved before looking it up in the cache. This means that if you have a playlist data source like above, that depends on a token resource, then Pharmacist is unable to check the cache for a playlist until it's fetched a token, which is a little backwards. If the playlist is already cached and you don't need the token for anything else, there is no need to fetch the token at all.
Pharmacist solves this problem with :pharmacist.data-source/cache-params
, a
vector of parameter paths (as in vectors you could pass to get-in
etc)
required to build the cache key:
(require '[pharmacist.data-source :as data-source]
'[pharmacist.prescription :as p]
'[myapp.spotify :as spotify])
(def prescription
{::auth {::data-source/fn #'spotify/auth
::data-source/params ^::data-source/dep [:config]}
::playlists {::data-source/fn #'spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]
:id ^::data-source/dep [::playlist-id]}
::data-source/cache-params [[:id]]}})
(-> prescription
(p/fill {:params {::playlist-id 42}})
(p/select [::playlists]))
Now Pharmacist will know that only the :id
is necessary to look for playlists
in the cache, meaning that its cache key will be something like:
[:spotify/playlists {[:id] 42}]
If you now select for the ::playlists
, Pharmacist may skip the ::auth
data
source entirely when the playlist in question exists in the cache.
:pharmacist.data-source/cache-params
can also be used to reduce the amount of
data that make up cache keys. Parameters can sometimes be huge maps, and
typically only a few keys, or even a single key, like :id
, is enough to adress
the source. You can use paths to reduce the amount of data that goes into the
cache key:
{::data-source/fn #'spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]
:user ^::data-source/dep [::user]}
::data-source/cache-params [[:user :id]]}
Because maps in atoms is such a versatile caching strategy (it will work with
plain maps and atoms, as well as atoms containing any of Clojure's core.cache
types), Pharmacist provides a convenience function for them:
(require '[pharmacist.prescription :as prescription]
'[pharmacist.cache :as cache])
(prescription/fill prescription {:cache (cache/atom-map (atom {}))})
Combine this with the core.cache
TTL cache to cache all data for one hour:
(require '[pharmacist.prescription :as prescription]
'[pharmacist.cache :as pc]
'[clojure.core.cache :as cache])
(prescription/fill
prescription
{:cache (pc/atom-map (atom (cache/ttl-cache-factory {} :ttl (* 60 60 1000))))})
NB! This approach caches everything the same way. If you need more control,
provide functions that dispatch on path
, ::data-source/id
, or even
individual results. The caching functions are passed the full source, meaning
that you could annotate it with information about how long different types of
data should be cached etc. You can also set :ttl
, :expires-at
or similar on
your sources and/or fetch results, and use these in your cache get/put
functions.
When using atom-map
cache, you can control which params are used for cache
keys with :pharmacist.data-source/cache-params
as described above.
When consuming external data, we might want to map the keys and coerce the types of the result set to better fit our world-view. Mapping and type-coercion is handled by Pharmacist schemas, which can also be used to generate specs, validate payloads, and generate Datascript schemas.
A schema specifies what parts of a data source result to extract, what data types to expect (enforcable through dev-time asserts), how to coerce data, and how to map names. It also serves as documentation of the parts of an external source of data your app is concerned with:
(def playlist
{::data-source/fn #'spotify/playlists
::data-source/params {:token ^::data-source/dep [::auth :access_token]
:id ^::data-source/dep [::playlist-id]}
::data-source/schema
{:image/url {::schema/spec string? ;; 1
::schema/source :url} ;; 2
:image/width {::schema/spec int?
::schema/source :width
::schema/coerce parse-int} ;; 3
:image/height {::schema/spec int?
::schema/source :height}
:playlist/id {::schema/unique ::schema/identity
::schema/source schema/infer-ns} ;; 4
:playlist/collaborative {::schema/spec boolean?
::schema/source :collaborative}
:playlist/title {::schema/spec string?
::schema/source :name} ;; 5
:playlist/image {::schema/spec (s/keys :req [:image/url :image/width :image/height])} ;; 6
:playlist/images {::schema/spec (s/coll-of :playlist/image)
::schema/source :images} ;; 7
::schema/entity {::schema/spec (s/keys :req [:playlist/id ;; 8
:playlist/collaborative
:playlist/title
:playlist/images])}}})
clojure.spec.alpha
spec for :image/url
. Provides documentation, and can
be used with asserts during development to verify assumptions about external
data.:image/url
key in the
resulting data from the :url
attribute of the source data.::schema/coerce
can be set to any function that takes a single argument -
the value to coerce - and returns a coerced value. If you are enforcing specs
during development, the coercer should produce a value that is compatible
with the spec.schema/infer-ns
infers a
namespaced key from its bare counterpart. In this case, :playlist/id
will
be populated from :id
. The ::schema/source
function is called with a map
and a key.:name
in the API payload to :playlist/title
in our local data.:images
in the payload
and map each one with the keys in the :playlist/image
key spec.:pharmacist.schema/entity
as either a map or collection spec, and this key is used to map the root
data. If this convention does not suit your needs, you can implement
pharmacist.schema/coerce-data
and provide another root spec, see the API
docs.With this schema in place, data from this source will be automatically mapped
when you call prescription/fill
. You can further utilize this schema if you
want:
(require '[pharmacist.schema :as schema])
;; Define global specs
(schema/define-specs! playlist)
;; Assert that payloads match specs in schema
(schema/assert-payloads! playlist)
;; Datascript schema
(def ds-schema (schema/datascript-schema playlist))
The asserts generated by assert-payloads!
are clojure.spec.alpha/assert
, and
will typically be compiled out of production code. They can be useful to detect
incorrect mappings and/or inconsistent API responses during development.
Sometimes, fetching a piece of data will make you aware of new data sources to fetch. Linked resources from a REST API would be one such example. Because it can be hard to predict upfront how many there will be, it's hard to formulate this in a prescription. It is fully possible to perform multiple HTTP requests within the same data source, but it is not recommended, because you then opt out of the retry mechanism, caching, and error handling in Pharmacist for each intermediary request.
To take advantage of Pharmacist's prescription filling abilities for an unknown number of child resources, you can define a collection data source. The collection data source includes a reference to another data source to apply to its children, and then its result is either a vector of parameters to pass to the data source, or a map of paths to parameters to the child resource.
To demonstrate this with an example, let's fetch some data from an HTTP endpoint, and then recursively look up linked resources. ghibliapi.herokuapp.com provides metadata on Studio Ghibli movies. The payload for a Ghibli movie includes hypermedia links to the people in the movie:
{
"id": "58611129-2dbc-4a81-a72f-77ddfc1b1b49",
"title": "My Neighbor Totoro",
...
"people": [
"https://ghibliapi.herokuapp.com/people/986faac6-67e3-4fb8-a9ee-bad077c2e7fe",
"https://ghibliapi.herokuapp.com/people/d5df3c04-f355-4038-833c-83bd3502b6b9",
"https://ghibliapi.herokuapp.com/people/3031caa8-eb1a-41c6-ab93-dd091b541e11",
"https://ghibliapi.herokuapp.com/people/87b68b97-3774-495b-bf80-495a5f3e672d",
"https://ghibliapi.herokuapp.com/people/d39deecb-2bd0-4770-8b45-485f26e1381f",
"https://ghibliapi.herokuapp.com/people/f467e18e-3694-409f-bdb3-be891ade1106",
"https://ghibliapi.herokuapp.com/people/08ffbce4-7f94-476a-95bc-76d3c3969c19",
"https://ghibliapi.herokuapp.com/people/0f8ef701-b4c7-4f15-bd15-368c7fe38d0a"
],
"species": [
"https://ghibliapi.herokuapp.com/species/af3910a6-429f-4c74-9ad5-dfe1c4aa04f2",
"https://ghibliapi.herokuapp.com/species/603428ba-8a86-4b0b-a9f1-65df6abef3d3",
"https://ghibliapi.herokuapp.com/species/74b7f547-1577-4430-806c-c358c8b6bcf5"
],
"locations": [
"https://ghibliapi.herokuapp.com/locations/"
],
"vehicles": [
"https://ghibliapi.herokuapp.com/vehicles/"
],
"url": "https://ghibliapi.herokuapp.com/films/58611129-2dbc-4a81-a72f-77ddfc1b1b49"
}
We can use Pharmacist to recursively pull down this structure. We'll start with functions to fetch movies and people:
(require '[pharmacist.data-source :as data-source]
'[clj-http.client :as http])
(defn ghibli-film [{::data-source/keys [params]}]
(let [res (http/get (str "https://ghibliapi.herokuapp.com/films/" (:id params))
{:throw-exceptions false})]
(if (http/success? res)
(result/success (:body res))
(result/failure {:error "Failed to fetch playlists"}))))
(defn ghibli-person [{::data-source/keys [params]}]
(let [res (http/get (:url params)
{:throw-exceptions false})]
(if (http/success? res)
(response/success (:body %))
(response/failure {:error "Failed to fetch person"}))))
In order to load all the people from a movie, we need a selection function that can decide which exact people to fetch. We'll make one that just pulls all the ones from the movie:
(defn people-urls [{::data-source/keys [params]}]
(result/success (map (fn [url] {:url url}) (:movie/people params))))
These three functions can be stitched together with a prescription. First off, we'll define the movie and the person, with their schemas for coercion:
(def prescription
{:movie
{::data-source/fn #'ghibli-film
::data-source/params {:id ^::data-source/dep [:movie-id]}
::data-source/schema
{:movie/id {::schema/unique ::schema/identity
::schema/spec uuid?
::schema/coerce uuid
::schema/source :id}
:movie/title {::schema/spec string?
::schema/source :title}
:movie/description {::schema/spec string?
::schema/source :description}
:movie/release-date {::schema/spec string?
::schema/source :release_date}
:movie/people {::schema/spec (s/coll-of string?)}
::schema/entity {::schema/spec (s/keys :req [:movie/id
:movie/title
:movie/description
:movie/release-date
:movie/people])}}}
:person
{::data-source/fn #'ghibli-person
::data-source/params {:url ^::data-source/dep [:url]}
::data-source/schema
{:person/id {::schema/unique ::schema/identity
::schema/spec uuid?
::schema/coerce uuid
::schema/source :id}
:person/name {::schema/spec string?
::schema/source :name}
:person/age {::schema/spec number?
::schema/source :age}
::schema/entity {}}}})
Finally, the collection source:
(def prescription
{;;...
:people
{::data-source/fn #'people-urls
::data-source/params ^::data-source/dep [:movie]
::data-source/coll-of :person}})
Finally, let's fetch My Neighbour Totoro with all of its people:
(require '[pharmacist.prescription :as prescription])
(-> prescription
(prescription/fill
{:params {:movie-id "58611129-2dbc-4a81-a72f-77ddfc1b1b49"}})
(prescription/select [:people]))
Now, when a person is fetched, your channel will receive a message like:
{::result/path [:movie :movie/people 0]
::result/success? true
::result/data {:person/id "986faac6-67e3-4fb8-a9ee-bad077c2e7fe"
:person/name "Satsuki Kusakabe"
:person/age "11"}}
You can combine all of these events into a single map using
pharmacist.prescription/merge-sources
:
(require '[pharmacist.prescription :as prescription]
'[clojure.core.async :as a])
(let [ch (-> prescription
(prescription/fill
{:params {:movie-id "58611129-2dbc-4a81-a72f-77ddfc1b1b49"}})
(prescription/select [:people]))
messages (a/go-loop [messages []]
(when-let [message (a/<! ch)]
(recur (conj messages message))))]
(prescription/merge-results messages)
;;=>
{:movie {:movie/id "58611129-2dbc-4a81-a72f-77ddfc1b1b49"
:movie/title "My Neighbour Totoro"
;;...
:movie/people [{:person/id "986faac6-67e3-4fb8-a9ee-bad077c2e7fe"
:person/name "Satsuki Kusakabe"
:person/age "11"}
;;...
]}})
If you don't care about consuming the messages at your earliest convenience,
pharmacist.prescription/collect
can collect all of them for you:
(-> prescription
(prescription/fill
{:params {:movie-id "58611129-2dbc-4a81-a72f-77ddfc1b1b49"}})
(prescription/select [:people])
(prescription/collect))
collect
returns a channel that emits a single message, where ::result/data
is the result of passing all the messages through merge-results
.
Pharmacist uses core.async for flow
control, and as such you can get results synchronously using <!!
- on the JVM.
ClojureScript consumption must always be asynchronous.
In either case, you can pass the channel returned from select
through
collect
to collect every message into a channel that emits them all in one go.
The map contained in this message contains:
:pharmacist.result/success?
A boolean indicating whether the entire data set was successfully loaded or not.
:pharmacist.result/data
A combined map of all successful results, like the one produced by
merge-results
.
:pharmacist.result/sources
A list of all the sources in the prescription. Each entry in this list is a map
of :path
, :source
(the initial prescription), and :result
(the
final result). In the event of an overall failure, the :result
maps can tell
you where things failed.
In the following example, :auth
failed, so :playlist
(which requires the
:auth
token) is not only considered a failure - it wasn't even attempted:
{::result/success? false
::result/data {:prefs {:dark-mode? true}}
::result/sources [{:path :prefs
:source {::data-source/id :user/prefs}
:result {::result/success? true
::result/data {:dark-mode? true}
::result/attempts 1}}
{:path :auth
:source {::data-source/id :spotify/auth
::data-source/params {:spotify-api-user "user"
:spotify-api-password "pass"}}
:result {::result/success? false
::result/data {:error "Failed to authenticate user"}
::result/attempts 3}}
{:path :playlists
:source {::data-source/id :spotify/playlists
::data-source/params {:token ^::data-source/dep [:auth :access_token]}}
:result {::result/success? false
::result/attempts 0}}]}
Because HTTP data sources are very common, Pharmacist provides a convenience function for them, based on clj-http (Clojure) and cljs-http (ClojureScript)- both expected to be provided by your application.
(require '[pharmacist.data-source :as data-source]
'[pharmacist.http :as http])
(defn spotify-playlist [{::data-source/keys [params]}]
(http/http-data-source {:method :get
:url "https://api.spotify.com/playlists"
:oauth-token (:token params)}))
API docs will be available on cljdoc.org eventually.
:pharmacist.data-source/fn
The function that fetches data. Should return either a
pharmacist.result/success
or a pharmacist.result/failure
.
:pharmacist.data-source/fn-async
The function that fetches data, asynchronously. Only used if
:pharmacist.data-source/fn
is not provided. Should return a core async channel
that emits a single message which is either a pharmacist.result/success
or a
pharmacist.result/failure
.
:pharmacist.data-source/id
ID that addresses a data source. If not explicitly provided, it is inferred from
:pharmacist.data-source/fn
or :pharmacist.data-source/async-fn
.
:pharmacist.data-source/params
Parameters passed from a prescription to a data source. Can be or include dependencies on initial parameters and other data sources.
:pharmacist.data-source/retries
How many times a data source should be retried in case of failure. Defaults to
0
. If :pharmacist.result/retryable?
is set to false
, this value is
ignored.
:pharmacist.data-source/dep
This keyword is set as meta data on parameters in
:pharmacist.data-source/params
to mark them as dependencies on other data
sources.
:pharmacist.result/success?
Boolean, indicating whether or not the source was successfully loaded.
:pharmacist.result/data
The data loaded. If the result is not successful, this key might contain error information, failed HTTP requests etc.
:pharmacist.result/attempts
The number of attempts made to load this specific piece of data.
:pharmacist.result/retryable?
A boolean indicating whether or not this failure result is retryable.
So you're writing a single-page application. When the user visits a URL you want to fetch some data and render it. To play on the earlier example, we want to fetch a user's playlists from Spotify. To do so, we must first ensure that we have a user (e.g. someone is logged in), and then fetch playlists from the Spotify API.
We will use Pharmacist to trigger user logins in addition to fetch data. We will define one data source that simply looks up the user's token from the global app state, and another one which uses HTTP. If the global state doesn't have a token, or the HTTP endpoint fails, we'll have the user log in using an OAuth 2.0 flow.
(require '[pharmacist.data-source :as data-source]
'[pharmacist.result :as result]
'[pharmacist.http :as http])
(def store (atom {}))
(defn spotify-auth [prescription]
(if-let [token (:token @store)]
(result/success {:token token})
(result/failure)))
(defn spotify-playlist [prescription]
(http/http-data-source
{:method :get
:url "https://api.spotify.com/playlists"
:oauth-token (-> prescription ::data-source/params :token)}))
Here's the prescription:
(def prescription
{::auth {::data-source/fn #'spotify-auth}
::playlists {::data-source/fn #'spotify-playlists
::data-source/params {:token ^::data-source/dep [::auth :token]}}})
We can now fetch data from this. If any of the sources fail, we'll assume there was an authentication problem, and redirect the user to login. In reality you'd probably want to check this more rigorously.
(require '[pharmacist.prescription :as prescription]
'[pharmacist.result :as result]
'[cljs.core.async :as a])
(def scopes
["playlist-read-collaborative"
"playlist-modify-private"
"playlist-modify-public"
"playlist-read-private"])
(defn token-url [state]
(str "https://accounts.spotify.com/en/authorize"
"?scope=" (js/encodeURIComponent (str/join " " scopes))
"&client_id=" (:spotify-client-id state)
"&response_type=token"
"&state=" state
"&redirect_uri=" (js/encodeURIComponent (router/qualified-url :location/auth))))
(let [ch (-> prescription
prescription/fill
(prescription/select [::playlists]))]
(a/go-loop [res {}]
(when-let [msg (a/<! ch)]
(if-not (result/success? msg)
(let [nonce (rand-str 12)]
(swap! store assoc :auth/state nonce)
(set! js/window.location (token-url nonce)))
(recur (assoc res (::result/path msg) (::result/data msg)))))))
The let
block will return the channel from the go-loop
, which you can read
the full result set from if all goes well.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close