From b57e1a406dd6c3533659802870a0b99e726c3990 Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 05:42:17 -0900 Subject: [PATCH] init commit --- .dockerignore | 3 + .gitignore | 3 + Dockerfile | 23 +++ bb.edn | 27 +++ buildkitd.toml | 2 + deps.edn | 23 +++ integration-tests/Dockerfile | 5 + integration-tests/README.md | 80 ++++++++ integration-tests/docker-compose.coverage.yml | 24 +++ integration-tests/docker-compose.yml | 25 +++ integration-tests/integration_test.bb | 179 ++++++++++++++++++ .../vibe/integration/coverage.clj | 31 +++ scripts/coverage_report.clj | 136 +++++++++++++ scripts/integration_tasks.clj | 79 ++++++++ src/vibe/core.clj | 60 ++++++ src/vibe/health/routes.clj | 10 + src/vibe/math/core.clj | 5 + src/vibe/math/routes.clj | 24 +++ test/vibe/math/core_test.clj | 15 ++ 19 files changed, 754 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bb.edn create mode 100644 buildkitd.toml create mode 100644 deps.edn create mode 100644 integration-tests/Dockerfile create mode 100644 integration-tests/README.md create mode 100644 integration-tests/docker-compose.coverage.yml create mode 100644 integration-tests/docker-compose.yml create mode 100755 integration-tests/integration_test.bb create mode 100644 integration-tests/vibe/integration/coverage.clj create mode 100644 scripts/coverage_report.clj create mode 100644 scripts/integration_tasks.clj create mode 100644 src/vibe/core.clj create mode 100644 src/vibe/health/routes.clj create mode 100644 src/vibe/math/core.clj create mode 100644 src/vibe/math/routes.clj create mode 100644 test/vibe/math/core_test.clj diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a0267a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +target +.cpcache +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56b967f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.cpcache +target + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7152e7f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM clojure:openjdk-11-tools-deps + +RUN sed -i \ + -e 's|deb.debian.org/debian|archive.debian.org/debian|g' \ + -e '/security.debian.org/d' \ + /etc/apt/sources.list \ + && apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Aliases whose deps get pre-fetched into the image; the coverage compose override adds `cov`. +ARG DEPS_ALIASES=run + +COPY deps.edn ./ +RUN clojure -P -X:${DEPS_ALIASES} \ + && echo "[build] cached jars: $(find /root/.m2 -name '*.jar' | wc -l)" + +COPY . . + +EXPOSE 3000 + +CMD ["clojure", "-X:run"] diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..6743686 --- /dev/null +++ b/bb.edn @@ -0,0 +1,27 @@ +{:paths ["integration-tests" "scripts"] + :override-builtin true + :tasks + {:requires ([integration-tasks :as it]) + unit-test {:doc "Run the Clojure unit tests (test/ directory)." + :task (shell "clojure" "-M:test")} + test {:doc "Run the unit tests, then the integration tests (stops at the first failure)." + :task (do (run 'unit-test) + (run 'integration-test))} + repl {:doc "Start an nREPL server with dev dependencies" + :task (shell "clojure" "-A:dev" "-M" "-m" "nrepl.cmdline" + "--middleware" "[cider.nrepl/cider-middleware]" + "--port" (try (slurp ".nrepl-port") (catch Exception _ "3000")))} + integration-test {:doc "Run the dockerized integration tests in isolation (builds the app image, starts it on a random port, runs the test suite against it, then tears everything down)." + :task (it/run-integration {})} + unit-coverage {:doc "Run the unit tests under Cloverage; reports go to target/coverage/unit." + :task (shell "clojure" "-M:cov" "-m" "cloverage.coverage" + "--src-ns-path" "src" "--test-ns-path" "test" + "--lcov" "--html" "--output" "target/coverage/unit")} + integration-coverage {:doc "Run the dockerized integration tests against a Cloverage-instrumented app; reports go to target/coverage/integration." + :task (it/run-integration {:coverage? true})} + coverage-report {:doc "Merge the unit and integration line-coverage results into target/coverage/merged and print a summary." + :task (shell "bb" "scripts/coverage_report.clj")} + coverage {:doc "Run the full suite (unit + integration) with line coverage and write the joint report to target/coverage/merged." + :task (do (run 'unit-coverage) + (run 'integration-coverage) + (run 'coverage-report))}}} diff --git a/buildkitd.toml b/buildkitd.toml new file mode 100644 index 0000000..3bb6ab2 --- /dev/null +++ b/buildkitd.toml @@ -0,0 +1,2 @@ +[worker.oci] + apparmor-profile = "" diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..94d3143 --- /dev/null +++ b/deps.edn @@ -0,0 +1,23 @@ +{:paths ["src" "resources" "integration-tests"] + :deps + {org.clojure/clojure {:mvn/version "1.12.0"} + ring/ring-jetty-adapter {:mvn/version "1.12.2"} + ring/ring-defaults {:mvn/version "0.4.0"} + metosin/reitit {:mvn/version "0.7.2"}} + + :aliases + {:dev + {:extra-deps + {nrepl/nrepl {:mvn/version "1.2.0"} + cider/cider-nrepl {:mvn/version "0.48.0"} + org.babashka/http-client {:mvn/version "0.4.25"}}} + :test + {:extra-paths ["test"] + :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} + :main-opts ["-m" "cognitect.test-runner"]} + ;; Line coverage via Cloverage. Only adds the dep + test path; the entry points + ;; (unit: cloverage.coverage, integration: vibe.integration.coverage) are chosen in bb.edn. + :cov + {:extra-paths ["test"] + :extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}}} + :run {:exec-fn vibe.core/start-server}}} diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile new file mode 100644 index 0000000..4f301a4 --- /dev/null +++ b/integration-tests/Dockerfile @@ -0,0 +1,5 @@ +FROM babashka/babashka:latest + +COPY integration_test.bb /integration_test.bb + +ENTRYPOINT ["bb", "/integration_test.bb"] diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 0000000..0898570 --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,80 @@ +# Integration Tests + +Dockerized integration tests for the vibe server. + +## Quick Start + +Run the full test suite (builds the app, starts it, runs tests): + +```bash +cd integration-tests +docker-compose up --abort-on-container-exit +``` + +## Manual Run (app already running) + +If your vibe app is running locally on port 3000: + +```bash +cd integration-tests +docker run --rm -v "$(pwd)/integration_test.bb:/integration_test.bb:ro" babashka/babashka:latest bb /integration_test.bb http://host.docker.internal:3000 +``` + +Or run directly on a host with babashka installed: + +```bash +cd integration-tests +bb integration_test.bb http://localhost:3000 +``` + +Or from the repo root, via the `bb.edn` task: + +```bash +bb integration-test +``` + +### Options + +| Flag | Default | Description | +|------|---------|--------------| +| `[base-url]` (positional) | `http://localhost:3000` | Server to test against | +| `--watch` | off | Re-run the suite on a timer instead of exiting after one run | +| `--interval ` | `5000` | Delay between runs in watch mode | + +Example — watch a local server every 2 seconds: + +```bash +bb integration-test http://localhost:3000 --watch --interval 2000 +``` + +## What's Tested + +| # | Test | Checks | +|---|------|--------| +| 1 | `GET /` returns 200 | HTTP status code | +| 2 | `Content-Type` header | `text/plain` | +| 3 | Response body | Contains expected Clojure/Ring content | +| 4 | Not HTML | Confirms plain text response | +| 5 | `HEAD /` works | HTTP 200 | + +## Line Coverage + +Line coverage is measured with [Cloverage](https://github.com/cloverage/cloverage) for the +unit tests, the integration tests, and both combined. From the repo root: + +| Task | What it does | Output | +|------|--------------|--------| +| `bb unit-coverage` | Runs `test/` under Cloverage | `target/coverage/unit/` | +| `bb integration-coverage` | Runs the integration suite against a Cloverage-instrumented `vibe-app` container | `target/coverage/integration/` | +| `bb coverage-report` | Merges the two `lcov.info` files, prints a per-file table | `target/coverage/merged/` | +| `bb coverage` | All three, in order (the full suite with a joint report) | all of the above | + +Each directory contains an `index.html` (open it in a browser) and an `lcov.info`. In the +merged report a line counts as covered if either suite hit it; the HTML shows per-line hit +counts per suite (`u:` unit, `i:` integration). + +How the integration numbers are collected: `docker-compose.coverage.yml` starts the app via +`vibe.integration.coverage`, which lets Cloverage instrument `src/` and then serves real +traffic until it receives SIGTERM. `bb integration-coverage` stops the container +(`docker compose stop`) so the report gets written, then copies it out with `docker compose cp`. +Plain `bb integration-test` / `bb test` are unchanged and run the uninstrumented app. diff --git a/integration-tests/docker-compose.coverage.yml b/integration-tests/docker-compose.coverage.yml new file mode 100644 index 0000000..67404c1 --- /dev/null +++ b/integration-tests/docker-compose.coverage.yml @@ -0,0 +1,24 @@ +# Overlay for `bb integration-coverage`: runs vibe-app under Cloverage instead of plainly. +# Reports land in /app/target/coverage/integration inside the container; bb.edn copies +# them out after the app is stopped (SIGTERM is what triggers the report). +services: + vibe-app: + build: + args: + DEPS_ALIASES: run:cov + command: + - clojure + - -M:cov + - -m + - vibe.integration.coverage + - --runner + - integration + - --src-ns-path + - src + - --extra-test-ns + - vibe.integration.coverage + - --lcov + - --html + - --output + - /app/target/coverage/integration + stop_grace_period: 2m diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml new file mode 100644 index 0000000..3d66cc7 --- /dev/null +++ b/integration-tests/docker-compose.yml @@ -0,0 +1,25 @@ +services: + vibe-app: + build: + context: .. + dockerfile: Dockerfile + ports: + - "${VIBE_PORT:-3000}:${VIBE_PORT:-3000}" + security_opt: + - apparmor:unconfined + environment: + - PORT=${VIBE_PORT:-3000} + # No container healthcheck: healthchecks run via `docker exec`, which hits the + # same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and + # always fails here. integration_test.bb polls the server until it's ready. + + integration-test: + image: babashka/babashka:latest + security_opt: + - apparmor:unconfined + volumes: + - ./integration_test.bb:/integration_test.bb:ro + depends_on: + vibe-app: + condition: service_started + entrypoint: ["bb", "/integration_test.bb", "http://vibe-app:${VIBE_PORT:-3000}"] diff --git a/integration-tests/integration_test.bb b/integration-tests/integration_test.bb new file mode 100755 index 0000000..8b6a498 --- /dev/null +++ b/integration-tests/integration_test.bb @@ -0,0 +1,179 @@ +#!/usr/bin/env bb + +(ns integration-test + (:require [babashka.http-client :as http] + [clojure.edn :as edn] + [clojure.string :as str])) + +(def default-config + {:base-url "http://localhost:3000" + :max-retries 30 + :retry-delay-ms 1000 + :watch? false + :watch-interval-ms 5000}) + +(defn get* [base-url] + (http/get base-url {:throw false})) + +(defn head* [base-url] + (http/head base-url {:throw false})) + +(defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}] + (println (str "=== Waiting for server at " base-url " ===")) + (loop [i 1] + (let [ready? (try (= 200 (:status (get* base-url))) (catch Exception _ false))] + (cond + ready? (do (println "Server is ready!") true) + (= i max-retries) (do (println (format "ERROR: Server did not become ready after %d attempts" max-retries)) + false) + :else (do (Thread/sleep retry-delay-ms) (recur (inc i))))))) + +(defn run-suite + "Runs the integration suite once against config's :base-url. + Returns {:pass n :fail n :errors [...]}" + [{:keys [base-url] :as config}] + (if-not (wait-for-server config) + {:pass 0 :fail 1 :errors ["server did not become ready"]} + (let [pass (atom 0) + fail (atom 0) + errors (atom []) + assert-eq (fn [label expected actual] + (if (= expected actual) + (do (println " ✓" label) (swap! pass inc)) + (do (println " ✗" label) + (println " expected:" expected) + (println " actual: " actual) + (swap! fail inc) + (swap! errors conj (format "%s: expected '%s' but got '%s'" label expected actual))))) + assert-contains (fn [label needle haystack] + (if (str/includes? haystack needle) + (do (println " ✓" label) (swap! pass inc)) + (do (println " ✗" label) + (println " expected to contain:" needle) + (println " actual: " haystack) + (swap! fail inc) + (swap! errors conj (format "%s: did not find '%s'" label needle))))) + assert-not-empty (fn [label value] + (if-not (str/blank? value) + (do (println " ✓" label) (swap! pass inc)) + (do (println " ✗" label) + (println " expected non-empty value") + (swap! fail inc) + (swap! errors conj (format "%s: value was empty" label)))))] + (println) + (println "=== Running Integration Tests ===") + (println) + + (println "Test 1: GET / returns 200") + (assert-eq "HTTP status" 200 (:status (get* base-url))) + (println) + + (println "Test 2: Content-Type is text/plain") + (assert-contains "Content-Type" "text/plain" (get (:headers (get* base-url)) "content-type" "")) + (println) + + (println "Test 3: Root response points at the API docs") + (let [body (:body (get* base-url))] + (assert-contains "Body mentions /api-docs" "/api-docs" body) + (assert-not-empty "Body is non-empty" body) + (println) + + (println "Test 4: Response is plain text, not HTML") + (if (and (not (str/includes? body "]" + [args] + (loop [args args + overrides {}] + (if (empty? args) + overrides + (let [[arg & more] args] + (cond + (= arg "--watch") (recur more (assoc overrides :watch? true)) + (= arg "--interval") (recur (rest more) (assoc overrides :watch-interval-ms (parse-long (first more)))) + :else (recur more (assoc overrides :base-url arg))))))) + +(defn -main [& args] + (let [config (merge default-config (parse-args args)) + {:keys [watch?]} config] + (if watch? + (do + (println "Watch mode: watching for file changes. Press Ctrl+C to stop.") + (println "Watching: integration_test.bb") + (println) + ;; Run once, then wait for inotify events + (run-suite config) + (println) + (println "Waiting for file changes...") + (println) + ;; Use inotifywait to watch the file for modifications + (let [proc (Runtime/exec (into-array String ["inotifywait" "-e" "modify" "-q" "integration_test.bb"]))] + (try + (.waitFor proc) + (finally + (.destroy proc))) + (recur args))) + (let [{:keys [fail]} (run-suite config)] + (System/exit (if (pos? fail) 1 0)))))) + + +(comment + (run-suite {:base-url "http://localhost:3000"})) + +;; bb runs this file as a script, not via -m — nothing calls -main otherwise. +(apply -main *command-line-args*) + diff --git a/integration-tests/vibe/integration/coverage.clj b/integration-tests/vibe/integration/coverage.clj new file mode 100644 index 0000000..e87ef90 --- /dev/null +++ b/integration-tests/vibe/integration/coverage.clj @@ -0,0 +1,31 @@ +(ns vibe.integration.coverage + "Entry point that runs the vibe server under Cloverage instrumentation so the + black-box integration tests produce a line-coverage report. + + Cloverage normally instruments the source and then runs a test suite in-process. + Here the 'suite' is the outside world: the runner starts the real server and blocks + until the JVM gets SIGTERM (`docker compose stop`), then Cloverage writes its reports. + + Deliberately does not require any vibe.* source namespace at load time: those must + be loaded by Cloverage (instrumented), not by us." + (:require [cloverage.coverage :as cov])) + +(defmethod cov/runner-fn :integration [_] + (fn [_nses] + (let [stop (promise) + main (Thread/currentThread)] + ((requiring-resolve 'vibe.core/start-server)) + ;; The report is written by the main thread after the runner returns, but the JVM + ;; only stays up while shutdown hooks run, so the hook must wait for the main thread. + (.addShutdownHook (Runtime/getRuntime) + (Thread. (fn [] + (deliver stop true) + (.join main)))) + @stop + {:errors 0}))) + +(defn -main [& args] + ;; Cloverage's own System/exit would deadlock inside the shutdown hook above. + (binding [cov/*exit-after-test* false] + (apply cov/-main args)) + (shutdown-agents)) diff --git a/scripts/coverage_report.clj b/scripts/coverage_report.clj new file mode 100644 index 0000000..3753a7f --- /dev/null +++ b/scripts/coverage_report.clj @@ -0,0 +1,136 @@ +#!/usr/bin/env bb +;; Merges the unit and integration LCOV files into one line-coverage report. +;; +;; bb scripts/coverage_report.clj [--out target/coverage/merged] +;; +;; Reads target/coverage/{unit,integration}/lcov.info (either may be missing), prints a +;; per-file table (unit / integration / merged) and writes the merged result to +;; /lcov.info and /index.html. A line counts as covered in the merged report +;; if either suite hit it. + +(ns coverage-report + (:require [babashka.cli :as cli] + [clojure.java.io :as io] + [clojure.string :as str])) + +(def suites + [[:unit "target/coverage/unit/lcov.info"] + [:integration "target/coverage/integration/lcov.info"]]) + +(defn parse-lcov + "lcov.info text -> {file {line hits}}" + [text] + (loop [[l & more] (str/split-lines text), file nil, acc {}] + (cond + (nil? l) acc + (str/starts-with? l "SF:") (recur more (subs l 3) (update acc (subs l 3) #(or % {}))) + (str/starts-with? l "DA:") (let [[ln hits] (map parse-long (str/split (subs l 3) #","))] + (recur more file (update-in acc [file ln] (fnil + 0) hits))) + :else (recur more file acc)))) + +(defn stats [lines] + (let [total (count lines) + hit (count (filter pos? (vals lines)))] + {:hit hit :total total :pct (if (zero? total) 100.0 (* 100.0 (/ hit total)))})) + +(defn fmt [{:keys [hit total pct]}] + (format "%3d/%-3d %6.1f%%" hit total pct)) + +(defn read-source [file] + ;; Cloverage's SF: paths are usually project-relative already (src/vibe/core.clj); fall + ;; back to the source roots in case they are classpath-relative. + (some (fn [dir] (let [f (io/file dir file)] (when (.isFile f) (slurp f)))) + [nil "src" "test" "integration-tests"])) + +(defn esc [s] + (-> s (str/replace "&" "&") (str/replace "<" "<") (str/replace ">" ">"))) + +(defn html-report [per-suite merged] + (let [pct-class #(cond (>= % 80) "hi" (>= % 50) "mid" :else "lo") + row (fn [label lines] + (let [s (stats lines)] + (format "%.1f%% (%d/%d)" + (pct-class (:pct s)) (:pct s) (:hit s) (:total s)))) + files (sort (keys merged))] + (str "vibe line coverage + +

vibe line coverage

+" + (apply str (for [k (keys per-suite)] (str ""))) + "" + (apply str + (for [f (concat files [nil])] + (str "" + (apply str (for [k (keys per-suite)] + (row k (if f (get-in per-suite [k f] {}) + (into {} (for [[ff ls] (get per-suite k) + [l h] ls] [[ff l] h])))))) + (row :merged (if f (merged f) + (into {} (for [[ff ls] merged [l h] ls] [[ff l] h])))) + ""))) + "
File" (name k) "merged
" (if f (esc f) "Total") "

Sources

" + (apply str + (for [f files + :let [lines (merged f) + src (read-source f)] + :when src] + (str "
" (esc f) "
"
+                       (apply str
+                              (map-indexed
+                               (fn [i text]
+                                 (let [n (inc i) h (get lines n)]
+                                   (format "%4d  %s%s"
+                                           (cond (nil? h) "" (pos? h) "c" :else "u")
+                                           n
+                                           (if h
+                                             (str/join " " (for [k (keys per-suite)]
+                                                             (str (subs (name k) 0 1) ":" (get-in per-suite [k f n] 0))))
+                                             "")
+                                           (esc text))))
+                               (str/split-lines src)))
+                       "
")))))) + +(defn -main [& args] + (let [{:keys [out]} (cli/parse-opts args {:coerce {:out :string}}) + out (or out "target/coverage/merged") + per-suite (into {} (for [[k path] suites + :when (.exists (io/file path))] + [k (parse-lcov (slurp path))]))] + (when (empty? per-suite) + (println "No lcov.info found; run `bb unit-coverage` and/or `bb integration-coverage` first.") + (System/exit 1)) + (let [merged (apply merge-with (fn [a b] (merge-with + a b)) (vals per-suite)) + cols (keys per-suite) + files (sort (keys merged)) + width (apply max 5 (map count files)) + hdr (str (format (str "%-" width "s") "File") + (apply str (for [k (concat cols [:merged])] (format " %-16s" (name k)))))] + (println hdr) + (println (apply str (repeat (count hdr) "-"))) + (doseq [f files] + (println (str (format (str "%-" width "s") f) + (apply str (for [k cols] (str " " (fmt (stats (get-in per-suite [k f] {})))))) + " " (fmt (stats (merged f)))))) + (println (apply str (repeat (count hdr) "-"))) + (let [flat (fn [m] (into {} (for [[f ls] m [l h] ls] [[f l] h])))] + (println (str (format (str "%-" width "s") "Total") + (apply str (for [k cols] (str " " (fmt (stats (flat (per-suite k))))))) + " " (fmt (stats (flat merged)))))) + (.mkdirs (io/file out)) + (spit (io/file out "lcov.info") + (apply str (for [f files] + (str "TN:\nSF:" f "\n" + (apply str (for [[l h] (sort (merged f))] (str "DA:" l "," h "\n"))) + "LF:" (count (merged f)) "\nLH:" (:hit (stats (merged f))) + "\nend_of_record\n")))) + (spit (io/file out "index.html") (html-report per-suite merged)) + (println (str "\nMerged report: " out "/index.html (+ lcov.info)"))))) + +(apply -main *command-line-args*) diff --git a/scripts/integration_tasks.clj b/scripts/integration_tasks.clj new file mode 100644 index 0000000..bee1f0b --- /dev/null +++ b/scripts/integration_tasks.clj @@ -0,0 +1,79 @@ +(ns integration-tasks + "Shared implementation for the dockerized integration bb tasks (see bb.edn)." + (:require [babashka.process :refer [shell]])) + +(defn run-integration + "Builds the app image, starts it on a random port, runs the integration suite against + it, then tears everything down and exits with the suite's exit code. + + With :coverage? the app runs under Cloverage (docker-compose.coverage.yml); after the + suite finishes the app is stopped gracefully so it writes its report, which is copied + to target/coverage/integration." + [{:keys [coverage?]}] + (let [free-port-in-range + ;; Picks a random port in [lo, hi] that's currently bindable on this + ;; host; retries on collision since another process could grab it first. + (fn [lo hi] + (loop [attempts 20] + (let [port (+ lo (rand-int (inc (- hi lo))))] + (if (try + (.close (java.net.ServerSocket. port)) + true + (catch java.io.IOException _ false)) + port + (if (pos? attempts) + (recur (dec attempts)) + (throw (ex-info "no free port found in range" {:lo lo :hi hi}))))))) + port (free-port-in-range 30000 40000) + _ (println (format "[test] starting vibe-app on random port %d%s" port + (if coverage? " (under coverage instrumentation)" ""))) + builder-name "vibe-builder" + compose (into ["docker" "compose"] + (when coverage? + ["-f" "docker-compose.yml" "-f" "docker-compose.coverage.yml"])) + ;; Works around an LXC/AppArmor bug in runc (opencontainers/runc#5007) that + ;; fails RUN steps during image build; a docker-container builder with + ;; apparmor disabled sidesteps it. + _ (let [{:keys [exit]} (shell {:continue true} "docker" "buildx" "inspect" builder-name)] + (when-not (zero? exit) + (shell "docker" "buildx" "create" "--name" builder-name + "--driver" "docker-container" + "--buildkitd-config" "buildkitd.toml"))) + _ (shell "docker" "buildx" "use" builder-name) + ;; Runs from a JVM shutdown hook (normal exit and Ctrl-C), where + ;; babashka.process/shell fails: it registers its own shutdown hook, + ;; which the JVM rejects with "Shutdown in progress". Plain + ;; ProcessBuilder has no such hook and lets us wait for `down` to finish. + teardown (fn [] + (-> (doto (ProcessBuilder. ^java.util.List (vec (concat compose ["down" "-v"]))) + (.directory (java.io.File. "integration-tests")) + (.inheritIO)) + .start + .waitFor)) + _ (.addShutdownHook (Runtime/getRuntime) (Thread. teardown)) + {:keys [exit]} (apply shell {:dir "integration-tests" :continue true + :extra-env {"VIBE_PORT" (str port)}} + ;; `run` returns the test container's own exit code; `up --abort-on-container-exit` + ;; can leak the app's SIGTERM exit (143) during teardown instead. + (concat compose ["--ansi" "never" "--progress" "plain" + "run" "--build" "--rm" "integration-test"]))] + (when coverage? + ;; SIGTERM is what makes Cloverage write its report, so stop (not kill) the app + ;; and pull the report out of the stopped container before `down` removes it. + (let [out-dir (java.io.File. "target/coverage") + dest (java.io.File. out-dir "integration")] + (println "[test] stopping vibe-app to flush coverage report") + (apply shell {:dir "integration-tests" :continue true} + (concat compose ["stop" "-t" "120" "vibe-app"])) + (when (.exists dest) + (run! (fn [^java.io.File f] (.delete f)) (reverse (file-seq dest)))) + (.mkdirs out-dir) + (let [{cp-exit :exit} (apply shell {:dir "integration-tests" :continue true} + (concat compose ["cp" "vibe-app:/app/target/coverage/integration" + (.getAbsolutePath out-dir)]))] + (when-not (and (zero? cp-exit) (.exists (java.io.File. dest "lcov.info"))) + (println "[test] WARNING: no integration coverage report was produced"))))) + ;; Only exit early on failure so chained tasks (`bb coverage`) can carry on after a pass; + ;; the teardown hook still runs when bb exits. + (when-not (zero? exit) + (System/exit exit)))) diff --git a/src/vibe/core.clj b/src/vibe/core.clj new file mode 100644 index 0000000..35c62da --- /dev/null +++ b/src/vibe/core.clj @@ -0,0 +1,60 @@ +(ns vibe.core + (:require [ring.adapter.jetty :as jetty] + [ring.middleware.defaults :refer [wrap-defaults api-defaults]] + [ring.middleware.head :refer [wrap-head]] + [reitit.ring :as ring] + [reitit.swagger :as swagger] + [reitit.swagger-ui :as swagger-ui] + [reitit.ring.middleware.muuntaja :as muuntaja-middleware] + [muuntaja.core :as m] + [vibe.health.routes :as health] + [vibe.math.routes :as math])) + +;; Each logical group (health, users, orders, ...) owns a `vibe..routes` +;; namespace that returns its own route subtree; they're concatenated here. +(def router + (ring/router + [["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "vibe API" :version "1.0.0"}} + :handler (swagger/create-swagger-handler)}}] + ["/" {:get {:no-doc true + :handler (fn [_req] + {:status 200 + :headers {"Content-Type" "text/plain; charset=utf-8"} + :body "vibe API — see /api-docs for documentation, /swagger.json for the spec"})}}] + (health/routes) + (math/routes)] + {:data {:muuntaja m/instance + :middleware [muuntaja-middleware/format-middleware]}})) + +(def app + (-> (ring/ring-handler + router + (ring/routes + (swagger-ui/create-swagger-ui-handler {:path "/api-docs"}) + (ring/create-default-handler))) + wrap-head)) + +(defn add-shutdown-hook [server] + (.addShutdownHook + (Runtime/getRuntime) + (Thread. (fn [] (.stop server 1000))))) + +(defn start-server [& [_ opts]] + (let [port (Integer/parseInt (str (or (System/getenv "PORT") + (get opts :port 3000))))] + (println (format "[vibe] starting server on port %d..." port)) + (try + (let [server (jetty/run-jetty (wrap-defaults app api-defaults) + {:port port + :join? false})] + (println (format "vibe server running on http://localhost:%d" port)) + (add-shutdown-hook server)) + (catch Throwable t + (println "[vibe] FAILED to start server:" (.getMessage t)) + (.printStackTrace t) + (throw t))))) + +(defn -main [& [_ port]] + (start-server)) diff --git a/src/vibe/health/routes.clj b/src/vibe/health/routes.clj new file mode 100644 index 0000000..cbb0a89 --- /dev/null +++ b/src/vibe/health/routes.clj @@ -0,0 +1,10 @@ +(ns vibe.health.routes) + +(defn routes [] + ["/api/ping" + {:get {:tags ["health"] + :summary "Ping the server" + :handler (fn [_req] + {:status 200 + :headers {"Content-Type" "text/plain; charset=utf-8"} + :body "pong"})}}]) diff --git a/src/vibe/math/core.clj b/src/vibe/math/core.clj new file mode 100644 index 0000000..b0f6d0c --- /dev/null +++ b/src/vibe/math/core.clj @@ -0,0 +1,5 @@ +(ns vibe.math.core + "Business logic for the math group. No HTTP concerns in here.") + +(defn add [x y] + (+ x y)) diff --git a/src/vibe/math/routes.clj b/src/vibe/math/routes.clj new file mode 100644 index 0000000..e60da7c --- /dev/null +++ b/src/vibe/math/routes.clj @@ -0,0 +1,24 @@ +(ns vibe.math.routes + (:require [vibe.math.core :as math])) + +(defn- parse-num [s] + (when s + (or (parse-long s) (parse-double s)))) + +(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 routes [] + ["/api/add" + {:get {:tags ["math"] + :summary "Add two numbers (?x=2&y=3 => {:result 5})" + :handler add-handler}}]) diff --git a/test/vibe/math/core_test.clj b/test/vibe/math/core_test.clj new file mode 100644 index 0000000..887dad8 --- /dev/null +++ b/test/vibe/math/core_test.clj @@ -0,0 +1,15 @@ +(ns vibe.math.core-test + (:require [clojure.test :refer [deftest is testing]] + [vibe.math.core :as math])) + +(deftest add-test + (testing "integers" + (is (= 5 (math/add 2 3))) + (is (= 0 (math/add 0 0)))) + (testing "negatives" + (is (= -1 (math/add 2 -3))) + (is (= -5 (math/add -2 -3)))) + (testing "decimals" + (is (= 3.5 (math/add 1.5 2)))) + (testing "is commutative" + (is (= (math/add 4 7) (math/add 7 4)))))