-
Notifications
You must be signed in to change notification settings - Fork 15
Basics
Kekkonen offers a multimethod-like dispatch for tagged Clojure functions called the handlers. Handlers are grouped into virtual namespaces, which are registered into a dispatcher. Clients create an (invocation) context and invoke the handler via dispatcher with context and full path to handler, called action, as the dispatch value.
The examples below might use the following namespace aliases:
(require '[kekkonen.core :as k])
(require '[schema.core :as s])
(require '[plumbing.core :as p])
For full set of features, read the source and the tests.
Context is an Clojure Map, similar to the Ring request, containing all the needed data to invoke the handlers.
- Keyword keys should be used - to get better support from Schema & Plumbing libraries
- Client input should placed under
:data
Dispatcher will add two special entries to the context for the handler to use:
-
:kekkonen.core/dispatcher
, holding the current dispatcher instance -
:kekkonen.core/handler
holding the meta-data of the current handler instance
these can be used for context forwarding, creating external api-docs etc.
Handler is a Clojure function or a Var. Handlers are be 1-Arity, consuming the context map and can return anything. Handlers are registered to a dispatcher, which requires the handler to be identified with both name and type.
- Handler name is resolved from the handler meta-data
:name
(a keyword or a symbol) - Type resolution is defined by the dispatcher
The default dispatcher allows only handlers of type :handler
to be registered. Type is read either from the :type
or :handler
meta-data.
Dispatchers also read the following meta-data from handlers:
-
:input
allowed input schema for the context -
:output
allowed output schema from the handler -
:description
description of the handler
Vanilla Clojure:
(with-meta
(fn [context]
(str "hello " context))
{:name :my-handler
:type :handler})
Using kekkonen.core/handler
helper:
(k/handler
{:name :my-handler}
(fn [context]
(str "hello " context)))
With input, output and description defined:
(k/handler
{:name :fn-plus
:description "a description"
:input {:data {:x s/Int, :y s/Int}}
:output {:result s/Int}}
(fn [{{:keys [x y]} :data}]
{:result (+ x y)}))
When using Vars, meta-data should be set to Var instead of the Function. :name
is set automatically by the Clojure
compiler. Doc-string will be mapped to :description
.
(defn ^:handler my-handler [context]
(str "hello " context))
With input, output and description defined:
(defn ^:handler defn-plus
"a description"
{:input {:data {:x s/Int, :y s/Int}}
:output {:result s/Int}}
[{{:keys [x y]} :data}]
{:result (+ x y)})
You can also use Plumbings Schema-aware concise destructuring.
(k/handler
{:name :my-handler}
(p/fnk [:as context]
(str "hello " context)))
With fnk-input, output and description defined:
(k/handler
{:name :fnk-plus
:description "a description"
:output {:result s/Int}}
(p/fnk [[:data x :- s/Int, y :- s/Int]]
{:result (+ x y)}))
With fnk-input, fnk-output and description defined (note: anonymous function requires a name here, a Plumbing feature):
(k/handler
{:name :fnk-plus
:description "a description"}
(p/fnk f :- {:result s/Int} [[:data x :- s/Int, y :- s/Int]]
{:result (+ x y)}))
Defnk provides most terse syntax for handlers:
(p/defnk ^:handler my-handler [:as context]
(str "hello " context))
With fnk-input, fnk-output and description defined:
(p/defnk ^:handler defnk-plus :- {:result s/Int}
"a description"
[[:data x :- s/Int, y :- s/Int]]
{:result (+ x y)})
(Virtual) namespaces are used for organizing the handlers. Normal clojure namespaces are not used, as they would create a tight coupling between the client and the actual handler implementation and thus would make refactoring harder. Also, virtual namespaces allow the handlers to reside in multiple namespace branches.
Virtual namespaces are defined by the kekkonen.core/Namespace
record, which has a keyword name and extra meta-data
map on it. The whole namespace tree is a nested map with Namespace
s as keys and either handlers or Namespaces as
values. Namespaces can be created with kekkonen.core/namespace
fn.
; a simple namespace
(def api-ns (k/namespace {:name :api}))
; namespace with custom meta-data (just for admins)
(def admin-ns (k/namespace {:name :admin ::roles #{:admin}}))
(def reset-db!
(k/handler
{:name :reset-db!}
(fn [context] "resetted db!")))
; a namespace tree
(def handlers {api-ns {admin-ns reset-db!}})
The fully qualified name of the handler instance, including the (virtual) namespace part and the handler name. Used in the handler dispatch.
From the previous Namespaces-example, there would be one action :api.admin/reset-db!
.
To make things easy, Dispatcher uses kekkonen.core/Collector
protocol to process the namespace tree. The following
extension are available to the protocol:
-
AFunction
collects a anonymous function as a handler -
Var
collects a defn(k) handler -
Symbol
collects all public defn(k) handlers from a namespace -
Keyword
creates aNamespace
out of the given value with empty meta-data -
PersistentVector
collects every (handler) on the vec -
IPersistentMap
collects keys as namespaces and values as what they represent
; namespace with custom meta-data (just for admins)
(def admin-ns (k/namespace {:name :admin ::roles #{:admin)
(def handlers
{:api
{:public
{:boss #'boss-move ; just one handler Var
:user [#'add-user! #'remove-user! #'get-user] ; collect multiple handlers Vars
:math 'my-app.handlers.math ; a namespace scan for handler Vars
:common (k/handler
{:name :ping)
(fn [_] (ok {:ping "pong") ; inlined handler function
admin-ns [#'add-user! reset-db! (k/handler {:name :identity} identity)]}}) ; mixing things
which would create the following fully-qualified actions (mapped to handlers):
:api.public.boss/boss-move
:api.public.user/add-user!
:api.public.user/remove-user!
:api.public.user/get-user
:api.public.math/...
:api.public.common/ping
:admin/... ; all with extra meta {::roles #{:admin}}
Dispatcher is registry holding the handlers and responsible for the handler dispatch. It's created with
kekkonen.core/dispatcher
function, returning a kekkonen.core/Dispatcher
record. Dispatcher can be configured
with the following options:
-
:handlers
a map containing the namespace-tree of handlers -
:context
(optional) initial state for the dispatcher, request contexts are deep-merged with this -
:type-resolver
(optional) a function to verify if the proposed handler is accepted -
:transformers
(optional) request context transformers (function ofcontext => context
) -
:coercion
->:input
,:output
(optional) coercion matchers for both input & output -
:user
(optional) a map of all valid extra meta-data keys and functions (value => context => context
) how to process them
Both the schema and default for the options are found in kekkonen.core namespace.
Clients can interact with the individual handlers via the dispatcher using the following public functions in
kekkonen.core
. All take three parameters: dispatcher, action and the context.
-
check
to check whether the handler is available - runs all rules from the namespaces & handler. Returns eithernil
(success) or fail with an exception. -
validate
to run both check and to validates context against the accumulated handler input-schema. Does not execute the actual handler body. -
invoke
to run both the check & validate and to actually run the handler body & the response validation.
Clients can also use the following dispatcher browsing functions:
-
some-handler
returns details of a given handler -
all-handlers
returns a details of all registered handlers. -
available-handlers
returns a details of all handlers available (run withcheck
) with the given context, optionally under a given namespace. -
dispatch-handlers
mass-evaluate handler rules either bycheck
orvalidate
, with the given context, optionally under a given namespace.
(def dispatcher
(k/dispatcher
{:handlers {:api (k/handler {:name :hello} (constantly "hello world"))}}))
(k/invoke dispatcher :api/hello)
; => "hello world"
; define handlers
(p/defnk ^:command inc! [counter] (swap! counter inc))
(p/defnk ^:query plus [[:data x :- s/Int, y :- s/Int]] (+ x y))
(p/defnk ^:command reset-counter! [counter] (reset! counter 0))
; define custom namespaces
(def admin-ns (k/namespace {::admin true :name :admin}))
; define ::admin resolver
(defn admin-access-resolver [_]
(fn [ctx] (and (-> ctx ::admin) ctx)))
; the dispatcher
(def dispatcher
(k/dispatcher
{:handlers {:api {:math [#'inc! #'plus]
admin-ns #'reset-counter!}}
:type-resolver (k/type-resolver :command :query)
:context {:counter (atom 0)}}))
; invoking handlers
(k/invoke dispatcher :api.math/plus {:data {:x 1 :y 2}}) ; => 3
(k/invoke dispatcher :api.math/inc!) ;=> 1
(k/invoke dispatcher :api.math/inc!) ;=> 2
(k/invoke dispatcher :api.math/inc!) ;=> 3
(k/invoke dispatcher :api.admin/reset-counter!) ; => ExceptionInfo Invalid action
(k/invoke dispatcher :api.admin/reset-counter! {::admin true}) ; => 0
; checking handlers
(k/check dispatcher :invalid) ; => ExceptionInfo Invalid action
(k/check dispatcher :api.math/inc!) ;=> nil
(k/check dispatcher :api.admin/reset-counter!) ; => ExceptionInfo Invalid action
(k/check dispatcher :api.admin/reset-counter! {::admin true}) ; => nil
; validating handlers
(k/validate dispatcher :api.math/plus {:data {:x "1"}})
; => ExceptionInfo Coercion error
; {:type :kekkonen.core/request
; :in nil
; :value {:data {:x "1"}}
; :schema {:data {:y s/Int, :x s/Int, s/Keyword s/Any}, s/Keyword s/Any}
; :error {:data {:y 'missing-required-key, :x '(not (integer? "1"))}}}
(k/validate dispatcher :api.math/plus {:data {:x 1, :y 2}}) ; => nil
; TODO: browsing the handlers
; k/some-handler
; k/all-handlers
; k/available-handlers
; k/dispatch-handlers
Next up, Apis