Skip to content

xsc/ronda-schema

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ronda-schema

ronda-schema aims to offer HTTP request/response validation and coercion using prismatic/schema.

Usage

Leiningen (via Clojars)

Clojars Project

REPL

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

Middleware

A handler can be wrapped using per-method request/response schemas and ronda.schema/wrap-schema:

(def schema
 {:params            {:name s/Str, :id s/Int}
  :param-constraints {:name (s/pred seq 'name-not-empty?)}
  :responses         {200 {:body s/Str
                           :constraint {:body (s/pred seq 'body-not-empty?)}}}})

(def app
  ;; for testing purposes, we allow a key `:f` in the request
  ;; to optionally transform the response.
  (-> (fn [{:keys [params f] :or {f identity}}]
        (let [{:keys [name id]} params]
          (f {:status 200,
              :body (format "Hello, %s! (ID: %d)" name id)})))
      (r/wrap-schema {:get schema})))

The resulting function will perform a variety of operations.

Integration with ronda/routing

You can add wrap-schema as a route-metadata-activated middleware using ronda/routing, attaching schemas to endpoints using ronda.routing/enable-middlewares:

(require '[ronda.routing :as routing])

(def routes-with-schema
  (-> routes
      (routing/enable-middlewares
        :home {:schema {:get schema}})
      ...))

(def app
  (->> (routing/compile-endpoints {:home "/"})
       (routing/meta-middleware :schema #(wrap-schema % %3))
       (routing/wrap-routing routes-with-schema)))

Request Validation + Coercion

Method Validation

(app {:request-method :post})
;; => {:status 405,
;;     :headers {},
;;     :ronda/error {:error :method-not-allowed, ...}
;;     :body ":method-not-allowed\n{:request-method (not (#{:get} :post))}"}

If the :request-method does not match any of the allowed ones, a HTTP 405 (Method Not Allowed) response will be returned.

Request Format Validation

(app {:request-method :get, :query-params {:name "you"}})
;; => {:status 400,
;;     :headers {},
;;     :ronda/error {:error :request-validation-failed, ...},
;;     :body ":request-validation-failed\n{:params {:id missing-required-key}}"}

If :params, :headers, :query-string or :body don't match the respective schema, a HTTP 400 (Bad Request) response will be returned.

Request Constraint Validation

(app {:request-method :get :query-params {:name "", :id 0}})
;; => {:status 422,
;;     :headers {},
;;     :ronda/error {:error :request-constraint-failed, ...}
;;     :body ":request-constraint-failed\n{:params {:name (not (name-not-empty? \"\"))}}"

If the request does not match :constraint and :param-constraints of the respective schema, a HTTP 422 (Unprocessable) response will be returned.

Request Coercion

(app {:request-method :get, :query-params {:name "you", :id "1234"}})
;; => {:status 200,
;;     :headers {},
;;     :body "Hello, you! (ID: 1234)"}

:id was coerced from String to Long to match the s/Int schema.

Response Validation + Coercion

Status Validation

(app {:request-method :get,
      :query-params {:name "you", :id 1234}
      :f #(assoc % :status 201)})
;; => {:status 500,
;;     :headers {},
;;     :ronda/error {:error :status-not-allowed, ...},
;;     :body ":status-not-allowed\n{:status (not (#{200} 201))}"}

If :status does not match one of the allowed ones, a HTTP 500 response will be returned.

Response Format Validation

(app {:request-method :get,
      :query-params {:name "you", :id 1234},
      :f #(dissoc % :body)})
;; => {:status 500,
;;     :headers {},
;;     :ronda/error {:error :response-validation-failed, ...},
;;     :body ":response-validation-failed\n{:body missing-required-key}"}

If :headers or :body don't match the respective schemas, a HTTP 500 response will be returned.

Response Constraint Validation

(app {:request-method :get,
      :query-params {:name "you", :id 1234},
      :f #(assoc % :body "")})
;; => {:status 500,
;;     :headers {},
;;     :ronda/error {:error :response-constraint-failed, ...},
;;     :body ":response-constraint-failed\n{:body (not (body-not-empty? \"\"))}"}

If the response does not match the respective :constraint schema, a HTTP 500 response will be returned.

Request/Response Semantics

It is possible to model request/response semantics using the :semantics key of the response schema (see below). If validation fails, a HTTP 500 response will be returned.

Custom Error Handling

ronda.schema/validation-error can be used to access the error value in the response emitted by the wrap-schema middleware which will have the following format:

{:error :request-validation-failed,
 :error-form {:params {:id missing-required-key}},
 :schema {:params {Keyword Any,
                   :name java.lang.String,
                   :id Int},
          Any Any},
 :value {:params {:name "you"},
         :headers {},
         :query-params {:name "you"},
         :request-method :get}}

Possible error values (and default HTTP status codes):

  • :method-not-allowed (405)
  • :request-validation-failed (400)
  • :request-constraint-failed (422)
  • :status-not-allowed (500)
  • :response-validation-failed (500)
  • :response-constraint-failed (500)
  • :semantics-failed (500)

These can be processed by wrapping the handler in a custom middleware à la:

(defn wrap-custom-errors
  [handler]
  (fn [request]
    (let [response (handler request)]
      (case (r/validation-error response)
        :status-not-allowed { ... }
        ...
        response))))

Schemas

Response

A full response schema could look like this:

{:headers    {"content-type" #"^text/plain"},
 :body       s/Str,
 :constraint (s/pred (comp seq :body) 'body-not-empty?)
 :semantics  (s/pred semantics? 'semantics-valid?)}

The following keys are possible, none are required:

  • :headers: a schema for a map of strings to be matched against the response's :headers key.

  • :body: a schema for the response body (no type restrictions).

  • :constraint: a schema that will be applied to the whole response.

  • :semantics: a schema that will be applied to a map of :request and :response, e.g.:

      (s/pred
        (fn [{:keys [request response]}]
          (= (-> request :params :length)
             (-> response :body count)))
        'length-semantics?)
    

A raw response schema can be prepared for validation using compile-response-schema which can then be used with check-single-response for direct validation:

(r/check-single-response
  (r/compile-response-schema {:body s/Str})
  {:status 200, :headers {}, :body ""})
;; => {:headers {}, :status 200, :body ""}

Error values can be distinguished using error?:

(r/error?
  (r/check-single-response
    (r/compile-response-schema {:body s/Str})
    {:status 200, :headers {}, :body nil}))
;; => true

Multiple responses can be given using a map of status/response pairs (where the status is allowed to be the wildcard value :*) and compiled using compile-responses. Checking is done by status inside of check-response.

Request

A full request schema could look like the following:

{:headers           {"content-type" #"^application/json"}
 :params            {:length s/Int}
 :query-string      s/Str
 :body              {:action (s/enum :start :stop)}
 :constraint        (s/pred #(-> % :query-string count (> 8)) 'valid-query?)
 :param-constraints {:length (s/pred pos? 'positive)}
 :responses         {200 ok-schema, 409 conflict-schema}}

The following keys are possible, none are required:

  • :headers: a schema for a map of strings to be matched against the request's :headers key.
  • :params: a schema for a map of keywords that will be matched against the merged result of :query-params, :route-params and :form-params.
  • :query-string: a schema that will be matched against the request's query string.
  • body: a schema for the request body (no type restrictions).
  • :constraint: a schema hat will be applied to the whole request.
  • :param-constraints: a map schema for semantic validation of the :params map.
  • :responses: a map of response status/schema pairs (the status is allowed to be the wildcard :* if a default schema shall be provided).

A raw request schems can be prepared for validation using compile-request-schema which can then be used with check-single-request for direct validation:

(r/check-single-request
  (r/compile-request-schema {:params {:x s/Int}})
  {:request-method :get, :query-params {:x "0"}, :headers {}})
;; => {:request-method :get,
;;     :headers {},
;;     :query-params {:x "0"}
;;     :params {:x 0}}

As mentioned above, :query-params/:route-params/:form-params will always be merged into :params which is also the only place that parameter coercion will happen. Keep that in mind when writing your handlers!

Multiple requests can be compiled using a map of method/schema pairs (method is allowed to be the wildcard :*) via compile-requests and subsequently checked by method in check-request.

FAQs

Q: How slow is this?

You can clone the repository and run lein perf which will compare the schema-based middleware to a custom one performing validation/coercion on foot.

Runs of the benchmark show an approximate overhead of 21x, i.e. validation using schemas produces a constant overhead that is 21 times higher than using a direct approach - but note that we're usually talking about microseconds here!

You have to decide on a per-case basis if the actual timing values are significant. Also, future performance improvements in the underlying schema library might reduce that significance.

Q: How do I handle e.g. JSON data?

Make sure that data decoding/encoding happens before and after schema validation respectively, and validate the unencoded data, probably using the :body key.

ring-json, for example, stores the decoded JSON either in :params (wrap-json-params) or :body (wrap-json-body), generating the response JSON again from :body. Your middleware stack should thus look similar to the following one:

(-> handler
    (r/wrap-schema {...})
    (ring.middleware.json/wrap-json-response {...})
    (ring.middleware.json/wrap-json-body {...}))

The wrap-schema middleware has to be closer to the bottom than any codec.

Q: Does it play well with Liberator?

Generally, yes. Problems arise mostly from content negotiation which causes a Liberator resource to usually return the body as a string matching the negotiated format:

(require '[liberator.core :refer [resource]])

((r/wrap-schema
   (resource {:available-media-types ["application/json"]
              :handle-ok (constantly {:data "Hello!"})})
   {:get {:responses {200 {:body {:data s/Str}}}}})
 {:request-method :get, :headers {"accept" "application/json"}})
;; => {:status 500,
;;     :ronda/error {:error-form {:body (not (map? "{\"data\":\"Hello!\"}"))}, ...}
;;     ...}

A workaround for this would be to use liberator.representation/ring-response to ensure the response is returned as-is, performing content-negotiation later on. An abstraction over this should not be too hard to produce and would be much appreciated.

Documentation

Auto-generated documentation can be found here.

Contributing

Contributions are very welcome.

  1. Clone the repository.
  2. Create a branch, make your changes.
  3. Make sure tests are passing by running lein test.
  4. Submit a Github pull request.

License

Copyright (c) 2015 Yannick Scherer

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.