Fern is an alternative way to write Vase descriptors. It does basically the same things as EDN, but with a couple of changes to make it easier for humans to write the descriptors.
First, it "flattens out" the deeply nested structure from the EDN descriptors. Instead of nesting, Fern lets you incorporate data by reference. That lets you remove some duplication, but more importantly it means you don't have to keep track of where you are in the descriptor nearly as much.
Second, the EDN files have a mix of "magic" keys (:vase/norms,
:vase/apis, :vase/specs) and user-defined keys (for API names,
Spec names, etc.) Fern changes that model quite a bit. There's only
one reserved key name: vase/service. After that you get to define
all the keys.
So what does a Fern version of a Vase descriptor look like? Here's a "Hello, World" sample:
{vase/service (fern/lit vase/service
{:apis [@hello]
:service-map @http-options})
http-options {:io.pedestal.http/port 8080}
hello (fern/lit vase/api
{:path "/example/v1"
:routes @hello-routes})
hello-routes #{["/hello" :get @hello-response]}
hello-response (fern/lit vase/respond
{:body "Hello world"})
}
Vase uses this by "pulling on" the vase/service key. You can
read more about how
Fern evaluation works. The short version is that fern/lit is kind of
like a Clojure reader literal, but Fern decides when to dereference
symbols so we can enhance the environment and produce nicer errors
when something goes wrong.
The vase/service literal has one argument, which is a map with
:apis and :service-map. See how the :apis key has [@hello] as
a value? When Fern evaluates that literal, it treats @hello as a
reference to the value of hello at the top level. So Fern follows
that reference to evaluate the vase/api literal. Evaluation follows
through to @hello-routes and eventually reaches the vase/respond
literal.
If you take a closer look at http-options and hello-routes, you
might notice that these are just ordinary Clojure data
structures. That's one of the nice things about Fern.
Fern lets us define new literals by adding defmethods to any
namespace. Vase provides a set of Fern literals out of the box. All
the normal Vase reader literals have equivalent Fern forms. But Vase
also adds some new ones like the vase/service in the "Hello World"
sample.
| Literal | Purpose |
|---|---|
| vase/plugins | Namespaces to load before evaluation. |
| vase/service | Define a service. |
| vase/api | Define an API. |
| vase/respond | Define an interceptor with the respond action. |
| vase/redirect | Define an interceptor with the redirect action. |
| vase/validate | Define an interceptor with the validate action. |
| vase/conform | Define an interceptor with the conform action. |
| vase.datomic/connection | Connect to a Datomic database and act as an interceptor to attach the connection to a request. |
| vase.datomic/query | Define an interceptor with the Datomic query action. |
| vase.datomic/transact | Define an interceptor with the Datomic transaction action. |
| vase.datomic/attributes | Define an interceptor that creates Datomic schema from short vector form. |
| vase.datomic/tx | Define an interceptor that executes an arbitrary Datomic transaction. |
| vase.datomic.cloud/connect | Connect to a Datomic Cloud database and act as an interceptor to attach the connection to a request. |
| vase.datomic.cloud/query | Define an interceptor to query a Datomic Cloud database. |
| vase.datomic.cloud/transact | Define an interceptor to transact request bodies as Datomic Cloud entities. |
| vase.datomic.cloud/attributes | Defing an interceptor that creates Datomic Cloud schema from short vector form. |
| vase.datomic.cloud/tx | Define an interceptor that executes an arbitrary Datomic Cloud transaction. |
:on-request and :on-startupWith the EDN format, Vase forces certain things to happen on your behalf. When it starts up, it looks for schema definitions and loads those into Datomic. It also puts an interceptor into every route that will attach a Datomic connection to the request map.
When you use Fern, that magic is under your control. A vase/api has
two keys that let you define what it needs to do at startup time and
what needs to happen on every request. These are cleverly called
:on-startup and :on-request. The value for each of them should be
a vector of interceptors.
Here is an example of an API that uses Datomic:
example/v1 (fern/lit vase/api
{:on-startup [@connection @base-attributes @user-attributes @loan-attributes @sample-users]
:on-request [@connection]
:routes @v1/routes})
connection (fern/lit vase.datomic/connection @datomic-uri)
datomic-uri "datomic:mem://example"
You can find the whole file in the source repo under test/resources/test_descriptor.fern
Notice that :on-startup has a reference to connection, which is a
vase.datomic/connection. At startup time, that literal serves to
connect to (and optionally create) a database.
On request, the connection attaches the connection and current
database value to the request, for use by later interceptors. All the
:on-request interceptors get run before Vase dispatches to a route.
Notice how the :on-startup from our last example vector has some
other references in it? Those are how we transact schema. Instead of
using :vase/norms as a top-level key, we use
interceptors. (Interceptors everywhere!) A couple of these look like:
base-attributes (fern/lit vase.datomic/tx
{:db/ident :company/name
:db/unique :db.unique/value
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one})
user-attributes (fern/lit vase.datomic/attributes
[:user/userId :one :long :identity "A Users unique identifier"]
[:user/userEmail :one :string :unique "The users email"]
[:user/userBio :one :string :fulltext "A short blurb about the user"])
There are two different things going on here. vase.datomic/tx says
"transact this data." It's arguments are exactly the kind of entities
and datoms that you can read about in the
Datomic docs.
The second literal is vase.datomic/attributes. It uses a kind of
shorthand notation for building schema. This is the same vector format
that the old #vase/schema-tx reader literal used. The format is this:
| Field | Allowed values | Maps to |
|---|---|---|
| Attribute name | Any keyword | :db/ident |
| Cardinality | :one or :many | :db/cardinality |
| Value type | Datomic value types | :db/valueType |
| Flags | Any of :index, :identity, :unique, :component, :fulltext, :no-history | :db/index true, :db.unique/identity, :db.unique/value, :db/isComponent true, :db/fulltext true, :db/no-history true |
| Docstring | String | :db/doc |
Multiple flags are allowed. Add as many as you like before the mandatory docstring.
Both vase.datomic/attributes and vase.datomic/tx have exact matches in vase.datomic.cloud/attributes and vase.datomic.cloud/tx. The cloud versions work exactly the same way as the On-Prem versions.
One of our goals with the Fern format was to make Vase easier to
extend. Fern calls these
plugins. Any
namespaces in the vase/plugins key get loaded before Vase evaluates
the rest of the environment. If those namespaces define new
multimethods for
fern/literal,
then you can freely mix those with Vase's pre-defined literals.
Suppose you want to aggregate data from an existing API into a Vase app. There are just two steps to make it happen.
Your literal can return anything. But if you want to use it in an
:on-startup, :on-request, or route chain, then it should return an
interceptor.
(ns example
(:require [fern :as f]))
(defmethod f/literal 'example/api-call
[_ target-uri]
;; return an interceptor)
The first argument to the multimethod will just be the symbol you define. Since we've already dispatched on that symbol, it's not very interesting and we ignore it in the function body.
The remaining arguments are whatever you want them to be. A lot of our examples above use maps. Most of the built in literals that Vase provides take maps. But Fern does allow you to use any arguments you like.
vase/pluginsYour defmethod has to get evaluated before you create the
service. Vase can do this for you if you add the namespace name to the
vase/plugins key like this:
{vase/plugins [example]
vase/service (fern/lit vase/service ,,,)
}
One way to use Fern descriptors is to use the new Vase -main. Just
give Vase a path to your descriptor file when you start Clojure. This
is most useful when you don't want to create a whole project
structure. Just use vase.jar directly.
The other way to use it when you embed Vase in your project. In that
case, the new API functions are
com.cognitect.vase.fern/load-from-file and
com.cognitect.vase.fern/prepare-service. You can use them like this:
(when-let [prepared-service-map (try
(-> filename
(load-from-file)
(prepare-service))
(catch Throwable t
(fe/print-evaluation-exception t)
nil))]
(try
(a/start-service prepared-service-map)
(catch Throwable t
(fe/print-other-exception t filename))))
The nesting gets a little weird because we need to catch exceptions at
two different times. If an exception happens while evaluating the
vase/service, then we can use Fern's print-evaluation-exception
function, which prints a kind of trace of the chain of values leading
up to the error. On the other hand, if an exception happens while
starting the service, we want to call print-other-exception which
produces nicely readable output of ordinary exceptions.
There's a macro called try-> that makes this look a little nicer:
(try->
filename
load-from-file
(:! java.io.IOException ioe (fe/print-other-exception ioe filename))
prepare-service
(:! Throwable t (fe/print-evaluation-exception t))
api/start-service
(:! Throwable t (fe/print-other-exception t filename)))
It's purely syntactic sugar.
Of course, if you're already happy with your error handling, then just let the functions throw:
(->
filename
load-from-file
prepare-service
api/start-service)
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |