A DSL for programming user interaction flows.
Rapids simplifies coding complex user interaction flows for web scale applications. It is an alternative to using state machines. Each user experience is defined as a function-like object called a "flow". Within a flow you can freely intermix computational expressions with expressions which pause for arbitrarily long periods for user input without consuming computational resources (other than a small amount of state on the backend).
The value of this approach is that you can write user experiences just like functions. Rapids makes it easy to create user experiences with branching logic, loops, exception handling (see Interruptions), and higher order programming techniques. You can even coordinate parallel user experiences using channel-like objects called "pools". This approach produces fewer files, much less code and requires vastly less infrastructure. It also makes it easy to write unit tests for complex user experiences.
Rapids defines a new macro, deflow
, akin to Clojure's defn
, but which permits suspending execution until an external event is received. This is done with the <*
(aka input!
) operator. The system saves the state of the computation when the <*
operator is invoked to a persistent storage. An in-memory and Postgres implementation are provided, or you can roll your own.
The control API consists of Clojure functions, start!
, continue!
and interrupt!
. The start!
function takes a flow (and optional arguments) and creates a Run
object, which is conceptually equivalent to a thread or a process. The Run
executes until it encounters a <*
operator. The continue!
function takes a Run
and provides it with data which becomes the return value of the <*
operator which suspended the run.
The Run
contains the execution state of the program (the stack and dynamic variables) and metadata. It is one of the two objects stored in persistent storage. Run
metadata can be set during flow execution and used for indexing and retrieving runs or just to provide useful information about execution state.
Also see tests/rapids_test.clj
.
(deflow multiply-by-user-input [x]
(>* "Hi, please enter a number!") ; output a string - this will be available in the stored state
(let [user-num, (Integer/parseInt (<*)) ; wait for input, then parse the result to return an integer, and assign it to user-num.
result (* user-num x)]
(>* (str "Multiplying " x " by " user-num " gives " result))
As of 0.3.2, deflow
supports multi-arity signatures and pre/post conditions like defn
.
(let [run (start! multiply-by-user-input [5])]
(continue! (:id run) {:input "100"}) ; this would normally happen as the result of a separate web API call
(println (:result run))) ; prints 500
=>
(output! arg*) ; or (*> arg*)
Write an object to the :output
key.
The output!
operator is conceptually akin to writing to stdout, but the output is collected and returned as the :output
key of a run object returned by start!
or continue!
when a flow hits a input!
or completes execution.
output!
take an arbitrary number of objects which are appended to the current response vector. Note that the response vector is automatically cleared before a run is continued so each request only retrieves some of the output!
arguments in a flow.
Suspends execution of the run until a call to continue!
.
(input!) ; or (<*)
(input! :permit permit)
(input! :expires expiry-time, :default value)
(input! :permit permit, :expires expiry-time, :default value)
A call to input!
causes the run to be persisted to storage. Execution is resumed by calling continue!
and providing the run-id (available using (:id run)
, the permit value (which is nil by default) and input
value. When the run resumes, the (input!...)
form evaluates to the result
value.
When the expiry time is passed, execution resumes, with the input!
operator evaluates to the value of the default
argument, which is nil if not provided.
Suspends execution of the current run until the given run completes. Returns the value returned by the given run.
(wait-for! run)
(wait-for! run :expires expiry-time, :default value)
Sets one or more values in the current run's hierarchical index. Key value pairs are provided. Keys may be vectors, indicating a nested value.
(set-index! :foo 1, [:a :b] 2)
(current-run :index) ; => {:foo 1 {:a {:b 2}}}
;; start! creates a run, beginning execution of the given flow
(start! multiply-by-user-input [4])
...
;; the caller provides run-id, permit and input
;; resume the flow as follows:
(continue! run-id {:permit permit :input input})
;; the value provided to input is returned by (input!)
(get-run run-id)
The find-runs
API allows for queries on multiple fields and JSON subfields of a run.
;; query for runs which are running wheere a nested index key has a particular value
(find-runs [[:state :eq :running]
[[:index :runs :patient :initial-labs] :eq lab-run-id]]
{:limit 3})
Rapids works by saving the runtime state in non-volatile storage. This capability can be provided by implementing the protocols, in rapids.storage.protocol: Storage and StorageConnection. The library contains implementations of an in memory implementation (used for testing) and a Postgres-based implementation.
We use:
brew install postgresql
# To start postgresql for the first time:
brew services start postgresql
# To restart postgresql after an upgrade:
brew services restart postgresql
#Or, if you don't want/need a background service you can just run:
/usr/local/opt/postgresql/bin/postgres -D /usr/local/var/postgres
createdb rapids-test
createdb rapids_storage
(ns mynamespace
(:require [rapids :refer :all]
[rapids.implementations.postgres-storage :refer [->postgres-storage postgres-storage-migrate!]])
(set-storage! (->postgres-storage {:jdbcUrl "jdbc:postgresql://localhost:5432/rapids_storage`}))
(postgres-storage-migrate!) ; uses the top-level storage by default
- Install direnv
- Copy
.envrc.example
to.envrc
- Edit
.envrc
to set the environment variables - Run
direnv allow
to allow the environment variables to be set
If you're using IntelliJ, the EnvFile plugin can be helpful for getting variables into your REPL: https://plugins.jetbrains.com/plugin/7861-envfile
The IntelliJ project has shortcuts for running tests under the Tools Menu. First, start the Clojure nREPL, then choose one of the following:
Tools -> Run Tests in Current Namespace in REPL Tools -> Run Tests Under Caret in REPL Tools -> Commands -> Run All Tests!
The test suite includes some tests for the Postgres backend which only run conditionally. To run them:
- install postgres
with homebrew:
brew install postgresql
- start postgres
with homebrew:
brew services start postgresql
- create a test database:
createdb rapids-test
- include the following line in your .env file:
TEST_POSTGRES_URL=postgresql://localhost:5432/rapids-test
I've had some issues with running tests from the command line:
lein test
The rapids.test
namespace includes a couple of clojure.test
compatible macros (branch
and keys-match
) which make it easier to test branching flows. These are useful because the start!
and continue!
methods cause side effects on the run.
Here's an example of how to use them:
(deftest WelcomeTest
(branch [run (start! welcome)]
"welcome"
(keys-match run
:state :suspended
:output ["welcome. Do You want to continue?" _])
(branch [run (continue! (:next-id run) {:input "yes"})]
"wants to continue"
(keys-match run
:state :suspended
:output ["great!... let's continue"]))
(branch [run (continue! (:next-id run) {:input "no"})]
"doesn't want to continue"
(keys-match run
:state :complete))))
Creates nested test conditions.
(branch [...bindings] description & body)
A wrapper around is
and match
to make it easy to match patterns in maps:
(keys-match obj-to-match :key1 pattern1 :key2 pattern2 ...)
Besides the usual Clojure program errors, this package throws ExceptionInfo
objects with data containing a :type key, indicating the following
:runtime-error
- an error caught at runtime, usually indicating a programmer error. E.g., passing the wrong type of argument to a function.:system-error
- a severe error usually indicating a bug in the system or inconsistency of the stack:syntax-error
- problem while compiling a flow:input-error
- invalid input was provided to the system. A run does NOT move to :error state and the error is returned to the caller
There's currently a problem in using cloverage with deflow. It seems that cloverage instruments the keys in :partition-fns
, which are addresses, by associng new keys. This results in call-partition failing because the internal addresses differ from the requested addresses (which aren't instrumented).
The current solution is just ot exclude the file(s) which have deflow. As of time of writing, this is only rapids.language.cc. Obviously, we need a better solution for the future - maybe by implementing a custom class of Address which cannot be instrumented.
lein cloverage --lcov --exclude-call rapids.language.cc/make-current-continuation --ns-exclude-regex 'rapids\.language\.cc'
This library uses the s3-wagon-private plugin to deploy to and consume the artifact from a dev-precisely S3 bucket (precisely-maven-repo as time of writing).
To push a release:
-
Commit the code
-
Update the version in project.clj and tag the release
If you use
"0.2.0-SNAPSHOT"
in project.clj, dogit tag 0.2.0-SNAPSHOT
-
Push to github
git push --tags
-
Ensure AWS access is configured
You need to provide access key id & secret to push to the S3 bucket. You will need write access, obviously. Set environment variables. Recommended approach is to use direnv. Leiningen needs these variables to access resources (e.g., AWS credentials), but there is not a clean way of loading them using lein plugins.
brew install direnv cp .envrc.sample .envrc # edit .envrc to set the variables direnv allow
-
Deploy the library to S3
lein deploy precisely
During development, you may want to publish to a local repo instead of to S3. This can be done by publishing to your local Maven repo. This is typically at ~/.m2
. The dev profile has the lein-localrepo plugin installed.
First build your target:
lein build
- Commit new code to a branch under your name. E.g.,
aneil/my-new-feature
- Issue a PR requesting a merge into
dev
- Wait for automated tests to complete on Github
- Merge on Github (not locally)
- Merge dev into master locally
git co dev # alias for checkout
git pull
lein test # make sure all tests are passing!
git co master
git merge dev
- Bump version in project.clj
- Tag and push to master
git tag <<VERSION-NUMBER>> # eg., git tag 1.0.1
git po # alias for !git push -u origin `git branch --show-current`
git pt # alias for push --tags
- Publish repo
lein deploy precisely
Occassionally, tests will pass in the REPL, but fail in CI, or at the command-line with lein test
. In the instances I've seen so far, this occurs because of differences in the macroexpansion environment between lein test
and REPL environments inside test functions. I think this is because test functions are compiled by lein test
before being executed, whereas they are evaluated by the REPL. This leads to symbols remaining unqualified at execution time in the lein test
environment.
The solution is to use backtick and carefully unquote/quote the symbols which should remain unqualified.
(deftest Foo
(testing "works fine in REPL, but throws an error in lein test"
(eval '(deflow foo [] (<*))))
(testing "works in both REPL and lein test"
(eval `(deflow ~'foo [] (<*)))))