From 0e3ea5a7144c561118d78d19fb06f1cbc19e55b2 Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 06:32:12 -0900 Subject: [PATCH] add coercion for numeric params --- src/tbd/core.clj | 24 +++++++++++++++++++++++- src/tbd/math/routes.clj | 18 +++++++++--------- test/tbd/math/routes_test.clj | 27 +++++++++++++++++++-------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/tbd/core.clj b/src/tbd/core.clj index cb01456..ca4711c 100644 --- a/src/tbd/core.clj +++ b/src/tbd/core.clj @@ -5,7 +5,12 @@ [reitit.ring :as ring] [reitit.swagger :as swagger] [reitit.swagger-ui :as swagger-ui] + [reitit.coercion] + [reitit.coercion.malli :as malli] + [reitit.ring.coercion :as coercion] + [reitit.ring.middleware.exception :as exception] [reitit.ring.middleware.muuntaja :as muuntaja-middleware] + [reitit.ring.middleware.parameters :as parameters] [muuntaja.core :as m] [tbd.health.routes :as health] [tbd.math.routes :as math])) @@ -21,6 +26,19 @@ (and (map? (:body resp)) (nil? (:definitions (:body resp)))) (update :body dissoc :definitions)))))) +(defn- coercion-error-handler + "Invalid request params -> 400 in the same EDN shape the routes use." + [e _req] + {:status 400 + :headers {"Content-Type" "application/edn; charset=utf-8"} + :body (pr-str {:error "invalid request parameters" + :details (:humanized (reitit.coercion/encode-error (ex-data e)))})}) + +(def ^:private exception-middleware + (exception/create-exception-middleware + (assoc exception/default-handlers + :reitit.coercion/request-coercion coercion-error-handler))) + ;; Each logical group (health, users, orders, ...) owns a `tbd..routes` ;; namespace that returns its own route subtree; they're concatenated here. (def router @@ -37,7 +55,11 @@ (health/routes) (math/routes)] {:data {:muuntaja m/instance - :middleware [muuntaja-middleware/format-middleware]}})) + :coercion malli/coercion + :middleware [parameters/parameters-middleware + muuntaja-middleware/format-middleware + exception-middleware + coercion/coerce-request-middleware]}})) (def app (-> (ring/ring-handler diff --git a/src/tbd/math/routes.clj b/src/tbd/math/routes.clj index a4fb788..5b6e79d 100644 --- a/src/tbd/math/routes.clj +++ b/src/tbd/math/routes.clj @@ -1,24 +1,24 @@ (ns tbd.math.routes (:require [tbd.math.core :as math])) -(defn- parse-num [s] - (when s - (or (parse-long s) (parse-double s)))) +;; :int first so "2" stays a long (a bare `number?` or :double would decode it to 2.0). +;; The :swagger/* props make the spec say plain `number` instead of the first :or branch. +(def ^:private number-schema + [:or {:swagger/type "number" :swagger/format "double"} :int :double]) (defn- edn-response [status body] {:status status :headers {"Content-Type" "application/edn; charset=utf-8"} :body (pr-str body)}) -(defn- add-handler [{:keys [query-params]}] - (let [x (parse-num (get query-params "x")) - y (parse-num (get query-params "y"))] - (if (and x y) - (edn-response 200 {:result (math/add x y)}) - (edn-response 400 {:error "x and y query params are required and must be numbers"})))) +(defn- add-handler [{{{:keys [x y]} :query} :parameters}] + (edn-response 200 {:result (math/add x y)})) (defn routes [] ["/api/add" {:get {:tags ["math"] :summary "Add two numbers (?x=2&y=3 => {:result 5})" + :parameters {:query [:map + [:x {:description "First addend"} number-schema] + [:y {:description "Second addend"} number-schema]]} :handler add-handler}}]) diff --git a/test/tbd/math/routes_test.clj b/test/tbd/math/routes_test.clj index 4b676e3..a9df9ab 100644 --- a/test/tbd/math/routes_test.clj +++ b/test/tbd/math/routes_test.clj @@ -1,16 +1,12 @@ (ns tbd.math.routes-test (:require [clojure.edn :as edn] [clojure.test :refer [deftest is testing]] - [reitit.ring :as ring] - [ring.middleware.params :refer [wrap-params]] - [tbd.math.routes :as math])) - -;; wrap-params populates :query-params, as wrap-defaults does in tbd.core. -(def handler - (wrap-params (ring/ring-handler (ring/router (math/routes))))) + [muuntaja.core :as m] + [tbd.core :as core])) +;; Coercion/validation is configured on the router in tbd.core, so go through the real app. (defn add [query] - (let [resp (handler {:request-method :get :uri "/api/add" :query-string query})] + (let [resp (core/app {:request-method :get :uri "/api/add" :query-string query})] (-> resp (update :body edn/read-string)))) @@ -27,4 +23,19 @@ (doseq [query [nil "x=2" "y=3" "x=abc&y=3" "x=2&y=abc"]] (let [resp (add query)] (is (= 400 (:status resp)) (str "query: " query)) + (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))) (is (contains? (:body resp) :error)))))) + +(deftest add-route-swagger-test + (let [spec (-> (core/app {:request-method :get :uri "/swagger.json" + :headers {"accept" "application/json"}}) + :body + (->> (m/decode "application/json"))) + params (->> (:paths spec) + (some (fn [[path v]] (when (= "/api/add" (str (namespace path) "/" (name path))) v))) + :get + :parameters)] + (testing "x and y are documented as required numeric query params" + (is (= [{:name "x" :in "query" :type "number" :required true} + {:name "y" :in "query" :type "number" :required true}] + (map #(select-keys % [:name :in :type :required]) params))))))