From 31e03d552ba648c842c1fa4fda8d4a7a7b97e290 Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 07:49:15 -0900 Subject: [PATCH] handle formats --- README.md | 14 +++- integration-test/integration_test.bb | 11 ++++ test/tbd/http_test.clj | 99 ++++++++++++++++++++++++++++ test/tbd/math/routes_test.clj | 5 +- test/tbd/test_util.clj | 5 +- 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 test/tbd/http_test.clj diff --git a/README.md b/README.md index 9a765a1..1e35124 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/integration-test/integration_test.bb b/integration-test/integration_test.bb index 8088504..f90fa40 100755 --- a/integration-test/integration_test.bb +++ b/integration-test/integration_test.bb @@ -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")))) diff --git a/test/tbd/http_test.clj b/test/tbd/http_test.clj new file mode 100644 index 0000000..046c7e6 --- /dev/null +++ b/test/tbd/http_test.clj @@ -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"}))))))))) diff --git a/test/tbd/math/routes_test.clj b/test/tbd/math/routes_test.clj index 02a8fd2..8c51690 100644 --- a/test/tbd/math/routes_test.clj +++ b/test/tbd/math/routes_test.clj @@ -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" "*/*"}})] diff --git a/test/tbd/test_util.clj b/test/tbd/test_util.clj index 894ed3d..3b9ca13 100644 --- a/test/tbd/test_util.clj +++ b/test/tbd/test_util.clj @@ -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)))