Almost all components that you need on runtime should be reachable via the passed around state. To achieve this it should be part of the :deps map in the state. Any other configuration what you need in runtime should be part of this map too.
The system configuration and start-up with the chainable set-up:
(defn ->system
[app-cfg]
(-> (config/config)
(merge app-cfg)
(rename-key :xiana/auth :auth)
(rename-key :xiana/uploads :uploads)
routes/reset
session/init-backend
sse/init
db/start
db/migrate!
(scheduler/start actions/ping 10000)
(scheduler/start actions/execute-scheduled-actions (* 60 1000))
ws/start
closeable-map))
(defn app-cfg
[config]
{:routes routes
:router-interceptors [(spa-index/wrap-default-spa-index "/re-frame")]
:controller-interceptors (concat [(xiana-interceptors/muuntaja)
cookies/interceptor
xiana-interceptors/params
(session/protected-interceptor "/api" "/login")
xiana-interceptors/view
xiana-interceptors/side-effect
db/db-access]
(:controller-interceptors config))})
(defn -main
[& _args]
(->system (app-cfg {})))
In migratus library there is an Achilles point:
It has no option to define separate migrations by profiles. Xiana decorates Migratus, to handle this weakness.
You can run lein migrate
with migratus parameters like: create
, destroy
, up
, down
, init
, reset
, migrate
, rollback
. It will do the same as migratus, except one more thing: you can use with profile
lein parameter to
define settings migratus should use. So instead of having only one migration folder you can define one for each of your
profiles.
lein with-profile +test migrate create default-users
Will create up
and down
SQL files in folder configured in config/test/config.edn
, and
lein with-profile +test migrate migrate
will use it.
But without profile:
lein migrate migrate
migratus will use the migrations from a folder, what is configured in config/dev/config.edn
.
With extending migration configuration with seeds-dir
and seeds-table-name
you can use
lein seed create
lein seed migrate
lein seed reset
lein seed destroy
commands. Every defined profile can have a different seeds directory to have different dataset for different environments. If you're using this method to seed your data, keep your eye on the database structure is already updated when the seeding is happens.
Example for configuration:
:xiana/migration {:store :database
:migration-dir "migrations"
:seeds-dir "dev_seeds"
:migration-table-name "migrations"
:seeds-table-name "seeds"}
Example of using it from application start
(-> (config/config app-cfg)
...
db/connect
db/migrate!
seed/seed!
...)
Typical use-case, and ordering looks like this:
{:router-interceptors [app/route-override?]
:controller-interceptors [(interceptors/muuntaja)
interceptors/params
session/interceptor
interceptors/view
interceptors/db-access
rbac/interceptor]}
Which means:
An interceptor is a map of three functions.
:enter Runs while we are going down from the request to it's action, in the order of executors
:leave Runs while we're going up from the action to the response.
:error Executed when any error thrown while executing the two other functions
The provided function should have one parameter, the application state, and should return the state.
{:enter (fn [state]
(println "Enter: " state)
(-> state
(transform-somehow)
(or-do-side-effects))
:leave (fn [state]
(println "Leave: " state)
state)
:error (fn [state]
(println "Error: " state)
;; Here `state` should have previously thrown exception
;; stored in `:error` key.
;; you can do something useful with it (e.g. log it)
;; and/or handle it by `dissoc`ing from the state.
;; In that case remaining `leave` interceptors will be executed.
(assoc state :response {:status 500 :body "Error occurred while printing out state"}))}
The router and controller interceptors are executed in the exact same order (enter functions in order, leave
functions in reversed order), but not in the same place of the execution flow.
The handler function executes interceptors in this order
In router interceptors, you are able to interfere with the routing mechanism. Controller interceptors can be interfered with via route definition.
The router and controller interceptors definition is part of the application startup. The system's dependency map should contain two sequence of interceptors like
{:router-interceptors [...]
:controller-interceptors [...]}
On route definition you can interfere with the default controller interceptors. With the route definition you are able to set up different controller interceptors other than the ones already defined with the app. There are three ways to do it:
... {:action #(do something)
:interceptors [...]}
will override all controller interceptors
... {:action #(do something)
:interceptors {:around [...]}}
will extend the defaults around
... {:action #(do something)
:interceptors {:inside [...]}}
will extend the defaults inside
... {:action #(do something)
:interceptors {:inside [...]
:around [...]}}
will extend the defaults inside and around
... {:action #(do something)
:interceptors {:except [...]}}
will skip the excepted interceptors from defaults
The execution flow will look like this
All interceptors in :except will be skipped.
Route definition is done via reitit's routing library. Route processing is done
with xiana.route
namespace. At route definition you can define.
If any extra parameter is provided here, it's injected into
(-> state :request-data :match)
in routing step.
The action function in a single CRUD application is for defining a view, a database-query and optionally a side-effect function which will be executed in the following interceptor steps.
(defn action
[state]
(assoc state :view view/success
:side-effect behaviour/update-sessions-and-db!
:query model/fetch-query))
The database.core
's interceptor extracts the datasource from the provided state parameter and the :query.
The query should be in honey SQL format, it will be sql-formatted on execution:
(defn fetch-query
[state]
(let [login (-> state :request :body-params :login)]
(-> (select :*)
(from :users)
(where [:and
:is_active
[:or
[:= :email login]
[:= :username login]]]))))
The execution always has {:return-keys true}
parameter and the result goes into
(-> state :response-data :db-data)
without any transformation.
A view is a function to prepare the final response and saving it into the state based on whatever happened before.
(defn success
[state]
(let [{:users/keys [id]} (-> state :response-data :db-data first)]
(assoc state :response {:status 200
:headers {"Content-type" "Application/json"}
:body {:view-type "login"
:data {:login "succeed"
:user-id id}}})))
Conventionally, side-effects interceptor is placed after action and database-access, just right before view. At this point, we already have the result of database execution, so we are able to do some extra refinements, like sending notifications, updating the application state, filtering or mapping the result and so on.
Adding to the previous examples:
(defn update-sessions-and-db!
"Creates and adds a new session to the server's store for the user that wants to sign-in.
Avoids duplication by firstly removing the session that is related to this user (if it exists).
After the session addition, it updates the user's last-login value in the database."
[state]
(if (valid-credentials? state)
(let [new-session-id (str (UUID/randomUUID))
session-backend (-> state :deps :session-backend)
{:users/keys [id] :as user} (-> state :response-data :db-data first)]
(remove-from-session-store! session-backend id)
(xiana-sessions/add! session-backend new-session-id user)
(update-user-last-login! state id)
(assoc-in state [:response :headers "Session-id"] new-session-id))
(throw (ex-info "Missing session data"
{:xiana/response
{:status 401
:body "You don't have rights to do this"}}))))
Session interceptor interchanges session data between the session-backend and the app state.
On :enter
it loads the session by its session-id, into (-> state :session-data)
The session-id can be provided either in headers, cookies, or as query-param. When session-id is found nowhere or is an invalid UUID, or the session is not stored in the storage, then the response will be:
{:status 401
:body "Invalid or missing session"}
On the :leave
branch, updates session storage with the data from (-> state :session-data)
To get the benefits of tiny RBAC library you need to provide the resource and the action for your endpoint in router definition:
[["/api"
["/image" {:delete {:action delete-action
:permission :image/delete}}]]]
and add your role-set into your app's dependencies:
(defn ->system
[app-cfg]
(-> (config/config)
(merge app-cfg)
xiana.rbac/init
ws/start))
On :enter
, the interceptor performs the permission check. It determines if the action allowed for the user found
in (-> state :session-data :user)
. If access to the resource/action isn't permitted, then the response is:
{:status 403
:body "Forbidden"}
If a permission is found, then it goes into (-> state :request-data :user-permissions)
as a parameter for data
ownership processing.
On :leave
, executes the restriction function found in (-> state :request-data :restriction-fn)
. The restriction-fn
should look like this:
(defn restriction-fn
[state]
(let [user-permissions (get-in state [:request-data :user-permissions])]
(cond
(user-permissions :image/all) state
(user-permissions :image/own) (let [session-id (get-in state [:request :headers "session-id"])
session-backend (-> state :deps :session-backend)
user-id (:users/id (session/fetch session-backend session-id))]
(update state :query sql/merge-where [:= :owner.id user-id])))))
The rbac interceptor must be placed between the action and the db-access interceptors in the interceptor chain.
To use an endpoint to serve a WebSockets connection, you can define it on route-definition alongside the restfull action:
(def routes
[[...]
["/ws" {:ws-action websocket/echo
:action restfull/hello}]])
In :ws-action
function you can provide the reactive functions in (-> state :response-data :channel)
(:require
...
[xiana.websockets :refer [router string->]]
...)
(defonce channels (atom {}))
(def routing
(partial router routes string->))
(defn chat-action
[state]
(assoc-in state [:response-data :channel]
{:on-receive (fn [ch msg]
(routing (update state :request-data
merge {:ch ch
:income-msg msg
:fallback views/fallback
:channels channels})))
:on-open (fn [ch]
(routing (update state :request-data
merge {:ch ch
:channels channels
:income-msg "/welcome"})))
:on-ping (fn [ch data])
:on-close (fn [ch status] (swap! channels dissoc ch))
:init (fn [ch])}))
The creation of the actual channel happens in Xiana's handler. All provided reactive functions have the entire state to work with.
xiana.websockets
offers a router function, which supports Xiana concepts. You can define a reitit route and use it
inside WebSockets reactive functions. With Xiana state
and support of interceptors, with interceptor override. You
can define a fallback function, to handle missing actions.
(def routes
(r/router [["/login" {:action behave/login
:interceptors {:inside [interceptors/side-effect
interceptors/db-access]}
:hide true}]] ;; xiana.websockets/router will not log the message
{:data {:default-interceptors [(interceptors/message "Incoming message...")]}}))
For route matching Xiana provides a couple of modes:
extract from string
The first word of given message as actionable symbol
from JSON
The given message parsed as JSON, and :action
is the actionable symbol
from EDN
The given message parsed as EDN, and :action
is the actionable symbol
Probe
It tries to decode the message as JSON, then as EDN, then as string.
You can also define your own matching, and use it as a parameter to xiana.websockets/router
Xiana contains a simple SSE solution over http-kit server's Channel
protocol.
Initialization is done by calling xiana.sse/init
. Clients can subscribe by routing to xiana.sse/sse-action
. Messages
are sent with xiana.sse/put!
function.
(ns app.core
(:require
[xiana.config :as config]
[xiana.sse :as sse]
[xiana.route :as route]
[xiana.webserver :as ws]))
(def routes
[["/sse" {:action sse/sse-action}]
["/broadcast" {:action (fn [state]
(sse/put! state {:message "This is not a drill!"})
state)}]])
(defn ->system
[app-cfg]
(-> (config/config)
(merge app-cfg)
sse/init
ws/start))
(def app-cfg
{:routes routes})
(defn -main
[& _args]
(->system app-cfg))
To repeatedly execute a function, you can use the xiana.scheduler/start
function. Below is an implementation of SSE
ping:
(ns app.core
(:require
[xiana.scheduler :as scheduler]
[clojure.core.async :as async]))
(defn ping [deps]
(let [channel (get-in deps [:events-channel :channel])]
(async/>!! channel {:type :ping
:id (str (UUID/randomUUID))
:timestamp (.getTime (Date.))})))
(defn ->system
[app-cfg]
(-> (config/config)
(merge app-cfg)
...
sse/init
(scheduler/start ping 10000)
...))
Can you improve this documentation? These fine people already did:
Krisztian Gulyas, Stas Makarov, g-krisztian & Marius A. RabenarivoEdit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close