Skip to content

A Clojure(Script) library, which helps to create explicit and understandable results to unify and simplify the data flow

License

Notifications You must be signed in to change notification settings

lazy-cat-io/tenet

Repository files navigation

license https://github.com/lazy-cat-io/tenet/releases clojars babashka,%20clojure,%20clojurescript just sultanov?style=flat&color=blue&label=%20supports

codecov build deploy

io.lazy-cat/tenet

A Clojure(Script) library, which helps to create explicit and understandable results to unify and simplify the data flow.

Rationale

Problem statement

Typically, when collaborating on a project, it is essential to establish beforehand the nature of the outcomes to be employed. Some individuals opt for maps, while others prefer vectors, and still others rely on monads such as Either, Maybe, and so on. It is not always evident when a function yields data without any accompanying context, such as nil, 42, and so forth.

What does nil mean?

It can mean:

  • No data

  • Something is done or not

  • Something went wrong

What does 42 mean:

  • User id?

  • Age?

Such responses make you think about the current implementation and take time to understand the current context.

Imagine that we have a function that contains some kind of business logic:

(defn create-user!
  [user]
  (cond
    (not (valid? user)) ??? ;; returns a response that the given data is not valid
    (exists? user) ??? ;; returns a response that the email is occupied
    :else
    (try
      (insert! user)
      ??? ;; returns a response that a new user has been created
      (catch SomeDbException _
        ??? ;; returns a response indicating that
            ;; there was a problem writing data to the database
        ))))

In this case, there are several possible responses that could occur:

  • The user’s data may not be valid

  • The email address may be occupied

  • An error may have occurred while writing the data to the database

  • Or, finally, a successful response may be returned, such as a user ID or data

And how can we add context?

There is a useful data type in Clojure - qualified (namespaced) keywords, which can be used to add some context to responses.

  • :user/incorrect, :user/exists

  • :user/created or :com.your-company.user/created

With this information, it is clear what happened - we have the context and the data. Most of the time, we don’t write code, we read it, and that’s very important.

We have added the context, but how should we use it? Should we use a key-value pair within a map, a vector, a monad, or metadata? And how should we decide which type of response should be classified as an error?

We used all the above methods in our practice, and it has always been something inconvenient.

What should be the structure of the map or vector?

Should we create custom object/type and use getters and setters? This adds problems in further use and looks like OOP. Should we Use metadata? Unfortunately, metadata cannot be added to some types of data. And what kind of response is considered an error?

Solution

This library helps to unify responses.

In short, all the responses are a vector [<kind> <any data> …​] similar to the hiccup syntax. E.g. [:com.your-company.user/created {:user/id 42}].

There are no requirements for the kind of response and the type of your data.

This library is very small. It is based on only 7 lines of code (2 protocols), and the default implementation is less than 80 lines (without comments and documentation).

Getting started

Add the following dependency in your project:

project.clj or build.boot
[io.lazy-cat/tenet "RELEASE"]
deps.edn or bb.edn
io.lazy-cat/tenet {:mvn/version "RELEASE"}

API

(ns example
  (:require
   [tenet.response :as r]
   [tenet.response.http :as http]))

;;;;
;; Defaults
;;;;

(r/error? nil) ;; => false
(r/error? 42) ;; => false
(r/error? ::error) ;; => false

;; By default, only keyword `:tenet.response/error`, `Throwable` and `js/Error` is considered an error.

;; keyword
(r/error? ::r/error) ;; => true
;; throwable
(r/error? (ex-info "boom!" {})) ;; => true
;; vector using the hiccup syntax
(r/error? [::r/error "Something went wrong"]) ;; => true

;;;;
;; Custom errors
;;;;

(r/error? :example/error) ;; => false

;; Add a custom error kind to the error registry
(r/derive :example/error) ;; => :example/error
(r/error? :example/error) ;; => true

;; Remove a custom error kind from the error registry
(r/underive :example/error) ;; => :example/error

;;;;
;; Responses
;;;;

(declare valid? explain exists? insert!)

;; In this example, we do not require our library, as we can construct the responses without helpers

(defn create-user!
  [user]
  (cond
    (not (valid? user)) [:user/invalid (explain user)] ;; returns a response that the given data is not valid
    (exists? user) [:user/exists user] ;; returns a response that the email is occupied
    :else
    (try
      (let [profile (insert! user)]
        [:user/created profile]) ;; returns a response that a new user has been created
      (catch Exception e
        [:user/not-created e] ;; returns a response indicating that there was a problem writing data to the database
        ))))

;; But we have to register our error kinds

(r/derive :user/invalid) ;; => :user/invalid
(r/derive :user/exists) ;; => :user/exists
(r/derive :user/not-created) ;; => :user/not-created

(r/error? [:user/exists {:user/id 42}]) ;; => true
(r/kind [:user/exists {:user/id 42}]) ;; => :user/exists

;; If necessary, you can change the kind of error to make the correct context
(->> [:db/conflict {:user/id 42}]
     (r/as :user/exists)) ;; => [:user/exists {:user/id 42}]

;;;;
;; Http responses
;;;;

;; With a unified approach to response management, we can easily add mappings to HTTP responses

(http/status 42) ;; => 200
(http/status [:user/exists {:user/id 42}]) ;; => 200

;; By default,
;;   - all unknown non-error response kinds have the status - 200 OK
;;   - all error response kinds have the status - 500 Internal Server Error

;; But we have to add our custom mappings
(http/derive :user/exists ::http/conflict) ;; => :user/exists
(http/status [:user/exists {:user/id 42}]) ;; => 409

;; Namespace `tenet.response.http` contains `wrap-status-middleware' - perhaps this middleware will be useful for you

Performance

See the performance tests.

About

A Clojure(Script) library, which helps to create explicit and understandable results to unify and simplify the data flow

Topics

Resources

License

Stars

Watchers

Forks