ronda-schema aims to offer HTTP request/response validation and coercion using prismatic/schema.
Leiningen (via Clojars)
REPL
(require '[ronda.schema :as r]
'[schema.core :as s])
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.
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)))
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.
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.
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))))
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
.
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
.
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.
Auto-generated documentation can be found here.
Contributions are very welcome.
- Clone the repository.
- Create a branch, make your changes.
- Make sure tests are passing by running
lein test
. - Submit a Github pull request.
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.