Skip to content

Instantly share code, notes, and snippets.

@ikitommi
Last active March 4, 2024 01:49
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ikitommi/fb3e0200504dd8b635ed7edd0cdbc768 to your computer and use it in GitHub Desktop.
Save ikitommi/fb3e0200504dd8b635ed7edd0cdbc768 to your computer and use it in GitHub Desktop.
Specs & Schema for Clojure web apps

Specs & Schema for Clojure web apps

Common ground for different modelling libs (Spec, Schema, ...) for web usage.

Additional metadata on models

  • for Schema, there are multiple ways to add extra metadata (at least in ring-swagger, schema-tools and in compojure-api)
  • emerging tools for spec
  • could have a common model
    • less syntax to learn
    • easy to transition in between

Schema

(require '[schema.core :as s])

;; a schema
s/Int

;; a schema record
(st/schema s/Int)

;; with extra meta-data
(st/schema 
  s/Int
  {:description "it's an int"})

;; alternative syntax
(st/schema
  {:schema s/Int
   :description "it's an int"})

Spec

(require '[spec-tools.core :as st])

;; a spec
int?

;; a spec record
(st/spec int?)

;; with extra meta-data
(st/spec 
  int? 
  {:description "it's an int"})

;; alternative syntax
(st/spec 
  {:spec int?
   :description "it's an int"})

;; with type hint
(st/spec
  {:spec int?
   :spec/type :int
   :description "it's an int"})

Runtime coercion

Same model for different libs. Common structure for errors (with :type identifier :schema or :spec).

Schema

  • has inbuilt selective runtime coercion via matchers for different wire-formats, e.g.
    • schema.coerce/json-coercion-matcher
    • schema.coerce/string-coercion-matcher
(require '[schema.core :as s])
(require '[schema.coerce :as sc])

(s/defschema User
  {:name s/Str
   :type (s/enum :grunt :boss)})

(def json->User (sc/coercer User sc/json-coercion-matcher))
(def ->User (sc/coercer User (constantly nil)))

(->User {:name "Tiina", :type :boss})
; {:name "Tiina", :type :boss}

(->User {:name "Tiina", :type "boss"})
; #schema.utils.ErrorContainer{:error {:type (not (#{:boss :grunt} "boss"))}}

(json->User {:name "Tiina", :type "boss"})
; {:name "Tiina", :type :boss}

Spec

  • has not, but spec-tools provides these, e.g.
    • spec-tools.core/json-conformers
    • spec-tools.core/string-conformers
(s/def ::name st/string?)
(s/def ::type (s/and st/keyword? #{:grunt :boss}))

(s/def ::user (s/keys :req-un [::name ::type]))

(def json->user #(st/conform ::user % st/json-conformers))
(def ->user #(st/conform ::user % (constantly nil)))

(->user {:name "Tiina", :type :boss})
; {:name "Tiina", :type :boss}

(->user {:name "Tiina", :type "boss"})
; :clojure.spec/invalid

(json->user {:name "Tiina", :type "boss"})
; {:name "Tiina", :type :boss}

Api-docs

Clojure routing libs should be able emit a openapi3-formatted data: routes, endpoints and operations. Web-libs can add more keys for their own use. Use the common-meta-data model here.

Schema

(require '[clj-open-api.schema :as open-api])

(require '[schema.tools.core :as st])
(require '[schema.core :as s])

(s/defeschema Person
  {::id  s/Int
   :name (st/schema s/Str {:description "Name of a user"})
   :age  (st/schema s/Int {:description "Age of a user"})})

(open-api/create-spec
  {:paths
   {"/echo-person"
    {:post
     {:summary "Echoes a person"
      :parameters {:body Person}
      :responses {200 {:schema Person}}}}}})

Spec

(require '[clj-open-api.spec :as open-api])

(require '[spec.tools.core :as st])
(require '[clojure.spec :as s])

(s/def ::id int?)
(s/def ::name (st/spec string? {:description "Name of a user"}))
(s/def ::age (st/spec int? {:description "Age of a user"}))

(s/def ::person (st/spec (s/keys :req [::id] ::req-un [::name ::age])))

(open-api/create-spec
  {:paths
   {"/echo-person"
    {:post
     {:summary "Echoes a person"
      :parameters {:body ::person}
      :responses {200 {:schema ::person}}}}}})

Middleware & Interceptors

  • same data models could be used for middleware (and interceptors) to describe their requirements for request & response.
  • Kekkonen (https://github.com/metosin/kekkonen#basic-building-blocks) does this already: the Interceptor input & output schemas are merged into the endpoint combined schema
  • Interceptors would look ~like this:
;; interceptor loading an user, requiring an `s/Int` path-parameter
(interceptor
  {:name ::load-user
   :input {:type :schema
           :request {:path-params {:id s/Int}}}
   :enter (fn [context]
            (let [db (-> context ::db)
                  user-id (-> context :request :path-params :id)
                  user (load-user db user-id)]
              (assoc context ::user user)))})
  • Middleware could use
    • meta-data in Macchiato
    • there could be new ring.middleware/Middleware executable record, less opaque.
;; async-middleware loading an user, requiring an `s/Int` path-parameter
(middleware
  {:name ::load-user
   :input {:type :schema
           :request {:path-params {:id s/Int}}}
   :handle (fn [handler]
             (fn [request respond raise]
               (let [db (-> request ::db)
                     user-id (-> request :path-params :id)
                     user (load-user db user-id)]
                 (handler (assoc request ::user user) respond raise))))})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment