@@ -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
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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"})))))))))
|
||||
@@ -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" "*/*"}})]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user