Skip to content

Commit 135e351

Browse files
author
Roman Rudakov
authored
Merge pull request #39 from rrudakov/readable-errors
Add readable errors
2 parents 7a58005 + 4ac7092 commit 135e351

18 files changed

+563
-203
lines changed

.clj-kondo/config.edn

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
(compojure.api.sweet/DELETE)
99
(compojure.api.sweet/OPTIONS)
1010
(compojure.api.sweet/POST)
11-
(compojure.api.sweet/PUT)]}}}
11+
(compojure.api.sweet/PUT)
12+
(phrase.alpha/defphraser)]}}}

project.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
:dependencies [[org.clojure/clojure "1.10.1"]
77
;; Configuration file parsing
88
[aero "1.1.6"]
9+
;; Human readable errors
10+
[phrase "0.3-alpha4"]
911
;; Ring and compojure
1012
[ring/ring-defaults "0.3.2"]
1113
[metosin/compojure-api "2.0.0-alpha31"]

src/education/http/constants.clj

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
(ns education.http.constants)
1+
(ns education.http.constants
2+
(:require [phrase.alpha :as p]
3+
[clojure.spec.alpha :as s]
4+
[clojure.string :as str]))
25

36
(def not-found-error-message "Resource not found")
47

@@ -21,3 +24,67 @@
2124
(def valid-decimal
2225
"Regex to check price."
2326
#"\d+(\.\d+)?")
27+
28+
(def valid-username-regex
29+
"Check new username using this regex."
30+
#"^[a-zA-Z]+[a-zA-Z0-9]*$")
31+
32+
(def valid-email-regex
33+
"Check new email addresses using this regex."
34+
#"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
35+
36+
(defn- ->prefix-or
37+
[{:keys [in via]} or-value]
38+
(->> (or (first in) (first via) or-value)
39+
(name)
40+
(str/capitalize)))
41+
42+
;; Phrases
43+
(p/defphraser #(contains? % key)
44+
[_ _ key]
45+
(format "Field %s is mandatory" (name key)))
46+
47+
(p/defphraser #(re-matches valid-username-regex %)
48+
[_ _]
49+
"Username must start from letter")
50+
51+
(p/defphraser #(>= (count %) min-length)
52+
[_ problem min-length]
53+
(str (->prefix-or problem "value") " must be at least " min-length " characters"))
54+
55+
(p/defphraser (complement str/blank?)
56+
[_ problem]
57+
(str (->prefix-or problem "value") " must not be empty"))
58+
59+
(p/defphraser #(<= (count %) max-length)
60+
[_ problem max-length]
61+
(str (->prefix-or problem "value") " must not be longer than " max-length " characters"))
62+
63+
(p/defphraser (partial re-matches valid-url-regex)
64+
[_ problem]
65+
(str (->prefix-or problem "field") " URL is not valid"))
66+
67+
(p/defphraser (partial re-matches valid-decimal)
68+
[_ problem]
69+
(str (->prefix-or problem "value") " is not valid"))
70+
71+
(p/defphraser #(re-matches valid-email-regex %)
72+
[_ _]
73+
"Email is not valid")
74+
75+
(p/defphraser :default
76+
[_ problem]
77+
(str (->prefix-or problem "value") " is not valid"))
78+
79+
;; Convert problems to
80+
(defn ->phrases
81+
"Given a spec and a value x, phrases the first problem using context if any.
82+
83+
Returns nil if x is valid or no phraser was found. See phrase for details.
84+
Use phrase directly if you want to phrase more than one problem."
85+
[spec x]
86+
(some->> (s/explain-data spec x)
87+
::s/problems
88+
(mapv #(p/phrase {} %))
89+
(distinct)
90+
(filterv (complement nil?))))

src/education/http/routes.clj

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
[education.http.endpoints.upload :refer [upload-routes]]
1212
[education.http.endpoints.users :refer [users-routes]]
1313
[ring.util.http-response :as status]
14-
[taoensso.timbre :refer [error trace]])
14+
[taoensso.timbre :refer [error trace]]
15+
[clojure.spec.alpha :as s])
1516
(:import java.sql.SQLException))
1617

1718
(defn sql-exception-handler
@@ -29,21 +30,26 @@
2930
(defn request-validation-handler
3031
"Verify request body and raise error."
3132
[]
32-
(fn [^Exception e _ _]
33-
(trace e "Invalid request")
34-
(error "Invalid request" (.getMessage e))
35-
(status/bad-request {:message const/bad-request-error-message
36-
:details (.getMessage e)})))
33+
(fn [^Exception e ex-data request]
34+
(let [spec (:spec ex-data)
35+
body (:body-params request)
36+
errors (const/->phrases spec body)]
37+
(trace e "Invalid request")
38+
(error "Invalid request" (s/explain-str spec body))
39+
(status/bad-request {:message const/bad-request-error-message
40+
:errors errors}))))
3741

3842
(defn response-validation-handler
3943
"Return error in case of invalid response."
4044
[]
41-
(fn [^Exception e _ _]
42-
(trace e "Invalid response")
43-
(error "Invalid response" (.getMessage e))
44-
(status/internal-server-error
45-
{:message const/server-error-message
46-
:details (.getMessage e)})))
45+
(fn [^Exception e ex-data _]
46+
(let [spec (:spec ex-data)
47+
body (:response ex-data)]
48+
(trace e "Invalid response")
49+
(error "Invalid response" (s/explain spec body))
50+
(status/internal-server-error
51+
{:message const/server-error-message
52+
:details (s/explain-str spec body)}))))
4753

4854
(defn api-routes
4955
"Define top-level API routes."

src/education/specs/articles.clj

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
(ns education.specs.articles
2-
(:require [clojure.spec.alpha :as s]))
2+
(:require [clojure.spec.alpha :as s]
3+
[clojure.string :as str]))
34

45
(s/def ::id pos-int?)
56

67
(s/def ::user_id pos-int?)
78

89
(s/def ::user-id-param pos-int?)
910

10-
(s/def ::title (s/and string? not-empty #(<= (count %) 100)))
11+
(s/def ::title
12+
(s/and string?
13+
(complement str/blank?)
14+
#(<= (count %) 100)))
1115

12-
(s/def ::body (s/and string? not-empty))
16+
(s/def ::body
17+
(s/and string?
18+
(complement str/blank?)))
1319

14-
(s/def ::featured_image (s/and string? #(<= (count %) 500)))
20+
(s/def ::featured_image
21+
(s/and string?
22+
#(<= (count %) 500)))
1523

1624
(s/def ::is_main_featured boolean?)
1725

src/education/specs/common.clj

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
(ns education.specs.common
22
(:require [clojure.spec.alpha :as s]
33
[education.http.constants :as const]
4-
[education.specs.gymnastics :as g]))
4+
[education.specs.gymnastics :as g]
5+
[clojure.string :as str]))
56

67
(s/def ::id pos-int?)
78

89
(s/def ::subtype_id pos-int?)
910

10-
(s/def ::title (s/and string? not-empty #(<= (count %) 500)))
11+
(s/def ::title
12+
(s/and string?
13+
(complement str/blank?)
14+
#(<= (count %) 500)))
1115

12-
(s/def ::subtitle (s/and string? not-empty #(<= (count %) 500)))
16+
(s/def ::subtitle
17+
(s/and string?
18+
(complement str/blank?)
19+
#(<= (count %) 500)))
1320

1421
(s/def ::url
1522
(s/and string?
16-
not-empty
23+
(complement str/blank?)
1724
(partial re-matches const/valid-url-regex)
1825
#(<= (count %) 1000)))
1926

@@ -27,11 +34,13 @@
2734
(s/def ::screenshots
2835
(s/coll-of ::screenshot :kind vector? :distinct true :into []))
2936

30-
(s/def ::description (s/and string? not-empty))
37+
(s/def ::description
38+
(s/and string?
39+
(complement str/blank?)))
3140

3241
(s/def ::price
3342
(s/and string?
34-
not-empty
43+
(complement str/blank?)
3544
(partial re-matches const/valid-decimal)))
3645

3746
(s/def ::size pos-int?)

src/education/specs/gymnastics.clj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
(ns education.specs.gymnastics
22
(:require [clojure.spec.alpha :as s]
3-
[education.http.constants :as const]))
3+
[education.http.constants :as const]
4+
[clojure.string :as str]))
45

56
(s/def ::picture
67
(s/nilable
78
(s/and string?
8-
not-empty
9+
(complement str/blank?)
910
(partial re-matches const/valid-url-regex)
1011
#(<= (count %) 1000))))

src/education/specs/users.clj

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
(ns education.specs.users
2-
(:require [clojure.spec.alpha :as s]))
3-
4-
(def email-regex
5-
"Check new email addresses using this regex."
6-
#"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
2+
(:require [clojure.spec.alpha :as s]
3+
[education.http.constants :as const]))
74

85
(s/def ::id int?)
96

107
(s/def ::username
118
(s/and string?
12-
#(re-matches #"^[a-zA-Z]+[a-zA-Z0-9]*$" %)
9+
#(re-matches const/valid-username-regex %)
1310
#(>= (count %) 2)))
1411

1512
(s/def ::password (s/and string? #(>= (count %) 6)))
1613

17-
(s/def ::email (s/and string? #(re-matches email-regex %)))
14+
(s/def ::email (s/and string? #(re-matches const/valid-email-regex %)))
1815

1916
(s/def ::role #{"admin" "moderator" "guest"})
2017

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
(ns education.http.constants-test
2+
(:require [clojure.test :refer [deftest is testing]]
3+
[education.http.constants :as sut]
4+
[education.specs.common :as spec]
5+
[education.specs.users :as user-spec]
6+
[clojure.string :as str]))
7+
8+
(def test-data
9+
"Test cases."
10+
[[["Field url is mandatory"]
11+
::spec/presentation-create-request
12+
{:title "Title"
13+
:description "Description"}]
14+
15+
[["Username must start from letter"]
16+
::user-spec/username
17+
"1asdf"]
18+
19+
[["Username must start from letter"]
20+
::user-spec/user-create-request
21+
{:username "1asdf"
22+
:password "123456"
23+
:email "email@example.com"}]
24+
25+
[["Username must be at least 2 characters"]
26+
::user-spec/username
27+
"a"]
28+
29+
[["Username must be at least 2 characters"]
30+
::user-spec/user-create-request
31+
{:username "a"
32+
:password "123456"
33+
:email "email@example.com"}]
34+
35+
[["Title must not be empty"]
36+
::spec/title
37+
""]
38+
39+
[["Title must not be empty"]
40+
::spec/presentation-create-request
41+
{:title ""
42+
:url "https://google.com/api/presentation"
43+
:description "Description"}]
44+
45+
[["Title must not be longer than 500 characters"]
46+
::spec/title
47+
(str/join (repeat 501 "a"))]
48+
49+
[["Title must not be longer than 500 characters"]
50+
::spec/presentation-create-request
51+
{:title (str/join (repeat 501 "a"))
52+
:url "https://google.com/api/presentation"
53+
:description "Description"}]
54+
55+
[["Url URL is not valid"]
56+
::spec/url
57+
"invalid"]
58+
59+
[["Url URL is not valid"]
60+
::spec/presentation-create-request
61+
{:title "Title"
62+
:url "invalid"
63+
:description "Description"}]
64+
65+
[["Price is not valid"]
66+
::spec/price
67+
"invalid"]
68+
69+
[["Price is not valid"]
70+
::spec/dress-create-request
71+
{:title "title"
72+
:description "description"
73+
:pictures []
74+
:size 22
75+
:price "invalid"}]
76+
77+
[["Email is not valid"]
78+
::user-spec/email
79+
"invalid@email"]
80+
81+
[["Email is not valid"]
82+
::user-spec/user-create-request
83+
{:username "username"
84+
:password "123456"
85+
:email "invalid@email"}]
86+
87+
[["Id is not valid"]
88+
::spec/id
89+
"invalid"]
90+
91+
[["Username is not valid"]
92+
::user-spec/user-create-request
93+
{:username 123
94+
:password "123456"
95+
:email "valid@email.com"}]
96+
97+
[["Field description is mandatory"
98+
"Title must not be longer than 500 characters"]
99+
::spec/presentation-create-request
100+
{:title (str/join (repeat 501 "a"))
101+
:url "https://google.com/api/presentation"}]])
102+
103+
(deftest ->phrases-test
104+
(doseq [[errors spec val] test-data]
105+
(testing "Test conversion error to phrase"
106+
(is (= errors (sut/->phrases spec val))))))

0 commit comments

Comments
 (0)