handle formats
CI / Test + coverage (push) Successful in 34s

This commit is contained in:
2026-09-19 07:49:15 -09:00
parent b1783e3f85
commit 31e03d552b
5 changed files with 129 additions and 5 deletions
+13 -1
View File
@@ -42,7 +42,7 @@ docker run --rm -p 3000:3000 tbd
|--------|------|-------------|
| `GET` | `/` | The UI: an HTML shell that loads the compiled ClojureScript from `/js/main.js` |
| `GET` | `/api/ping` | Health check, returns `pong` |
| `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) |
| `GET` | `/api/add?x=2&y=3` | Adds two numbers: `{:result 5}` (EDN by default, see [Formats](#formats)) (400 if either is missing or not a number) |
| `GET` | `/api-docs` | Swagger UI |
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
@@ -60,8 +60,20 @@ docker run --rm -p 3000:3000 tbd
```bash
curl localhost:3000/api/ping # pong
curl 'localhost:3000/api/add?x=2&y=3' # {:result 5}
curl 'localhost:3000/api/add?x=2&y=3' -H 'Accept: application/json' # {"result":5}
```
## Formats
The API speaks EDN, JSON and Transit (`application/transit+json`, `application/transit+msgpack`).
Responses follow the request's `Accept` header, and request bodies are read by their `Content-Type`.
**EDN is the default**: with no `Accept` (or `*/*`, or a type the API can't produce) you get EDN, and a
body sent with no `Content-Type` is read as EDN. Errors (400, 401, 403, ...) use the same format
as everything else. Two exceptions: `/api/ping` is always `text/plain`, and `/swagger.json` is always JSON.
Handlers return plain data (`(tbd.http/respond 200 {:result 5})`), and the muuntaja middleware in
`tbd.core` encodes it; don't `pr-str` a body yourself.
## Todos
Each logged-in user has their own todo list, keyed by their Gitea user id. Every query is
+11
View File
@@ -128,6 +128,17 @@
(assert-eq "Body" {:result 5} (edn/read-string (:body resp))))
(println)
(println "Test 9b: the API answers in the format the Accept header asks for, EDN by default")
(let [json (http/get (str base-url "/api/add?x=2&y=3") {:throw false :headers {"Accept" "application/json"}})
any (http/get (str base-url "/api/add?x=2&y=3") {:throw false :headers {"Accept" "*/*"}})
err (http/get (str base-url "/api/todos") {:throw false :headers {"Accept" "application/json"}})]
(assert-contains "JSON Content-Type" "application/json" (get (:headers json) "content-type" ""))
(assert-eq "JSON body" "{\"result\":5}" (:body json))
(assert-contains "Accept */* is EDN" "application/edn" (get (:headers any) "content-type" ""))
(assert-contains "Errors are JSON too" "application/json" (get (:headers err) "content-type" ""))
(assert-eq "Error body" "{\"error\":\"login required\"}" (:body err)))
(println)
(println "Test 10: GET /api/add with a missing or non-numeric param returns 400")
(assert-eq "Missing y" 400 (:status (get* (str base-url "/api/add?x=2"))))
(assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3"))))
+99
View File
@@ -0,0 +1,99 @@
(ns tbd.http-test
(:require [clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.auth.routes-test :as t]
[tbd.core :as core]
[tbd.http :as http]
[tbd.test-util :as test-util]
[tbd.todo.db-test :as db-test])
(:import (java.io ByteArrayInputStream)))
(defn call
"`headers` is a map; `body` a string or nil. The response body is decoded by its Content-Type."
[app method uri headers & [body]]
(test-util/decode-body
(app (cond-> {:request-method method :uri uri :headers headers}
body (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))))))
(defn content-type [resp] (get-in resp [:headers "Content-Type"]))
(defn get-add-swagger []
(core/app {:request-method :get :uri "/swagger.json" :headers {}}))
(deftest response-format-test
(let [get-add (fn [accept]
(test-util/decode-body
(core/app (cond-> {:request-method :get :uri "/api/add" :query-string "x=2&y=3" :headers {}}
accept (assoc-in [:headers "accept"] accept)))))]
(testing "EDN is the default: no Accept, or one nothing here satisfies"
(doseq [accept [nil "*/*" "application/xml"]]
(let [resp (get-add accept)]
(is (= "application/edn; charset=utf-8" (content-type resp)) (str "accept: " accept))
(is (= {:result 5} (:body resp))))))
(testing "JSON when asked for"
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "application/json"}})]
(is (= "application/json; charset=utf-8" (content-type resp)))
(is (= "{\"result\":5}" (slurp (:body resp))))))
(testing "Transit when asked for"
(let [resp (get-add "application/transit+json")]
(is (= "application/transit+json; charset=utf-8" (content-type resp)))
(is (= {:result 5} (:body resp)))))
(testing "the first Accept type we can produce wins"
(is (= "application/json; charset=utf-8" (content-type (get-add "application/xml, application/json")))))
(testing "text/plain responses are left alone"
(let [resp (core/app {:request-method :get :uri "/api/ping" :headers {"accept" "application/json"}})]
(is (= "text/plain; charset=utf-8" (content-type resp)))
(is (= "pong" (:body resp)))))
(testing "swagger.json stays JSON, even with no Accept header"
(is (= "application/json; charset=utf-8" (content-type (get-add-swagger)))))))
(deftest error-format-test
(testing "errors follow the same negotiation"
(let [req (fn [accept] (core/app (cond-> {:request-method :get :uri "/api/add" :query-string "x=abc&y=3" :headers {}}
accept (assoc-in [:headers "accept"] accept))))]
(is (= "application/edn; charset=utf-8" (content-type (req nil))))
(is (= "application/json; charset=utf-8" (content-type (req "application/json"))))
(is (= 400 (:status (req "application/json"))))
(is (contains? (:body (test-util/decode-body (req "application/json"))) :error)))
(testing "including the auth middleware's 401"
(let [app (t/make-app (t/gitea-stub "alice"))
resp (call app :get "/api/todos" {"accept" "application/json"})]
(is (= 401 (:status resp)))
(is (= "application/json; charset=utf-8" (content-type resp)))
(is (= {:error "login required"} (:body resp)))
(is (= "application/edn; charset=utf-8" (content-type (call app :get "/api/todos" {}))))))))
(deftest request-format-test
(let [app (t/make-app (t/gitea-stub "alice" 7) :todo {:db-path (db-test/temp-db-path)})
cookie (t/login! app)
post (fn [headers body] (call app :post "/api/todos" (assoc headers "cookie" cookie) body))]
(testing "JSON in, EDN out by default"
(let [resp (post {"content-type" "application/json"} "{\"title\":\"a\"}")]
(is (= 201 (:status resp)))
(is (= "application/edn; charset=utf-8" (content-type resp)))
(is (= "a" (:title (:body resp))))))
(testing "EDN in, JSON out when asked for"
(let [resp (post {"content-type" "application/edn" "accept" "application/json"} "{:title \"b\"}")]
(is (= 201 (:status resp)))
(is (= "application/json; charset=utf-8" (content-type resp)))
(is (= "b" (:title (:body resp))))))
(testing "with no Content-Type the body is EDN"
(let [resp (post {} "{:title \"c\"}")]
(is (= 201 (:status resp)))
(is (= "c" (:title (:body resp))))))
(testing "Transit in"
(let [body (String. (.readAllBytes (m/encode http/muuntaja "application/transit+json" {:title "d"})) "UTF-8")
resp (post {"content-type" "application/transit+json"} body)]
(is (= 201 (:status resp)))
(is (= "d" (:title (:body resp))))))
(testing "a body that isn't valid for its type is a 400 in the negotiated format"
(let [resp (post {"content-type" "application/json" "accept" "application/json"} "{:title \"e\"}")]
(is (= 400 (:status resp)))
(is (= "application/json; charset=utf-8" (content-type resp)))
(is (= {:error "malformed request body"} (:body resp))))
(is (= 400 (:status (post {} "{\"title\":\"f\"}")))
"JSON isn't assumed when nothing says so"))
(testing "listing shows only what was created"
(is (= ["a" "b" "c" "d"]
(map :title (:todos (:body (call app :get "/api/todos" {"cookie" cookie "accept" "application/json"})))))))))
+3 -2
View File
@@ -1,5 +1,6 @@
(ns tbd.math.routes-test
(:require [clojure.test :refer [deftest is testing]]
(:require [clojure.edn :as edn]
[clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.core :as core]
[tbd.test-util :as test-util]))
@@ -29,7 +30,7 @@
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "text/html,application/xhtml+xml,*/*;q=0.8"}})]
(is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (= {:result 5} (:body (test-util/decode-body resp))))))
(is (= {:result 5} (edn/read-string (slurp (:body resp)))))))
(testing "programmatic clients still get application/edn"
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "*/*"}})]
+3 -2
View File
@@ -1,5 +1,6 @@
(ns tbd.test-util
(:require [muuntaja.core :as m]
(:require [clojure.string :as str]
[muuntaja.core :as m]
[tbd.http :as http]))
(defn decode-body
@@ -9,5 +10,5 @@
(let [content-type (get-in resp [:headers "Content-Type"] "")
body (:body resp)]
(if (and body (not (string? body)) (re-find #"json|edn|transit" content-type))
(update resp :body #(m/decode http/muuntaja content-type %))
(update resp :body #(m/decode http/muuntaja (first (str/split content-type #";")) %))
resp)))