biff

jf 2024-11-22T21:11:45.206229Z

How do folks do application-specific form validation? And by that I mean human-friendly error messages, reflected back in the form with error input fields highlighted and the appropriate error message placed beside each failing input. I understand that Malli has some sort of validation, but all I get back currently when trying to go that route with malli.core/validate is true or false. And then malli.core/explain always ends up returning nil, so no help there

2024-11-24T01:45:01.063109Z

So far myself I've just done this very manually--I have the POST endpoint handler redirect back to the form and add error info to the query params (like error=invalid-email), and then the form rendering code checks the query params and renders error messages accordingly. Definitely would be nice to have a better solution for more complex forms; I just haven't gotten around to it yet myself.

jf 2024-11-28T03:06:19.075079Z

thank you everybody for the help!

Safe 2024-11-25T14:41:02.940019Z

I've managed to use Malli schemas to validate submitted form data with a combination of: • malli.core/decode to transform form params to types defined in schema • malli.core/explain to return validation errors (this returns nil when valid) • malli.error/humanize to return a human readable error summary Something like this:

(require '[malli.core :as m])
(require '[malli.error :as me])
(require '[malli.transform :as mt])

(def validator [:map
                [:name :string]
                [:age :int]])

(def transformers (mt/transformer
                    mt/string-transformer
                    mt/default-value-transformer))

(defn validate
  [params]
  (->> (m/decode validator params transformers)
       (m/explain validator)
       (me/humanize)))

(validate {:name "Bob" :age "3"})  ; valid: nil

(validate {:name 42 :age "old"})   ; invalid: {:name ["should be a string"],
                                   ;           :age ["should be an integer"]}
The Malli docs explain how the plain error messages such as "should be a string" https://github.com/metosin/malli?#custom-error-messages as needed.

👀 1
1
oλv 2024-11-25T22:16:58.022669Z

I have my own humble system, which is probably inferior to Malli, but is rather satisfying to me. I use the concept of a field validator, which is a function that takes a field value and returns a corresponding error or nil if all is good. E.g

(defn not-empty [s]
  (when (empty? s) "Must not be empty"))

(defn at-most-n-xs [n plural-noun]
  (fn [s]
    (when (> (count s) n) (str "Must be at most " n " " plural-noun))))

(defn count-between [least most plural-noun]
  (fn [s]
    (cond
      (> (count s) most) (str "Must be at most " most " " plural-noun)
      (< (count s) least) (str "Must be at least " least " " plural-noun))))

(defn scalar-between [least most]
  (fn [s]
    (cond
      (> s most) (str "Must be at most " most)
      (< s least) (str "Must be at least " least))))

(defn subreddits [srs]
  (some #(when-not (str/starts-with? % "r/") "Must be subreddits of the form \"r/subreddit\"")
        srs))
(tests
 (subreddits ["r/foo" "bar"]))


(defn at-most-n-chars [n]
  (fn [s]
    (when (> (count s) n) (str "Must be at most " n " characters long"))))

(defn not-admin [s]
  (when (= (str/lower-case s) "admin") "Must not be \"admin\""))

(defn not-less-than-eight-chars [s]
  (when (< (count s) 8) "Must be at least 8 characters long"))
Then I have a function validate which is best explained by example:
(or (some-> params
            (validate [:email validate/not-empty]
                      [:email validate/not-less-than-eight-chars])
            ;; page:sign-up is called with accompanied params
            page:sign-up)
    #_green-path)
An accompanied params is a params map where each parameter value is a map like so {:value original-value} optionally accompanied by more attributes such as {:value original-value :status :error :message "something is wrong!"} or even {:value original-value :status :error :message "something is wrong!" :label "Email"}. I denote accompanied params “Params” with a capital p and each set of get:<route> and post:<route> functions typically defer to a function (fn page:<route> [Params]) which does most of the work.
(defn get:sign-up
     [{:keys [params session identity]}]
     (if identity
       (see-other "/app")
       (page:sign-up (accompany params))))
   
(defn post:sign-up [{:keys [server-name params] :as req}]
  (or (some-> params
              (validate [:email validate/not-empty]
                        [:email validate/not-admin]
                        [:email (validate/unique-email (d/db ds/datomic))]
                        [:password validate/not-less-than-eight-chars])
              page:sign-up)
      (let [{:keys [email password]} params
            tx-result (d/transact ds/datomic
                                  {:tx-data
                                   [{:user/username email
                                     :user/pw-hash (hash-password password)}]})
            id (tx-result->eid tx-result)]
        ;; As per the ns doc, the functions throw `ex-info`s if
        ;; something goes wrong, so I need not do any error handling here
        (-> (see-other "/app")
            (update :session assoc :identity id)))))
(defn page:sign-up [Params]
  (page
   [:h1 "Sign up"]
   [:form {:method "post"
           :action "/sign-up"}
    (email-input (merge {:name "email"
                         :required true
                         :label "Your email"}
                        (:email Params)))
    (password-input (merge {:name "password"}
                           (:password Params)))
    [:button
     {:type "submit"}
     "Create an account"]]))