Skip to content

Instantly share code, notes, and snippets.

@rauhs
Last active May 27, 2023 05:29
Show Gist options
  • Star 27 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save rauhs/cfdb55a8314e0d3f4862 to your computer and use it in GitHub Desktop.
Save rauhs/cfdb55a8314e0d3f4862 to your computer and use it in GitHub Desktop.
Translate prismatic's schema.core errors to a human readable form. Use this for presenting validation errors to users. Don't use this for programming errors like a missing map key etc.
(ns x.y
(:use [plumbing.core]) ;; Just for the map-vals
(:require [clojure.walk :refer [postwalk prewalk prewalk-demo postwalk-demo]]
[clojure.core.match :refer [match]]
[schema.utils :refer [named-error-explain validation-error-explain]]
[schema.core :as s])
(:import (schema.utils NamedError ValidationError)))
;; Partially FROM:
;; https://github.com/puppetlabs/clj-schema-tools
(defn vectorize
"Recursively transforms all seq in m to vectors.
Because maybe you want to use core.match with it."
[x]
(postwalk #(if (seq? %) (vec %) %)
x))
(defn humanize
"Returns a human explanation of a SINGLE error.
This is for errors which are from USER input. It is not for programming errors.
If the error is a map/vector then this function must be applied to each of those
values.
You should adapat this function for your own custom errors.
This is just an example. Don't actually use this function.
Define it in your business logic and pass it in to the check function."
[x]
;; http://stackoverflow.com/questions/25189031/clojure-core-match-cant-match-on-class
(let [;; TLDR: We can't match on classes (it'd be bound to that symbol)
;; However, match will first try to match a local binding if it exists:
String java.lang.String
Number java.lang.Number]
(match
x
;;;;;;;;;;;;;;;;;;;;; DEAL with most common stuff
['not ['pos? num]]
(str num " is not positive but it should be.")
['not ['instance? Number not-num]]
(str "'" not-num "' is not a number but it should be.")
['not ['instance? String not-num]]
(str "'" not-num "' is not a string but it should be.")
['not [['between min max] given]]
(str "The value must be between " min " and " max ". But given: " given)
;; We can use core.match's :guard to apply a function check:
;; error by s/enum: (not (#{:x :y} :foo))
['not [(enum :guard set?) given]]
(str "The value must be one of the following: " enum ". But given: " given)
;; TODO: Add much more cases which depend on your business logic.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Pluck out the error in case of a named error
['named inner name]
(humanize inner)
;; likely a programming error. Needs fixed from either client or server code:
:else
x
)))
(defn explain
"Takes an error object as returned from schema an transforms each leaf value of either
1. NamedError
2. ValidationError
Such that it is 'explained' (like schema's explain) but additionally
turns the results into vectors.
Optionally takes a translator if you want the NamedErrors & ValidationErrors explained
in a humanized form.
"
([errors] (explain errors identity))
([errors translator]
(cond
(map? errors)
(map-vals #(explain % translator) errors)
(or (seq? errors)
(coll? errors))
(mapv #(explain % translator) errors)
(instance? NamedError errors)
(translator (vectorize (named-error-explain errors)))
(instance? ValidationError errors)
(translator (vectorize (validation-error-explain errors)))
:else
errors)))
(defn check
"Check x against schema and explain the errors.
See explain for details of output format.
Just like schema's check, returns nil if no error.
(check s/Str 4) ;; => [not [instance? java.lang.String 4]]"
([schema x] (check schema x identity))
([schema x translator]
(some-> (s/check schema x) (explain translator))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Example usage:
(s/defschema PosNum
(s/both s/Num (s/pred pos? 'pos?)))
(def my-map-schema {:x PosNum :y s/Num :z {:zz s/Str}})
(check s/Str 4 humanize);; => "4 is not a string but it should be"
(check PosNum -1 humanize) ;; => -1 is not positive but it should be
(check my-map-schema {:x 1 :y -2 :z {:zz 3}} humanize) ;; => {:z {:zz "'3' is not a string but it should be."}}
;; Or without translator:
(check PosNum 0);; => [not [pos? 0]]
(check PosNum "fo");; => [not [instance? java.lang.Number 0]]
;; Or an example of a custom predicate which carries
;; the data over to name of the predicate:
(defn between
"showing that we can store arbitrary information about the schema in the name"
[min max]
(s/both s/Num
(s/pred #(<= min % max)
`(~'between ~min ~max))))
(check (between 3 6) 8 humanize) ;; => "The value must be between 3 and 6. But given: 8"
@rauhs
Copy link
Author

rauhs commented Aug 22, 2015

@rauhs
Copy link
Author

rauhs commented Feb 4, 2018

License: EPL. Same as Clojure: https://www.eclipse.org/legal/epl-v10.html

@rauhs
Copy link
Author

rauhs commented Mar 1, 2018

Please see: https://github.com/siilisolutions/humanize for a library based on this code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment