{"id":19293976,"url":"https://github.com/igrishaev/soothe","last_synced_at":"2025-04-22T07:32:33.024Z","repository":{"id":147470354,"uuid":"426649592","full_name":"igrishaev/soothe","owner":"igrishaev","description":"Clear error messages for Clojure.spec","archived":false,"fork":false,"pushed_at":"2021-11-29T15:24:39.000Z","size":116,"stargazers_count":11,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-04-01T20:51:26.266Z","etag":null,"topics":["clojure","clojurescript","errors","spec"],"latest_commit_sha":null,"homepage":"https://github.com/igrishaev/soothe","language":"Clojure","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/igrishaev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-11-10T14:18:26.000Z","updated_at":"2023-09-02T12:27:09.000Z","dependencies_parsed_at":null,"dependency_job_id":"f5c75ce5-a66e-4ba7-9e79-eff738d2f4ae","html_url":"https://github.com/igrishaev/soothe","commit_stats":null,"previous_names":[],"tags_count":2,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/igrishaev%2Fsoothe","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/igrishaev%2Fsoothe/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/igrishaev%2Fsoothe/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/igrishaev%2Fsoothe/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/igrishaev","download_url":"https://codeload.github.com/igrishaev/soothe/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250195054,"owners_count":21390230,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["clojure","clojurescript","errors","spec"],"created_at":"2024-11-09T22:36:43.929Z","updated_at":"2025-04-22T07:32:33.016Z","avatar_url":"https://github.com/igrishaev.png","language":"Clojure","funding_links":[],"categories":[],"sub_categories":[],"readme":"\n\u003cimg align=\"right\" src=\"art/real-soothe.jpeg\"\u003e\n\n# Soothe\n\nClear error messages for Clojure.spec, extremely simple and robust.\n\n[API Documentation](https://igrishaev.github.io/soothe/)\n\n## Table of Contents\n\n\u003c!-- toc --\u003e\n\n- [Installation](#installation)\n- [Concepts](#concepts)\n- [TL;DR: Code Samples](#tldr-code-samples)\n- [The API](#the-api)\n  * [Special messages](#special-messages)\n- [Pre-defined messages \u0026 Localization](#pre-defined-messages--localization)\n- [ClojureScript](#clojurescript)\n- [Best practices \u0026 Known cases](#best-practices--known-cases)\n\n\u003c!-- tocstop --\u003e\n\n## Installation\n\n- Leiningen/Boot\n\n~~~clojure\n[com.github.igrishaev/soothe \"0.1.1\"]\n~~~\n\n- clojure CLI/deps.edn\n\n~~~clojure\ncom.github.igrishaev/soothe {:mvn/version \"0.1.1\"}\n~~~\n\n## Concepts\n\nClojure.spec is a piece of art yet misses some bits when dealing with error\nmessages.  The standard `s/explain-data` gives a raw machinery output that\nbearly can be shown to the end-user. This library is going to fix this.\n\nThe idea of Soothe is extremely simple. The library keeps its private registry\nof spec/pred =\u003e message pairs. The key is either a keyword referencing a spec or\na fully-qualified symbol meaning a predicate. The value of this map is either a\nplain string or a function that takes the problem map of the raw explain spec\ndata.\n\nFor example:\n\n~~~clojure\n{:some.ns/user\n \"This is a wrong user.\"\n\n 'other.ns/data-valid?\n \"The data is invalid.\"\n\n :my.project.spec/item\n (fn [{:as problem :keys [pred in]}] ;; other spec problem keys\n   (format \"Build a custom message for this spec in runtime\"))}\n~~~\n\nSoothe provides its own version of `explain-data`. When called, it prepares the\nraw Spec explain data and then remaps it. For each problem, Soothe tries to find\na message using the following algorithm.\n\n- When the `pred` field is a fully-qualified symbol, get the message from the\n  registry. For example, `clojure.core/int?` resolves into something like `\"The\n  value must be an integer\"`.\n\n- When the `pred` is something different, try the `via` vector of specs. The\n  algorithm iterates the vector in reverse order. The first spec which has a\n  message in the registry will succeed.\n\n- A special case when an `s/keys` spec misses a required key.\n\n- Another special case when the spec is wrapped with `s/conformer`.\n\n- The default message gets resolved.\n\n## TL;DR: Code Samples\n\n~~~clojure\n\n;;\n;; Imports\n;;\n\n(ns ...\n  (:require\n   [soothe.core :as sth]\n   [clojure.spec.alpha :as s]))\n\n\n;;\n;; Define a spec\n;;\n(s/def :user/name string?)\n(s/def :user/age int?)\n\n(s/def :user/email\n  (s/and\n    string?\n    (partial re-matches #\"(.+?)@(.+?)\")))\n\n(s/def :user/field-42\n  (fn [x]\n    (= x 42)))\n\n(s/def ::user\n  (s/keys :req-un [:user/name\n                   :user/age]\n          :opt-un [:user/email\n                   :user/field-42]))\n\n\n;;\n;; Data sample\n;;\n\n(def user\n  {:name \"Test\" :age 42})\n\n\n;;\n;; no errors\n;;\n(sth/explain-data ::user user) ;; nil\n\n;;\n;; wrong type\n;;\n(sth/explain-data\n  ::user\n  (assoc user :name 42))\n\n{:problems\n  [{:message \"The value must be a string.\"  ;; \u003c\u003c\u003c\n    :path [:name]\n    :val 42}]}\n\n;;\n;; Missing key\n;;\n(sth/explain-data\n  ::user (dissoc user :age))\n\n{:problems\n  [{:message \"The object misses the mandatory key 'age'.\"  ;; \u003c\u003c\u003c\n    :path []\n    :val {:name \"Test\"}}]}\n\n;;\n;; Custom predicate fails, no custom message defined\n;;\n(sth/explain-data\n  ::user (assoc user :email \"wrong-string\"))\n\n{:problems\n [{:message \"The value is incorrect.\"  ;; \u003c\u003c\u003c\n   :path [:email]\n   :val \"wrong-string\"}]}\n\n\n;;\n;; Define a cumstom message\n;;\n(sth/def :user/email \"Wrong email.\")\n\n(sth/explain-data\n  ::user (assoc user :email \"wrong-string\"))\n\n{:problems\n  [{:message \"Wrong email.\"  ;; \u003c\u003c\u003c\n   :path [:email]\n   :val \"wrong-string\"}]}\n\n;;\n;; A message for a custom predicate\n;;\n(defn some-complidated-check [value]\n  (= value 100500))\n\n(sth/def `some-complidated-check\n  \"The value did't match that complicated check.\")\n\n(s/def ::data\n  (s/and int? some-complidated-check))\n\n(sth/explain ::data -1)\n\n{:problems\n [{:message \"The value did't match that complicated check.\"  ;; \u003c\u003c\u003c\n   :path []\n   :val -1}]}\n\n;;\n;; The message can be a function\n;;\n(sth/def :user/email\n  (fn [{:as problem\n        :keys [path pred val via in]}]\n    (format \"Custom error message for email, pred: %s\" pred)))\n\n(sth/explain-data\n  ::user (assoc user :email \"wrong-string\"))\n\n{:problems\n [{:message\n   \"Custom error message for email, pred: (clojure.core/partial clojure.core/re-matches #\\\"(.+?)@(.+?)\\\")\"  ;; \u003c\u003c\u003c\n   :path [:email]\n   :val \"wrong-string\"}]}\n\n;;\n;; Formatted output:\n;;\n(sth/explain\n  ::user (dissoc user :age))\n\n;; Problems:\n;;\n;; - The object misses the mandatory key 'age'.\n;;   path: []\n;;   value: {:name Test}\n\n(sth/explain-str ::user (dissoc user :age))\n;; returns the same output as a string\n\n;;\n;; Handling conformers\n;;\n(sth/def `-\u003eint\n  \"Cannot coerce the value to an integer.\")\n\n(s/def ::config\n  (s/keys :req-un [:config/port\n                   :config/timeout]))\n\n(s/def :config/port\n  (s/conformer -\u003eint))\n\n(s/def :config/timeout\n  (s/conformer -\u003eint))\n\n(sth/explain-data\n  ::config {:port \"five\" :timeout \"dunno\"})\n\n{:problems\n  [{:message \"Cannot coerce the value to an integer.\"  ;; \u003c\u003c\u003c\n    :path [:port]\n    :val \"five\"}\n   {:message \"Cannot coerce the value to an integer.\"  ;; \u003c\u003c\u003c\n    :path [:timeout]\n    :val \"dunno\"}]}\n~~~\n\n[tests]: blob/master/test/soothe/core_test.cljc\n\nFor more examples, see [the unit tests][tests].\n\n## The API\n\nDefine a message for a spec or a predicate using the `soothe.core/def` function:\n\n~~~clojure\n(defn my-predicate [x]\n  ...)\n\n(sth/def `my-predicate \"Some message\")\n~~~\n\nUse fully-qualified symbols, not simple ones. In the example above, the backtick\nexpands the symbol to the full form (with the current namespace).\n\nDefining a message for a spec:\n\n~~~clojure\n(s/def ::user (s/keys ...))\n(sth/def ::user \"Message for the user spec\")\n~~~\n\nThe message might be a function that takes a preblem map and returns a string:\n\n~~~clojure\n(sth/def ::user\n  (fn [problem]\n    (format \"A custom message ... %s\" ...)))\n~~~\n\nThe library handles the case when the predicate is wrapped into the\n`s/conformer` spec. Soothe tries to find a message for the nested predicate if\npossible:\n\n~~~clojure\n(defn -\u003eint\n  [val]\n  (cond\n    (int? val)\n    val\n    (string? val)\n    ;; (... try to parse the string ...)\n    :else\n    ::s/invalid))\n\n(s/def ::port -\u003eint)\n\n(sth/def `-\u003eint \"Cannot coerce the value to an integer.\")\n\n(sth/explain-data ::port \"dunno\")\n;; you'll get \"Cannot coerce the value to an integer.\"\n~~~\n\n### Special messages\n\nThere are two *special messages* at the moment. The first one is the\n`:soothe.core/missing-key` keyword which is used when a map misses a key. The\ndefault implementation is:\n\n~~~clojure\n:soothe.core/missing-key\n(fn [{:keys [key]}]\n  (format \"The object misses the mandatory key '%s'.\"\n          (-\u003e key str (subs 1))))\n~~~\n\nThe library adds the `key` field into the problem map when detecting this case.\n\nThe second special message is `:soothe.core/default`. The default implementation\nis just a string `\"The value is incorrect.\"` You're welcome to register a\nfunction for that key with a custom function.\n\nUse `(sth/def-many {...})` function to define several key/message pairs at once\npassing them as a map. The `(sth/undef ...)` function removes a message for the\npassed key. To wipe all the messages, use `(sth/undef-all)`.\n\n## Pre-defined messages \u0026 Localization\n\n[en]: blob/master/src/soothe/en.cljc\n\nThe library ships predefined messages for all the `clojure.core` predicates:\n`int?`, `string?`, `uuid?` and so forth. They locate in the [en.cljs module][en]\nwich gets loaded automatically once you import `soothe.core`.\n\nThere is also a Russian version of the messages provided with the `soothe.ru`\nmodule. Once loaded, it overrides the messages in the registry. Just import it\nsomewhere in your project:\n\n~~~clojure\n(ns ...\n  (:require\n    [soothe.core :as sth]\n    soothe.ru ;; RU messages for spec\n    ...))\n~~~\n\nYou're welcome to submit your localized messages with a pull request.\n\n## ClojureScript\n\nSoothe is fully compatible with ClojureScript and thus can be used on the\nfrontend.\n\n## Best practices \u0026 Known cases\n\n- Declare the messages right after you're declared the specs or predicates, for\n  example:\n\n~~~clojure\n(s/def ::my-spec ...) ;; your spec\n(sth/def ::my-spec \"...\") ;; the message\n\n;; or\n\n(defn check-email [string]...)\n(sth/def `check-email \"...\")\n~~~\n\nBut don't put them in another namespace.\n\n- Some specs spoil the predicates, for example, `s/coll-of`. Imagine you have a\n  spec like this one:\n\n~~~clojure\n(s/def ::my-items (s/coll-of int?))\n~~~\n\nNow, if one of the items fails, the predicate will be not `'clojure.core/int?`\nbut just a `'int?` which leads to the default error message. To handle this,\nbind the predicate to a spec and pass the spec:\n\n~~~clojure\n(s/def ::int? int?)\n(s/def ::my-items (s/coll-of ::int?))\n(sth/def ::int? \"The value must be an integer.\")\n~~~\n\nWith this approach, the library will return the right error message.\n\n---\n\nIvan Grishaev, 2021\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Figrishaev%2Fsoothe","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Figrishaev%2Fsoothe","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Figrishaev%2Fsoothe/lists"}