├── .github └── FUNDING.yml ├── resources ├── config.edn └── database │ └── migrations │ ├── V2__add_events_table.sql │ └── V1__add_todo_tables.sql ├── .gitignore ├── src └── real_world_clojure_api │ ├── config.clj │ ├── components │ ├── example_component.clj │ ├── in_memory_state_component.clj │ ├── htmx_pedestal_component.clj │ └── pedestal_component.clj │ ├── routes │ └── htmx │ │ ├── shared.clj │ │ ├── active_search.clj │ │ ├── delete_with_confirmation.clj │ │ ├── comments_section.clj │ │ ├── click_to_edit.clj │ │ └── infinite_scroll.clj │ └── core.clj ├── docker-compose.yml ├── dev └── dev.clj ├── test ├── unit │ └── real_world_clojure_api │ │ └── simple_test.clj ├── persistence │ └── real_world_clojure_api │ │ ├── simple_test.clj │ │ ├── migrations_test.clj │ │ ├── honeysql_test.clj │ │ └── event_sourcing_test.clj └── component │ └── real_world_clojure_api │ ├── info_handler_test.clj │ ├── todo_api_test.clj │ └── api_test.clj ├── deps.edn └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: andfadeev 2 | ko_fi: andreyfadeev 3 | buy_me_a_coffee: andrey.fadeev 4 | -------------------------------------------------------------------------------- /resources/config.edn: -------------------------------------------------------------------------------- 1 | {:server {:port #long #or [#env REAL_WORLD_CLOJURE_API_SERVER_PORT 8080]} 2 | :htmx {:server {:port #long #or [#env RWCA_HTMX_SERVER_PORT 8081]}}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | .cpcache 4 | .nrepl-port 5 | .clj-kondo/.cache 6 | .clj-kondo/prismatic 7 | .clj-kondo/com.github.seancorfield 8 | .calva 9 | .fleet 10 | .lsp 11 | -------------------------------------------------------------------------------- /src/real_world_clojure_api/config.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.config 2 | (:require [aero.core :as aero] 3 | [clojure.java.io :as io])) 4 | 5 | (defn read-config 6 | [] 7 | (-> "config.edn" 8 | (io/resource) 9 | (aero/read-config))) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | database: 5 | image: postgres:15.4 6 | ports: 7 | - '5432:5432' 8 | environment: 9 | - 'POSTGRES_USER=rwca' 10 | - 'POSTGRES_PASSWORD=rwca' 11 | - 'POSTGRES_DB=rwca' 12 | - "POSTGRES_INITDB_ARGS='--encoding=UTF-8'" -------------------------------------------------------------------------------- /resources/database/migrations/V2__add_events_table.sql: -------------------------------------------------------------------------------- 1 | create table events 2 | ( 3 | id uuid primary key default gen_random_uuid(), 4 | type text not null, 5 | aggregate_id uuid not null, 6 | aggregate_type text not null, 7 | payload jsonb not null, 8 | created_at timestamp not null default current_timestamp 9 | ); -------------------------------------------------------------------------------- /dev/dev.clj: -------------------------------------------------------------------------------- 1 | (ns dev 2 | (:require [com.stuartsierra.component.repl :as component-repl] 3 | [real-world-clojure-api.core :as core])) 4 | 5 | (component-repl/set-init 6 | (fn [_] 7 | (core/real-world-clojure-api-system 8 | {:server {:port 3001} 9 | :htmx {:server {:port 3002}} 10 | :db-spec {:jdbcUrl "jdbc:postgresql://localhost:5432/rwca" 11 | :username "rwca" 12 | :password "rwca"}}))) -------------------------------------------------------------------------------- /resources/database/migrations/V1__add_todo_tables.sql: -------------------------------------------------------------------------------- 1 | create table todo 2 | ( 3 | todo_id uuid primary key default gen_random_uuid(), 4 | created_at timestamp not null default current_timestamp, 5 | title text not null 6 | ); 7 | 8 | create table todo_item 9 | ( 10 | todo_item_id uuid primary key default gen_random_uuid(), 11 | todo_id uuid references todo (todo_id), 12 | created_at timestamp not null default current_timestamp, 13 | title text not null 14 | ); -------------------------------------------------------------------------------- /src/real_world_clojure_api/components/example_component.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.components.example-component 2 | (:require [com.stuartsierra.component :as component])) 3 | 4 | (defrecord ExampleComponent 5 | [config] 6 | component/Lifecycle 7 | 8 | (start [component] 9 | (println "Starting ExampleComponent") 10 | (assoc component :state ::started)) 11 | 12 | (stop [component] 13 | (println "Stopping ExampleComponent") 14 | (assoc component :state nil))) 15 | 16 | (defn new-example-component 17 | [config] 18 | (map->ExampleComponent {:config config})) -------------------------------------------------------------------------------- /test/unit/real_world_clojure_api/simple_test.clj: -------------------------------------------------------------------------------- 1 | (ns unit.real-world-clojure-api.simple-test 2 | (:require [clojure.test :refer :all] 3 | [real-world-clojure-api.components.pedestal-component :refer [url-for]])) 4 | 5 | 6 | (deftest a-simple-passing-test 7 | (is (= 1 1))) 8 | 9 | (deftest url-for-test 10 | (testing "greet endpoint url" 11 | (is (= "/greet" (url-for :greet)))) 12 | 13 | (testing "get todo by id endpoint url" 14 | (let [todo-id (random-uuid)] 15 | (is (= (str "/todo/" todo-id) 16 | (url-for :get-todo {:path-params {:todo-id todo-id}})))))) 17 | -------------------------------------------------------------------------------- /test/persistence/real_world_clojure_api/simple_test.clj: -------------------------------------------------------------------------------- 1 | (ns persistence.real-world-clojure-api.simple-test 2 | (:require [clojure.test :refer :all] 3 | [next.jdbc :as jdbc]) 4 | (:import (org.testcontainers.containers PostgreSQLContainer))) 5 | 6 | (deftest a-simple-persistence-test 7 | (let [database-container (doto (PostgreSQLContainer. "postgres:15.4") 8 | (.withDatabaseName "real-world-clojure-api-db") 9 | (.withUsername "test") 10 | (.withPassword "test"))] 11 | (try 12 | (.start database-container) 13 | (let [ds (jdbc/get-datasource {:jdbcUrl (.getJdbcUrl database-container) 14 | :user (.getUsername database-container) 15 | :password (.getPassword database-container)})] 16 | (is (= {:r 1} (first (jdbc/execute! ds ["select 1 as r;"]))))) 17 | (finally 18 | (.stop database-container))))) 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/real_world_clojure_api/components/in_memory_state_component.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.components.in-memory-state-component 2 | (:require [com.stuartsierra.component :as component])) 3 | 4 | (defrecord InMemoryStateComponent 5 | [config] 6 | component/Lifecycle 7 | 8 | (start [component] 9 | (println "Starting InMemoryStateComponent") 10 | (assoc component 11 | :state-atom (atom []) 12 | :htmx-click-to-edit-state 13 | (atom {"1" {:first-name "changeit" 14 | :last-name "changeit" 15 | :email "change@it.com"} 16 | "2" {:first-name "user2" 17 | :last-name "user2" 18 | :email "change@it.com"} 19 | "3" {:first-name "user3" 20 | :last-name "user3" 21 | :email "change@it.com"}}))) 22 | (stop [component] 23 | (println "Stopping InMemoryStateComponent") 24 | (assoc component 25 | :state-atom nil 26 | :htmx-click-to-edit-state nil))) 27 | 28 | (defn new-in-memory-state-component 29 | [config] 30 | (map->InMemoryStateComponent {:config config})) -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {aero/aero {:mvn/version "1.1.6"} 2 | io.pedestal/pedestal.service {:mvn/version "0.6.0"} 3 | io.pedestal/pedestal.route {:mvn/version "0.6.0"} 4 | io.pedestal/pedestal.jetty {:mvn/version "0.6.0"} 5 | org.slf4j/slf4j-simple {:mvn/version "2.0.7"} 6 | com.stuartsierra/component {:mvn/version "1.1.0"} 7 | com.stuartsierra/component.repl {:mvn/version "0.2.0"} 8 | clj-http/clj-http {:mvn/version "3.12.3"} 9 | prismatic/schema {:mvn/version "1.4.1"} 10 | 11 | org.testcontainers/testcontainers {:mvn/version "1.18.0"} 12 | org.testcontainers/postgresql {:mvn/version "1.18.0"} 13 | 14 | com.github.seancorfield/next.jdbc {:mvn/version "1.3.883"} 15 | org.postgresql/postgresql {:mvn/version "42.2.10"} 16 | 17 | com.zaxxer/HikariCP {:mvn/version "5.0.1"} 18 | 19 | com.github.seancorfield/honeysql {:mvn/version "2.4.1066"} 20 | 21 | org.flywaydb/flyway-core {:mvn/version "9.21.2"} 22 | 23 | hiccup/hiccup {:mvn/version "2.0.0-RC1"} 24 | faker/faker {:mvn/version "0.3.2"}} 25 | :aliases {:dev {:main-opts ["-e" "(require,'dev)" 26 | "-e" "(in-ns,'dev)"]} 27 | :test {:extra-paths ["test"] 28 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 29 | :main-opts ["-m" "cognitect.test-runner"] 30 | :exec-fn cognitect.test-runner.api/test}} 31 | :paths ["src" "resources" "dev" "test"]} -------------------------------------------------------------------------------- /test/component/real_world_clojure_api/info_handler_test.clj: -------------------------------------------------------------------------------- 1 | (ns component.real-world-clojure-api.info-handler-test 2 | (:require [cheshire.core :as json] 3 | [clojure.string :as str] 4 | [clojure.test :refer :all] 5 | [com.stuartsierra.component :as component] 6 | [real-world-clojure-api.components.pedestal-component :refer [url-for]] 7 | [real-world-clojure-api.core :as core] 8 | [clj-http.client :as client]) 9 | (:import (java.net ServerSocket) 10 | (org.testcontainers.containers PostgreSQLContainer))) 11 | 12 | (defmacro with-system 13 | [[bound-var binding-expr] & body] 14 | `(let [~bound-var (component/start ~binding-expr)] 15 | (try 16 | ~@body 17 | (finally 18 | (component/stop ~bound-var))))) 19 | 20 | (defn sut->url 21 | [sut path] 22 | (str/join ["http://localhost:" 23 | (-> sut :pedestal-component :config :server :port) 24 | path])) 25 | 26 | (defn get-free-port 27 | [] 28 | (with-open [socket (ServerSocket. 0)] 29 | (.getLocalPort socket))) 30 | 31 | (deftest info-handler-test 32 | (let [database-container (doto (PostgreSQLContainer. "postgres:15.4") 33 | (.withDatabaseName "real-world-clojure-api-db") 34 | (.withUsername "test") 35 | (.withPassword "test"))] 36 | (try 37 | (.start database-container) 38 | (with-system 39 | [sut (core/real-world-clojure-api-system 40 | {:server {:port (get-free-port)} 41 | :htmx {:server {:port (get-free-port)}} 42 | :db-spec {:jdbcUrl (.getJdbcUrl database-container) 43 | :username (.getUsername database-container) 44 | :password (.getPassword database-container)}})] 45 | (is (= {:body "Database server version: 15.4 (Debian 15.4-1.pgdg120+1)" 46 | :status 200} 47 | (-> (sut->url sut (url-for :info)) 48 | (client/get {:accept :json}) 49 | (select-keys [:body :status]))))) 50 | (finally 51 | (.stop database-container))))) -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/shared.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.shared) 2 | 3 | (defn random-picture 4 | [] 5 | (let [skin-color ["Tanned" 6 | "Yellow" 7 | "Pale" 8 | "Light" 9 | "Brown" 10 | "DarkBrown" 11 | "Black"] 12 | top-type #{"Hat" 13 | "LongHairNotTooLong" 14 | "ShortHairDreads01" 15 | "ShortHairShortFlat" 16 | "ShortHairDreads02" 17 | "LongHairFrida" 18 | "WinterHat4" 19 | "Turban" 20 | "LongHairShavedSides" 21 | "ShortHairTheCaesarSidePart" 22 | "ShortHairShaggyMullet" 23 | "ShortHairShortWaved" 24 | "ShortHairFrizzle" 25 | "LongHairMiaWallace" 26 | "WinterHat2" 27 | "LongHairBigHair" 28 | "Hijab" 29 | "LongHairStraightStrand" 30 | "LongHairFroBand" 31 | "ShortHairSides" 32 | "NoHair" 33 | "ShortHairShortRound" 34 | "WinterHat1" 35 | "LongHairDreads" 36 | "ShortHairTheCaesar" 37 | "LongHairFro" 38 | "LongHairBun" 39 | "WinterHat3" 40 | "LongHairCurvy" 41 | "Eyepatch" 42 | "LongHairStraight" 43 | "LongHairStraight2" 44 | "ShortHairShortCurly" 45 | "LongHairBob" 46 | "LongHairCurly"} 47 | mouth-type #{"Tongue" "Default" "Smile" "Grimace" "Twinkle" "Disbelief" "Eating" "Sad" "Serious" "Concerned" "ScreamOpen" "Vomit"} 48 | hair-color #{"Platinum" "Black" "BlondeGolden" "BrownDark" "SilverGray" "Blue" "Brown" "Blonde" "Red" "PastelPink" "Auburn"}] 49 | (format "https://avataaars.io/?skinColor=%s&topType=%s&hairColor=%s&mouthType=%s" 50 | (first (shuffle skin-color)) 51 | (first (shuffle top-type)) 52 | (first (shuffle hair-color)) 53 | (first (shuffle mouth-type))))) -------------------------------------------------------------------------------- /src/real_world_clojure_api/components/htmx_pedestal_component.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.components.htmx-pedestal-component 2 | (:require [com.stuartsierra.component :as component] 3 | [io.pedestal.http :as http] 4 | [io.pedestal.http.route :as route] 5 | [io.pedestal.interceptor :as interceptor] 6 | [real-world-clojure-api.routes.htmx.click-to-edit 7 | :as click-to-edit] 8 | [real-world-clojure-api.routes.htmx.infinite-scroll 9 | :as infinite-scroll] 10 | [real-world-clojure-api.routes.htmx.active-search 11 | :as active-search] 12 | [real-world-clojure-api.routes.htmx.delete-with-confirmation 13 | :as delete-with-confirmation] 14 | [real-world-clojure-api.routes.htmx.comments-section 15 | :as comments-section])) 16 | 17 | (def routes 18 | (route/expand-routes 19 | (into #{} 20 | (concat click-to-edit/routes 21 | infinite-scroll/routes 22 | active-search/routes 23 | delete-with-confirmation/routes 24 | comments-section/routes)))) 25 | 26 | (defn inject-dependencies 27 | [dependencies] 28 | (interceptor/interceptor 29 | {:name ::inject-dependencies 30 | :enter (fn [context] 31 | (assoc context :dependencies dependencies))})) 32 | 33 | (defrecord HTMXPedestalComponent 34 | [config 35 | in-memory-state-component] 36 | component/Lifecycle 37 | 38 | (start [component] 39 | (println "Starting HTMXPedestalComponent") 40 | (let [server (-> {::http/routes routes 41 | ::http/type :jetty 42 | ::http/join? false 43 | ::http/port (-> config :htmx :server :port) 44 | ::http/secure-headers {:content-security-policy-settings {:object-src "none"}}} 45 | (http/default-interceptors) 46 | (update ::http/interceptors concat 47 | [(inject-dependencies component)]) 48 | (http/create-server) 49 | (http/start))] 50 | (assoc component :server server))) 51 | 52 | (stop [component] 53 | (println "Stopping HTMXPedestalComponent") 54 | (when-let [server (:server component)] 55 | (http/stop server)) 56 | (assoc component :server nil))) 57 | 58 | (defn new-htmx-pedestal-component 59 | [config] 60 | (map->HTMXPedestalComponent {:config config})) -------------------------------------------------------------------------------- /src/real_world_clojure_api/core.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.core 2 | (:require [clojure.tools.logging :as log] 3 | [com.stuartsierra.component :as component] 4 | [next.jdbc.connection :as connection] 5 | [real-world-clojure-api.config :as config] 6 | [real-world-clojure-api.components.example-component 7 | :as example-component] 8 | [real-world-clojure-api.components.pedestal-component 9 | :as pedestal-component] 10 | [real-world-clojure-api.components.in-memory-state-component 11 | :as in-memory-state-component] 12 | [real-world-clojure-api.components.htmx-pedestal-component 13 | :as htmx-pedestal-component]) 14 | (:import (com.zaxxer.hikari HikariDataSource) 15 | (org.flywaydb.core Flyway))) 16 | 17 | (defn datasource-component 18 | [config] 19 | (connection/component 20 | HikariDataSource 21 | (assoc (:db-spec config) 22 | :init-fn (fn [datasource] 23 | (log/info "Running database init") 24 | (.migrate 25 | (.. (Flyway/configure) 26 | (dataSource datasource) 27 | ; https://www.red-gate.com/blog/database-devops/flyway-naming-patterns-matter 28 | (locations (into-array String ["classpath:database/migrations"])) 29 | (table "schema_version") 30 | (load))))))) 31 | 32 | (defn real-world-clojure-api-system 33 | [config] 34 | (component/system-map 35 | :example-component (example-component/new-example-component config) 36 | :in-memory-state-component (in-memory-state-component/new-in-memory-state-component config) 37 | 38 | :datasource (datasource-component config) 39 | 40 | :pedestal-component 41 | (component/using 42 | (pedestal-component/new-pedestal-component config) 43 | [:example-component 44 | :datasource 45 | :in-memory-state-component]) 46 | 47 | :htmx-pedestal-component 48 | (component/using 49 | (htmx-pedestal-component/new-htmx-pedestal-component config) 50 | [:in-memory-state-component]))) 51 | 52 | (defn -main 53 | [] 54 | (let [system (-> (config/read-config) 55 | (real-world-clojure-api-system) 56 | (component/start-system))] 57 | (println "Starting Real-World Clojure API Service with config") 58 | (.addShutdownHook 59 | (Runtime/getRuntime) 60 | (new Thread #(component/stop-system system))))) -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/active_search.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.active-search 2 | (:require [clojure.string :as str] 3 | [hiccup.page :as hp] 4 | [hiccup2.core :as h])) 5 | 6 | (defn tw 7 | [classes] 8 | (->> (flatten classes) 9 | (remove nil?) 10 | (map name) 11 | (sort) 12 | (str/join " "))) 13 | 14 | (def tw-input 15 | [:bg-gray-200 :appearance-none :border-2 :border-gray-200 :rounded :py-2 :px-4 :text-gray-700 :leading-tight :focus:outline-none :focus:bg-white :focus:border-blue-500]) 16 | 17 | 18 | (defn ok 19 | [body] 20 | {:status 200 21 | :headers {"Content-Type" "text/html"} 22 | :body (-> body 23 | (h/html) 24 | (str))}) 25 | 26 | (def title "HTMX: Active Search") 27 | 28 | (defn load-text-data* 29 | [] 30 | (let [lines (-> (slurp "https://ocw.mit.edu/ans7870/6/6.006/s08/lecturenotes/files/t8.shakespeare.txt") 31 | (str/split #"\n"))] 32 | (->> lines 33 | (remove str/blank?) 34 | (map str/trim)))) 35 | 36 | (def load-text-data (memoize load-text-data*)) 37 | 38 | (defn- layout 39 | [body] 40 | [:head 41 | [:title title] 42 | (hp/include-js 43 | "https://cdn.tailwindcss.com" 44 | "https://unpkg.com/htmx.org@1.9.4?plugins=forms") 45 | [:body 46 | [:div {:class "container mx-auto mt-10"} 47 | [:h1 {:class "text-2xl font-bold leading-7 text-gray-900 mb-5 sm:p-0 p-6"} 48 | title] 49 | body]]]) 50 | 51 | 52 | (def root-handler 53 | {:name ::root 54 | :enter 55 | (fn [context] 56 | (assoc context :response 57 | (-> (list 58 | [:div.htmx-indicator 59 | "Searching..."] 60 | [:input 61 | {:class (tw [tw-input]) 62 | :type "search" 63 | :name "q" 64 | :placeholder "Begin Typing To Search..." 65 | :hx-get "/htmx/active-search/search" 66 | :hx-trigger "keyup changed delay:500ms" 67 | :hx-target "#search-results" 68 | :hx-indicator ".htmx-indicator"}] 69 | [:div#search-results 70 | "Search results"]) 71 | (layout) 72 | (ok))))}) 73 | 74 | 75 | (def search-handler 76 | {:name ::search 77 | :enter 78 | (fn [context] 79 | (let [q (-> context :request :query-params :q str/trim) 80 | lines (if (str/blank? q) 81 | [] 82 | (->> (load-text-data) 83 | (filter (fn [line] 84 | (str/includes? line q))) 85 | (take 50))) 86 | response (-> (map (fn [line] 87 | [:div.even:bg-red-50.odd:bg-green-50.p-2 line]) lines) 88 | (doall) 89 | (ok))] 90 | (assoc context :response response)))}) 91 | 92 | (def routes 93 | #{["/htmx/active-search" 94 | :get root-handler 95 | :route-name ::root] 96 | ["/htmx/active-search/search" 97 | :get search-handler 98 | :route-name ::search]}) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # real-world-clojure-api 2 | 3 | **Author**: Andrey Fadeev 4 | 5 | ## Introduction 6 | 7 | This is a Clojure project titled "real-world-clojure-api" by Andrey Fadeev. It has dependencies on various libraries such as `aero`, `pedestal`, `slf4j`, and `component`. This README provides instructions on how to set up your environment and run the project. 8 | 9 | ## Video Series 10 | 11 | For a comprehensive walkthrough and detailed explanations of the project, you can watch the video series available on Andrey Fadeev's YouTube channel: 12 | 13 | [Real-World Clojure API Video Series](https://youtube.com/playlist?list=PLRGAFpvDgm2ylbXYIjvu3kI426zAP_Lqc&si=00MjPUe_aL0h8Nbi) 14 | 15 | [Andrey Fadeev's YouTube Channel](https://www.youtube.com/@andrey.fadeev) 16 | 17 | ## Prerequisites 18 | 19 | 1. **Java**: Ensure you have Java installed. Clojure runs on the Java Virtual Machine (JVM), so you'll need Java to run Clojure code. 20 | 21 | 2. **Clojure CLI (`clj`)**: This project uses the Clojure CLI tool (`clj`) for dependency management and running Clojure scripts. 22 | 23 | ## Installation 24 | 25 | ### Installing Clojure CLI (`clj`) 26 | 27 | If you haven't installed the Clojure CLI (`clj`) yet, follow these steps: 28 | 29 | #### For macOS: 30 | 31 | Using Homebrew: 32 | 33 | ```bash 34 | brew install clojure/tools/clojure 35 | ``` 36 | 37 | #### For Linux: 38 | 39 | Download the script: 40 | 41 | ```bash 42 | curl -O https://download.clojure.org/install/linux-install-1.10.3.967.sh 43 | chmod +x linux-install-1.10.3.967.sh 44 | sudo ./linux-install-1.10.3.967.sh 45 | ``` 46 | 47 | #### For Windows: 48 | 49 | - **Using Windows Subsystem for Linux (WSL)**: It's recommended to use the [Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install) and then follow the Linux instructions. 50 | 51 | - **Using Git Bash**: If you have Git Bash installed, you can use it to run the Linux installation commands. Open Git Bash and follow the Linux instructions above. 52 | 53 | ### Clone the Project 54 | 55 | If you haven't already, clone this project to your local machine: 56 | 57 | ```bash 58 | git clone https://github.com/andfadeev/real-world-clojure-api.git 59 | cd real-world-clojure-api 60 | ``` 61 | 62 | ## Running the Project 63 | 64 | 1. Navigate to the project directory: 65 | 66 | ```bash 67 | cd real-world-clojure-api 68 | ``` 69 | 70 | 2. Start the project in development mode: 71 | 72 | ```bash 73 | clj -A:dev 74 | ``` 75 | 76 | This command uses the `:dev` alias specified in the project configuration to start the project in development mode. It will load the `dev` namespace and execute the required functions. 77 | 78 | ## Dependencies 79 | 80 | This project uses the following dependencies: 81 | 82 | - `aero`: Configuration library. 83 | - `pedestal`: Web application framework. 84 | - `slf4j`: Simple Logging Facade for Java. 85 | - `component`: Manage the lifecycle of stateful objects in Clojure. 86 | 87 | For detailed versions, refer to the project configuration. 88 | 89 | ## Conclusion 90 | 91 | You should now have everything set up and running. Explore the code, make changes, and enjoy your Clojure development experience! If you encounter any issues, please refer to the respective library's documentation, watch the video series on Andrey Fadeev's YouTube channel for guidance, or raise an issue in this repository. -------------------------------------------------------------------------------- /test/persistence/real_world_clojure_api/migrations_test.clj: -------------------------------------------------------------------------------- 1 | (ns persistence.real-world-clojure-api.migrations-test 2 | (:require [clojure.test :refer :all] 3 | [com.stuartsierra.component :as component] 4 | [next.jdbc :as jdbc] 5 | [next.jdbc.result-set :as rs] 6 | [real-world-clojure-api.core :as core]) 7 | (:import (org.testcontainers.containers PostgreSQLContainer))) 8 | 9 | 10 | (defmacro with-system 11 | [[bound-var binding-expr] & body] 12 | `(let [~bound-var (component/start ~binding-expr)] 13 | (try 14 | ~@body 15 | (finally 16 | (component/stop ~bound-var))))) 17 | 18 | (defn datasource-only-system 19 | [config] 20 | (component/system-map 21 | :datasource (core/datasource-component config))) 22 | 23 | (defn create-database-container 24 | [] 25 | (PostgreSQLContainer. "postgres:15.4")) 26 | 27 | (deftest migrations-test 28 | (let [database-container (create-database-container)] 29 | (try 30 | (.start database-container) 31 | (with-system 32 | [sut (datasource-only-system 33 | {:db-spec {:jdbcUrl (.getJdbcUrl database-container) 34 | :username (.getUsername database-container) 35 | :password (.getPassword database-container)}})] 36 | (let [{:keys [datasource]} sut 37 | [schema-version :as schema-versions] 38 | (jdbc/execute! 39 | (datasource) 40 | ["select * from schema_version"] 41 | {:builder-fn rs/as-unqualified-lower-maps})] 42 | (is (= 1 (count schema-versions))) 43 | (is (= {:description "add todo tables" 44 | :script "V1__add_todo_tables.sql" 45 | :success true} 46 | (select-keys schema-version [:description :script :success]))))) 47 | (finally 48 | (.stop database-container))))) 49 | 50 | (deftest todo-table-test 51 | (let [database-container (create-database-container)] 52 | (try 53 | (.start database-container) 54 | (with-system 55 | [sut (datasource-only-system 56 | {:db-spec {:jdbcUrl (.getJdbcUrl database-container) 57 | :username (.getUsername database-container) 58 | :password (.getPassword database-container)}})] 59 | (let [{:keys [datasource]} sut 60 | insert-results (jdbc/execute! 61 | (datasource) 62 | [" 63 | insert into todo (title) 64 | values ('my todo list'), 65 | ('other todo list') 66 | returning * 67 | " 68 | ] 69 | {:builder-fn rs/as-unqualified-lower-maps}) 70 | select-results (jdbc/execute! 71 | (datasource) 72 | [" 73 | select * from todo"] 74 | {:builder-fn rs/as-unqualified-lower-maps})] 75 | (is (= 2 76 | (count insert-results) 77 | (count select-results))) 78 | (is (= #{"my todo list" 79 | "other todo list"} 80 | (->> insert-results (map :title) (into #{})) 81 | (->> select-results (map :title) (into #{})))))) 82 | (finally 83 | (.stop database-container))))) -------------------------------------------------------------------------------- /test/component/real_world_clojure_api/todo_api_test.clj: -------------------------------------------------------------------------------- 1 | (ns component.real-world-clojure-api.todo-api-test 2 | (:require [cheshire.core :as json] 3 | [clojure.string :as str] 4 | [clojure.test :refer :all] 5 | [com.stuartsierra.component :as component] 6 | [honey.sql :as sql] 7 | [next.jdbc :as jdbc] 8 | [next.jdbc.result-set :as rs] 9 | [real-world-clojure-api.components.pedestal-component :refer [url-for]] 10 | [real-world-clojure-api.core :as core] 11 | [clj-http.client :as client]) 12 | (:import (java.net ServerSocket) 13 | (org.testcontainers.containers PostgreSQLContainer))) 14 | 15 | (defmacro with-system 16 | [[bound-var binding-expr] & body] 17 | `(let [~bound-var (component/start ~binding-expr)] 18 | (try 19 | ~@body 20 | (finally 21 | (component/stop ~bound-var))))) 22 | 23 | (defn sut->url 24 | [sut path] 25 | (str/join ["http://localhost:" 26 | (-> sut :pedestal-component :config :server :port) 27 | path])) 28 | 29 | (defn get-free-port 30 | [] 31 | (with-open [socket (ServerSocket. 0)] 32 | (.getLocalPort socket))) 33 | (deftest get-todo-test 34 | (let [database-container (PostgreSQLContainer. "postgres:15.4")] 35 | (try 36 | (.start database-container) 37 | (with-system 38 | [sut (core/real-world-clojure-api-system 39 | {:server {:port (get-free-port)} 40 | :htmx {:server {:port (get-free-port)}} 41 | :db-spec {:jdbcUrl (.getJdbcUrl database-container) 42 | :username (.getUsername database-container) 43 | :password (.getPassword database-container)}})] 44 | (let [{:keys [datasource]} sut 45 | {:keys [todo-id 46 | title]} (jdbc/execute-one! 47 | (datasource) 48 | (-> {:insert-into [:todo] 49 | :columns [:title] 50 | :values [["My todo for test"]] 51 | :returning :*} 52 | (sql/format)) 53 | {:builder-fn rs/as-unqualified-kebab-maps}) 54 | {:keys [status body]} (-> (sut->url sut 55 | (url-for :db-get-todo 56 | {:path-params {:todo-id todo-id}})) 57 | (client/get {:accept :json 58 | :as :json 59 | :throw-exceptions false}) 60 | (select-keys [:body :status]))] 61 | (is (= 200 status)) 62 | (is (some? (:created-at body))) 63 | (is (= {:todo-id (str todo-id) 64 | :title title} 65 | (select-keys body [:todo-id :title]) 66 | ))) 67 | (testing "Empty body is return for random todo id" 68 | (is (= {:body "" 69 | :status 404} 70 | (-> (sut->url sut 71 | (url-for :db-get-todo 72 | {:path-params {:todo-id (random-uuid)}})) 73 | (client/get {:throw-exceptions false}) 74 | (select-keys [:body :status])) 75 | )))) 76 | (finally 77 | (.stop database-container))))) -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/delete_with_confirmation.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.delete-with-confirmation 2 | (:require [clojure.string :as str] 3 | [hiccup.page :as hp] 4 | [hiccup2.core :as h] 5 | [faker.lorem :as fl])) 6 | 7 | (defn tw 8 | [classes] 9 | (->> (flatten classes) 10 | (remove nil?) 11 | (map name) 12 | (sort) 13 | (str/join " "))) 14 | 15 | (defn random-item 16 | [] 17 | {:id (random-uuid) 18 | :title (first (fl/sentences))}) 19 | 20 | (def items-atom (atom (repeatedly 50 random-item))) 21 | 22 | (defn ok 23 | [body] 24 | {:status 200 25 | :headers {"Content-Type" "text/html"} 26 | :body (-> body 27 | (h/html) 28 | (str))}) 29 | 30 | (def title "HTMX: Delete with confirmation") 31 | 32 | (defn- layout 33 | [body] 34 | [:head 35 | [:title title] 36 | (hp/include-js 37 | "https://cdn.tailwindcss.com?plugins=forms" 38 | "https://unpkg.com/htmx.org@1.9.4" 39 | "https://unpkg.com/hyperscript.org@0.9.11" 40 | "https://cdn.jsdelivr.net/npm/sweetalert2@11") 41 | [:body 42 | [:div {:class "container mx-auto mt-10"} 43 | [:h1 {:class "text-2xl font-bold leading-7 text-gray-900 mb-5 sm:p-0 p-6"} 44 | title] 45 | body]]]) 46 | 47 | (defn item-div-id 48 | [item] 49 | (str "item-div-" (:id item))) 50 | 51 | (defn item-div-id-ref 52 | [item] 53 | (str "#" (item-div-id item))) 54 | 55 | (def root-handler 56 | {:name ::root 57 | :enter 58 | (fn [context] 59 | (let [response (-> [:div 60 | (for [item @items-atom] 61 | [:div.p-2.odd:bg-red-50.even:bg-green-50 62 | {:id (item-div-id item)} 63 | [:div (:title item)] 64 | [:div 65 | [:button.text-red-600.hover:underline 66 | {:hx-delete (str "/htmx/delete-with-confirmation/items/" (:id item)) 67 | :hx-target (item-div-id-ref item) 68 | :hx-swap "outerHTML" 69 | :hx-confirm "Are you sure you wish to delete this item?"} 70 | "delete"] 71 | 72 | ] 73 | [:div 74 | [:button.text-red-600.hover:underline 75 | {:hx-delete (str "/htmx/delete-with-confirmation/items/" (:id item)) 76 | :hx-target (item-div-id-ref item) 77 | :hx-swap "outerHTML" 78 | :_ "on htmx:confirm(issueRequest) 79 | halt the event 80 | call Swal.fire({title: 'Confirm', text:'Do you want to continue?', showDenyButton: true,\n showCancelButton: true,\n confirmButtonText: 'Save',}) 81 | if result.isConfirmed issueRequest()"} "delete 2"] 82 | ] 83 | ] 84 | ) 85 | ] 86 | (layout) 87 | (ok))] 88 | (assoc context :response response)))}) 89 | 90 | 91 | (def delete-handler 92 | {:name ::delete 93 | :enter 94 | (fn [context] 95 | (let [item-id (-> context :request :path-params :item-id)] 96 | (swap! items-atom 97 | (fn [items] 98 | (remove (fn [item] 99 | (= (str (:id item)) item-id)) items))) 100 | ) 101 | 102 | (assoc context :response (ok nil)))}) 103 | 104 | (def routes 105 | #{["/htmx/delete-with-confirmation" 106 | :get root-handler 107 | :route-name ::root] 108 | ["/htmx/delete-with-confirmation/items/:item-id" 109 | :delete delete-handler 110 | :route-name ::delete]}) -------------------------------------------------------------------------------- /test/persistence/real_world_clojure_api/honeysql_test.clj: -------------------------------------------------------------------------------- 1 | (ns persistence.real-world-clojure-api.honeysql-test 2 | (:require [clojure.test :refer :all] 3 | [com.stuartsierra.component :as component] 4 | [next.jdbc :as jdbc] 5 | [next.jdbc.result-set :as rs] 6 | [real-world-clojure-api.core :as core] 7 | [honey.sql :as sql]) 8 | (:import (org.testcontainers.containers PostgreSQLContainer))) 9 | 10 | 11 | (defmacro with-system 12 | [[bound-var binding-expr] & body] 13 | `(let [~bound-var (component/start ~binding-expr)] 14 | (try 15 | ~@body 16 | (finally 17 | (component/stop ~bound-var))))) 18 | 19 | (defn datasource-only-system 20 | [config] 21 | (component/system-map 22 | :datasource (core/datasource-component config))) 23 | 24 | (defn create-database-container 25 | [] 26 | (PostgreSQLContainer. "postgres:15.4")) 27 | 28 | (deftest migrations-test 29 | (let [database-container (create-database-container)] 30 | (try 31 | (.start database-container) 32 | (with-system 33 | [sut (datasource-only-system 34 | {:db-spec {:jdbcUrl (.getJdbcUrl database-container) 35 | :username (.getUsername database-container) 36 | :password (.getPassword database-container)}})] 37 | (let [{:keys [datasource]} sut 38 | select-query (sql/format {:select :* 39 | :from :schema-version}) 40 | [schema-version :as schema-versions] 41 | (jdbc/execute! 42 | (datasource) 43 | select-query 44 | {:builder-fn rs/as-unqualified-lower-maps})] 45 | (is (= ["SELECT * FROM schema_version"] 46 | select-query)) 47 | (is (= 1 (count schema-versions))) 48 | (is (= {:description "add todo tables" 49 | :script "V1__add_todo_tables.sql" 50 | :success true} 51 | (select-keys schema-version [:description :script :success]))))) 52 | (finally 53 | (.stop database-container))))) 54 | 55 | (deftest todo-table-test 56 | (let [database-container (create-database-container)] 57 | (try 58 | (.start database-container) 59 | (with-system 60 | [sut (datasource-only-system 61 | {:db-spec {:jdbcUrl (.getJdbcUrl database-container) 62 | :username (.getUsername database-container) 63 | :password (.getPassword database-container)}})] 64 | (let [{:keys [datasource]} sut 65 | insert-query (-> {:insert-into [:todo] 66 | :columns [:title] 67 | :values [["my todo list"] 68 | ["other todo list"]] 69 | :returning :*} 70 | (sql/format)) 71 | insert-results (jdbc/execute! 72 | (datasource) 73 | insert-query 74 | {:builder-fn rs/as-unqualified-lower-maps}) 75 | select-results (jdbc/execute! 76 | (datasource) 77 | (-> {:select :* 78 | :from :todo} 79 | (sql/format)) 80 | {:builder-fn rs/as-unqualified-lower-maps})] 81 | (is (= ["INSERT INTO todo (title) VALUES (?), (?) RETURNING *" 82 | "my todo list" 83 | "other todo list"] insert-query )) 84 | (is (= 2 85 | (count insert-results) 86 | (count select-results))) 87 | (is (= #{"my todo list" 88 | "other todo list"} 89 | (->> insert-results (map :title) (into #{})) 90 | (->> select-results (map :title) (into #{})))))) 91 | (finally 92 | (.stop database-container))))) -------------------------------------------------------------------------------- /test/component/real_world_clojure_api/api_test.clj: -------------------------------------------------------------------------------- 1 | (ns component.real-world-clojure-api.api-test 2 | (:require [cheshire.core :as json] 3 | [clojure.string :as str] 4 | [clojure.test :refer :all] 5 | [com.stuartsierra.component :as component] 6 | [real-world-clojure-api.components.pedestal-component :refer [url-for]] 7 | [real-world-clojure-api.core :as core] 8 | [clj-http.client :as client]) 9 | (:import (java.net ServerSocket))) 10 | 11 | (defmacro with-system 12 | [[bound-var binding-expr] & body] 13 | `(let [~bound-var (component/start ~binding-expr)] 14 | (try 15 | ~@body 16 | (finally 17 | (component/stop ~bound-var))))) 18 | 19 | (defn sut->url 20 | [sut path] 21 | (str/join ["http://localhost:" 22 | (-> sut :pedestal-component :config :server :port) 23 | path])) 24 | 25 | (defn get-free-port 26 | [] 27 | (with-open [socket (ServerSocket. 0)] 28 | (.getLocalPort socket))) 29 | 30 | (deftest greeting-test 31 | (with-system 32 | [sut (core/real-world-clojure-api-system {:server {:port (get-free-port)}})] 33 | (is (= {:body "Hello world" 34 | :status 200} 35 | (-> (sut->url sut (url-for :greet)) 36 | (client/get {:accept :json}) 37 | (select-keys [:body :status])))))) 38 | 39 | (deftest content-negotiation-test 40 | (testing "only application/json is accepted" 41 | (with-system 42 | [sut (core/real-world-clojure-api-system {:server {:port (get-free-port)}})] 43 | (is (= {:body "Not Acceptable" 44 | :status 406} 45 | (-> (sut->url sut (url-for :greet)) 46 | (client/get {:accept :edn 47 | :throw-exceptions false}) 48 | (select-keys [:body :status]))))))) 49 | 50 | (deftest get-todo-test 51 | (let [todo-id-1 (str (random-uuid)) 52 | todo-1 {:id todo-id-1 53 | :name "My todo for test" 54 | :items [{:id (str (random-uuid)) 55 | :name "finish the test"}]}] 56 | (with-system 57 | [sut (core/real-world-clojure-api-system {:server {:port (get-free-port)}})] 58 | (reset! (-> sut :in-memory-state-component :state-atom) 59 | [todo-1]) 60 | (is (= {:body todo-1 61 | :status 200} 62 | (-> (sut->url sut 63 | (url-for :get-todo 64 | {:path-params {:todo-id todo-id-1}})) 65 | (client/get {:accept :json 66 | :as :json 67 | :throw-exceptions false}) 68 | (select-keys [:body :status])))) 69 | (testing "Empty body is return for random todo id" 70 | (is (= {:body "" 71 | :status 404} 72 | (-> (sut->url sut 73 | (url-for :get-todo 74 | {:path-params {:todo-id (random-uuid)}})) 75 | (client/get {:throw-exceptions false}) 76 | (select-keys [:body :status])) 77 | )))))) 78 | 79 | (deftest post-todo-test 80 | (let [todo-id-1 (str (random-uuid)) 81 | todo-1 {:id todo-id-1 82 | :name "My todo for test" 83 | :items []}] 84 | (with-system 85 | [sut (core/real-world-clojure-api-system {:server {:port (get-free-port)}})] 86 | (testing "store and retrieve todo by id" 87 | (is (= {:body todo-1 88 | :status 201} 89 | (-> (sut->url sut (url-for :post-todo)) 90 | (client/post {:accept :json 91 | :content-type :json 92 | :as :json 93 | :throw-exceptions false 94 | :body (json/encode todo-1)}) 95 | (select-keys [:body :status])))) 96 | (is (= {:body todo-1 97 | :status 200} 98 | (-> (sut->url sut 99 | (url-for :get-todo 100 | {:path-params {:todo-id todo-id-1}})) 101 | (client/get {:accept :json 102 | :as :json 103 | :throw-exceptions false}) 104 | (select-keys [:body :status]))))) 105 | 106 | (testing "invalid Todo is rejected" 107 | (is (= {:status 500} 108 | (-> (sut->url sut (url-for :post-todo)) 109 | (client/post {:accept :json 110 | :content-type :json 111 | :as :json 112 | :throw-exceptions false 113 | :body (json/encode {:id todo-id-1})}) 114 | (select-keys [:status])))))))) 115 | -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/comments_section.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.comments-section 2 | (:require [clojure.string :as str] 3 | [faker.lorem :as fl] 4 | [faker.name :as fn] 5 | [hiccup.page :as hp] 6 | [hiccup2.core :as h] 7 | [io.pedestal.http.body-params :as body-params] 8 | [io.pedestal.http.params :as params] 9 | [real-world-clojure-api.routes.htmx.shared :as shared])) 10 | 11 | (defn ok 12 | [body] 13 | {:status 200 14 | :headers {"Content-Type" "text/html"} 15 | :body (-> body 16 | (h/html) 17 | (str))}) 18 | 19 | (defn tw 20 | [classes] 21 | (->> (flatten classes) 22 | (remove nil?) 23 | (map name) 24 | (sort) 25 | (str/join " "))) 26 | 27 | (def tw-input 28 | [:bg-gray-200 :appearance-none :border-2 :border-gray-200 :rounded :py-2 :px-4 :text-gray-700 :leading-tight :focus:outline-none :focus:bg-white :focus:border-blue-500]) 29 | 30 | (def title "HTMX: Comments section") 31 | 32 | (defn- layout 33 | [body] 34 | [:head 35 | [:title title] 36 | (hp/include-js 37 | "https://cdn.tailwindcss.com" 38 | "https://unpkg.com/htmx.org@1.9.4?plugins=forms") 39 | [:body 40 | [:div {:class "container mx-auto mt-10"} 41 | [:h1 {:class "text-2xl font-bold leading-7 text-gray-900 mb-5 sm:p-0 p-6"} 42 | title] 43 | body]]]) 44 | 45 | (defn random-comment 46 | [] 47 | {:name (first (fn/names)) 48 | :comment (first (fl/paragraphs)) 49 | :picture (shared/random-picture)}) 50 | 51 | (def comments-atom (atom (repeatedly 3 random-comment))) 52 | 53 | (def author-picture (shared/random-picture)) 54 | (def author-name "Andrey Fadeev") 55 | 56 | (defn comment-component 57 | ([comment] 58 | (comment-component comment {})) 59 | ([comment opts] 60 | [:div.odd:bg-white.even:bg-slate-100.px-4.py-4.sm:px-6.lg:px-8 61 | opts 62 | [:p.text-gray-700.text-sm.linkify.break-all 63 | (:comment comment)] 64 | [:div.mt-2.flex.items-center.gap-1.text-xs.text-gray-500 65 | [:a.flex.items-center.hover:underline.gap-1 66 | {:href "#"} 67 | [:img.rounded-full.h-5.w-5 68 | {:src (:picture comment)}] 69 | [:span (:name comment)]]]])) 70 | 71 | (defn comment-form-component 72 | [] 73 | [:form.px-4.py-4.sm:px-6.lg:px-8 74 | {:hx-post "/htmx/comments-section/comments" 75 | :hx-target "this" 76 | :hx-swap "outerHTML" 77 | :hx-boost "true" 78 | } 79 | [:div 80 | [:label.sr-only {:for "comment"} "Comment"] 81 | [:textarea#comment.w-full.p-3.text-sm.rounded-sm 82 | {:autocomplete "off" 83 | :name "comment" 84 | :class (tw [tw-input]) 85 | :placeholder "Your comment" :rows "5"}]] 86 | [:div.mt-2 87 | [:button.px-5.py-3.text-white.text-sm.bg-indigo-800.rounded-sm 88 | {:type "submit"} 89 | [:span.font-medium "Publish comment"]]]]) 90 | 91 | (defn comments-component 92 | [comments] 93 | [:div#comments 94 | {:hx-get "/htmx/comments-section/comments" 95 | :hx-trigger "newComment from:body"} 96 | [:h2.px-4.sm:px-6.lg:px-8.bg-gray-100.py-5.text-lg.font-medium.sm:text-xl 97 | (format "Comments (%s)" (count comments))] 98 | [:div.mx-auto 99 | (for [comment comments] 100 | (comment-component comment))]]) 101 | 102 | (defn comments-section-component 103 | [comments] 104 | [:div#comments-section.shadow-xl.mt-4 105 | (comments-component comments) 106 | (comment-form-component)]) 107 | 108 | (def root-handler 109 | {:name ::root 110 | :enter 111 | (fn [context] 112 | (let [response 113 | (-> (comments-section-component @comments-atom) 114 | (layout) 115 | (ok))] 116 | (assoc context :response response)))}) 117 | 118 | (def post-handler 119 | {:name ::post 120 | :enter 121 | (fn [context] 122 | (let [comment {:comment (-> context :request :params :comment) 123 | :name author-name 124 | :picture author-picture} 125 | _ (swap! comments-atom conj comment) 126 | response 127 | (-> (comment-form-component) 128 | (ok))] 129 | (assoc context :response 130 | (update response :headers merge {"HX-Trigger" "newComment"}))))}) 131 | 132 | (def comments-handler 133 | {:name ::comments 134 | :enter 135 | (fn [context] 136 | (let [response 137 | (-> (comments-component @comments-atom) 138 | (ok))] 139 | (assoc context :response response)))}) 140 | 141 | (def routes 142 | #{["/htmx/comments-section" 143 | :get root-handler 144 | :route-name ::root] 145 | ["/htmx/comments-section/comments" 146 | :get comments-handler 147 | :route-name ::comments] 148 | ["/htmx/comments-section/comments" 149 | :post [(body-params/body-params) 150 | params/keyword-params 151 | post-handler] 152 | :route-name ::post]}) 153 | 154 | 155 | (comment 156 | {:hx-post "/htmx/comments-section/comments" 157 | :hx-target "#comments-section" 158 | :hx-swap "outerHTML" 159 | :hx-boost "true" 160 | } 161 | 162 | 163 | {:hx-swap-oob "afterbegin:#comments"} 164 | 165 | (update {} :headers merge {"HX-Trigger" "newComment"}) 166 | 167 | ["/htmx/comments-section/comments" 168 | :get comments-handler 169 | :route-name ::comments] 170 | 171 | {:hx-get "/contacts/table" 172 | :hx-trigger "newComment from:body, every 10s"}) -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/click_to_edit.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.click-to-edit 2 | (:require [clojure.string :as str] 3 | [hiccup.page :as hp] 4 | [hiccup2.core :as h] 5 | [io.pedestal.http.body-params :as body-params])) 6 | 7 | ;; Helpers 8 | (defn ok 9 | [body] 10 | {:status 200 11 | :headers {"Content-Type" "text/html"} 12 | :body (-> body 13 | (h/html) 14 | (str))}) 15 | 16 | (defn tw 17 | [classes] 18 | (->> (flatten classes) 19 | (remove nil?) 20 | (map name) 21 | (sort) 22 | (str/join " "))) 23 | 24 | (defn- dependencies->state 25 | [dependencies] 26 | (get-in dependencies [:in-memory-state-component 27 | :htmx-click-to-edit-state])) 28 | 29 | ;; Tailwind classes 30 | (def tw-primary-button 31 | [:bg-blue-500 :hover:bg-blue-400 :text-white :font-bold :py-2 :px-4 :border-b-4 :border-blue-700 :hover:border-blue-500 :rounded]) 32 | 33 | (def tw-cancel-button 34 | [:bg-red-500 :hover:bg-red-400 :text-white :font-bold :py-2 :px-4 :border-b-4 :border-red-700 :hover:border-red-500 :rounded]) 35 | 36 | (def tw-input 37 | [:bg-gray-200 :appearance-none :border-2 :border-gray-200 :rounded :py-2 :px-4 :text-gray-700 :leading-tight :focus:outline-none :focus:bg-white :focus:border-blue-500]) 38 | 39 | ;; Hiccup components 40 | (defn- layout 41 | [body] 42 | [:head 43 | [:title "HTMX: Click to edit"] 44 | (hp/include-js 45 | "https://cdn.tailwindcss.com" 46 | "https://unpkg.com/htmx.org@1.9.4?plugins=forms") 47 | [:body 48 | [:div {:class "container mx-auto mt-10"} 49 | body]]]) 50 | 51 | (defn user-details-component 52 | [{:keys [id 53 | first-name 54 | last-name 55 | email]}] 56 | [:div {:class (tw [:p-5 :bg-slate-100]) 57 | :hx-target "this" 58 | :hx-swap "outerHTML"} 59 | [:div {:class "mt-2 flex gap-2 items-center"} 60 | [:label "First Name:"] 61 | [:span {:class (tw [:font-bold])} first-name]] 62 | [:div {:class "mt-2 flex gap-2 items-center"} 63 | [:label "Last Name:"] 64 | [:span {:class (tw [:font-bold])} last-name]] 65 | [:div {:class "mt-2 flex gap-2 items-center"} 66 | [:label "Email:"] 67 | [:span {:class (tw [:font-bold])} email]] 68 | [:button {:class (tw [tw-primary-button :mt-5]) 69 | :hx-get (format "/htmx/click-to-edit/user/%s/edit" id)} 70 | "Click To Edit"]]) 71 | 72 | (defn click-to-edit-form 73 | [{:keys [id 74 | first-name 75 | last-name 76 | email]}] 77 | [:form 78 | {:class (tw [:bg-slate-100 79 | :p-5]) 80 | :hx-put (format "/htmx/click-to-edit/user/%s/edit" id) 81 | :hx-target "this" 82 | :hx-swap "outerHTML"} 83 | [:div {:class "mt-2 flex gap-2 items-center"} 84 | [:label "First Name"] 85 | [:input {:class (tw [tw-input]) 86 | :type "text" 87 | :name "first-name" 88 | :value first-name}]] 89 | [:div {:class "mt-2 flex gap-2 items-center"} 90 | [:label "Last Name"] 91 | [:input {:class (tw [tw-input]) 92 | :type "text" 93 | :name "last-name" 94 | :value last-name}]] 95 | [:div {:class "mt-2 flex gap-2 items-center"} 96 | [:label "Email"] 97 | [:input {:class (tw [tw-input]) 98 | :type "email" 99 | :name "email" 100 | :value email}]] 101 | [:div {:class (tw [:flex :flex-row :gap-2 :mt-5])} 102 | [:button {:class (tw [tw-primary-button])} "Submit"] 103 | [:button {:hx-get "/htmx/click-to-edit" 104 | :hx-target "body" 105 | :class (tw [tw-cancel-button])} "Cancel"]]]) 106 | 107 | ;; Pedestal handlers 108 | (def root-handler 109 | {:name ::root 110 | :enter 111 | (fn [{:keys [dependencies] :as context}] 112 | (let [users @(dependencies->state dependencies) 113 | response 114 | (-> [:div 115 | [:h1 {:class "text-2xl font-bold leading-7 text-gray-900 mb-5"} "Click to edit"] 116 | (map (fn [[id user]] 117 | [:div {:class (tw [:mt-2])} 118 | (user-details-component (assoc user :id id))]) users)] 119 | (layout) 120 | (ok))] 121 | (assoc context :response response)))}) 122 | 123 | (def get-form-handler 124 | {:name ::get 125 | :enter 126 | (fn [{:keys [dependencies] :as context}] 127 | (let [user-id (-> context :request :path-params :user-id) 128 | users @(dependencies->state dependencies) 129 | user (get users user-id) 130 | response 131 | (-> (assoc user :id user-id) 132 | (click-to-edit-form) 133 | (ok))] 134 | (assoc context :response response)))}) 135 | 136 | (def put-form-handler 137 | {:name ::put 138 | :enter 139 | (fn [{:keys [dependencies request] :as context}] 140 | (let [user-id (-> context :request :path-params :user-id) 141 | _ (swap! (dependencies->state dependencies) 142 | assoc user-id (:form-params request)) 143 | users @(dependencies->state dependencies) 144 | user (get users user-id) 145 | response 146 | (-> (assoc user :id user-id) 147 | (user-details-component) 148 | (ok))] 149 | (assoc context :response response)))}) 150 | 151 | (def routes 152 | #{["/htmx/click-to-edit" 153 | :get root-handler 154 | :route-name ::root] 155 | ["/htmx/click-to-edit/user/:user-id/edit" 156 | :get get-form-handler 157 | :route-name ::get] 158 | ["/htmx/click-to-edit/user/:user-id/edit" 159 | :put [(body-params/body-params) 160 | put-form-handler] 161 | :route-name ::put]}) 162 | 163 | (comment 164 | (defn ->tw 165 | [s] 166 | (map keyword (str/split s #" ")))) 167 | 168 | -------------------------------------------------------------------------------- /src/real_world_clojure_api/components/pedestal_component.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.components.pedestal-component 2 | (:require [com.stuartsierra.component :as component] 3 | [honey.sql :as sql] 4 | [io.pedestal.http :as http] 5 | [io.pedestal.http.route :as route] 6 | [io.pedestal.interceptor :as interceptor] 7 | [io.pedestal.http.content-negotiation :as content-negotiation] 8 | [io.pedestal.http.body-params :as body-params] 9 | [cheshire.core :as json] 10 | [next.jdbc :as jdbc] 11 | [next.jdbc.result-set :as rs] 12 | [schema.core :as s])) 13 | 14 | (defn response 15 | ([status] 16 | (response status nil)) 17 | ([status body] 18 | (merge 19 | {:status status 20 | :headers {"Content-Type" "application/json"}} 21 | (when body {:body (json/encode body)})))) 22 | 23 | (def ok (partial response 200)) 24 | (def created (partial response 201)) 25 | (def not-found (partial response 404)) 26 | 27 | (defn get-todo-by-id 28 | [{:keys [in-memory-state-component]} todo-id] 29 | (->> @(:state-atom in-memory-state-component) 30 | (filter (fn [todo] 31 | (= todo-id (:id todo)))) 32 | (first))) 33 | 34 | (def get-todo-handler 35 | {:name :get-todo-handler 36 | :enter 37 | (fn [{:keys [dependencies] :as context}] 38 | (let [request (:request context) 39 | todo (get-todo-by-id dependencies 40 | (-> request 41 | :path-params 42 | :todo-id)) 43 | response (if todo 44 | (ok todo) 45 | (not-found))] 46 | (assoc context :response response)))}) 47 | 48 | 49 | (def db-get-todo-handler 50 | {:name :db-get-todo-handler 51 | :enter 52 | (fn [{:keys [dependencies] :as context}] 53 | (let [{:keys [datasource]} dependencies 54 | todo-id (-> context 55 | :request 56 | :path-params 57 | :todo-id 58 | (parse-uuid)) 59 | todo (jdbc/execute-one! 60 | (datasource) 61 | (-> {:select :* 62 | :from :todo 63 | :where [:= :todo-id todo-id]} 64 | (sql/format)) 65 | {:builder-fn rs/as-unqualified-kebab-maps}) 66 | response (if todo 67 | (ok todo) 68 | (not-found))] 69 | (assoc context :response response)))}) 70 | 71 | 72 | (def info-handler 73 | {:name :info-handler 74 | :enter 75 | (fn [{:keys [dependencies] :as context}] 76 | (let [{:keys [datasource]} dependencies 77 | db-response (first (jdbc/execute! 78 | (datasource) 79 | ["SHOW SERVER_VERSION"]))] 80 | (assoc context :response 81 | {:status 200 82 | :body (str "Database server version: " (:server_version db-response))})))}) 83 | 84 | (comment 85 | [{:id (random-uuid) 86 | :name "My todo list" 87 | :items [{:id (random-uuid) 88 | :name "Make a new youtube video" 89 | :status :created}]} 90 | {:id (random-uuid) 91 | :name "Empty todo list" 92 | :items []}]) 93 | 94 | (defn respond-hello 95 | [request] 96 | {:status 200 97 | :body "Hello world"}) 98 | 99 | (defn save-todo! 100 | [{:keys [in-memory-state-component]} todo] 101 | (swap! (:state-atom in-memory-state-component) conj todo)) 102 | 103 | 104 | 105 | (s/defschema 106 | TodoItem 107 | {:id s/Str 108 | :name s/Str 109 | :status s/Str}) 110 | 111 | (s/defschema 112 | Todo 113 | {:id s/Str 114 | :name s/Str 115 | :items [TodoItem]}) 116 | 117 | (def post-todo-handler 118 | {:name :post-todo-handler 119 | :enter 120 | (fn [{:keys [dependencies] :as context}] 121 | (let [request (:request context) 122 | todo (s/validate Todo (:json-params request))] 123 | (save-todo! dependencies todo) 124 | (assoc context :response (created todo))))}) 125 | 126 | (def routes 127 | (route/expand-routes 128 | #{["/greet" :get respond-hello :route-name :greet] 129 | ["/info" :get info-handler :route-name :info] 130 | ["/todo/:todo-id" :get get-todo-handler :route-name :get-todo] 131 | ["/todo" :post [(body-params/body-params) post-todo-handler] :route-name :post-todo] 132 | 133 | ["/db/todo/:todo-id" :get db-get-todo-handler :route-name :db-get-todo] 134 | })) 135 | 136 | (def url-for (route/url-for-routes routes)) 137 | 138 | (defn inject-dependencies 139 | [dependencies] 140 | (interceptor/interceptor 141 | {:name ::inject-dependencies 142 | :enter (fn [context] 143 | (assoc context :dependencies dependencies))})) 144 | 145 | (def content-negotiation-interceptor 146 | (content-negotiation/negotiate-content ["application/json"])) 147 | 148 | (defrecord PedestalComponent 149 | [config 150 | example-component 151 | datasource 152 | in-memory-state-component] 153 | component/Lifecycle 154 | 155 | (start [component] 156 | (println "Starting PedestalComponent") 157 | (let [server (-> {::http/routes routes 158 | ::http/type :jetty 159 | ::http/join? false 160 | ::http/port (-> config :server :port)} 161 | (http/default-interceptors) 162 | (update ::http/interceptors concat 163 | [(inject-dependencies component) 164 | content-negotiation-interceptor]) 165 | (http/create-server) 166 | (http/start))] 167 | (assoc component :server server))) 168 | 169 | (stop [component] 170 | (println "Stopping PedestalComponent") 171 | (when-let [server (:server component)] 172 | (http/stop server)) 173 | (assoc component :server nil))) 174 | 175 | (defn new-pedestal-component 176 | [config] 177 | (map->PedestalComponent {:config config})) -------------------------------------------------------------------------------- /src/real_world_clojure_api/routes/htmx/infinite_scroll.clj: -------------------------------------------------------------------------------- 1 | (ns real-world-clojure-api.routes.htmx.infinite-scroll 2 | (:require [clojure.string :as str] 3 | [hiccup.page :as hp] 4 | [hiccup2.core :as h] 5 | [faker.lorem :as fl] 6 | [faker.name :as fn])) 7 | 8 | ;; Helpers 9 | (defn ok 10 | [body] 11 | {:status 200 12 | :headers {"Content-Type" "text/html"} 13 | :body (-> body 14 | (h/html) 15 | (str))}) 16 | 17 | (def page-size 10) 18 | 19 | 20 | 21 | (defn random-picture 22 | [] 23 | (let [skin-color ["Tanned" 24 | "Yellow" 25 | "Pale" 26 | "Light" 27 | "Brown" 28 | "DarkBrown" 29 | "Black"] 30 | top-type #{"Hat" 31 | "LongHairNotTooLong" 32 | "ShortHairDreads01" 33 | "ShortHairShortFlat" 34 | "ShortHairDreads02" 35 | "LongHairFrida" 36 | "WinterHat4" 37 | "Turban" 38 | "LongHairShavedSides" 39 | "ShortHairTheCaesarSidePart" 40 | "ShortHairShaggyMullet" 41 | "ShortHairShortWaved" 42 | "ShortHairFrizzle" 43 | "LongHairMiaWallace" 44 | "WinterHat2" 45 | "LongHairBigHair" 46 | "Hijab" 47 | "LongHairStraightStrand" 48 | "LongHairFroBand" 49 | "ShortHairSides" 50 | "NoHair" 51 | "ShortHairShortRound" 52 | "WinterHat1" 53 | "LongHairDreads" 54 | "ShortHairTheCaesar" 55 | "LongHairFro" 56 | "LongHairBun" 57 | "WinterHat3" 58 | "LongHairCurvy" 59 | "Eyepatch" 60 | "LongHairStraight" 61 | "LongHairStraight2" 62 | "ShortHairShortCurly" 63 | "LongHairBob" 64 | "LongHairCurly"} 65 | mouth-type #{"Tongue" "Default" "Smile" "Grimace" "Twinkle" "Disbelief" "Eating" "Sad" "Serious" "Concerned" "ScreamOpen" "Vomit"} 66 | hair-color #{"Platinum" "Black" "BlondeGolden" "BrownDark" "SilverGray" "Blue" "Brown" "Blonde" "Red" "PastelPink" "Auburn"}] 67 | (format "https://avataaars.io/?skinColor=%s&topType=%s&hairColor=%s&mouthType=%s" 68 | (first (shuffle skin-color)) 69 | (first (shuffle top-type)) 70 | (first (shuffle hair-color)) 71 | (first (shuffle mouth-type))))) 72 | 73 | (defn random-infinite-scroll-item 74 | [] 75 | {:title (first (fl/sentences)) 76 | :description (first (fl/paragraphs)) 77 | :author {:name (first (fn/names)) 78 | :picture (random-picture)}}) 79 | 80 | (def items 81 | (repeatedly 50 random-infinite-scroll-item)) 82 | 83 | (defn items->page 84 | [page-number] 85 | (nth (partition-all page-size items) page-number nil)) 86 | 87 | ;; Hiccup components 88 | (defn- layout 89 | [body] 90 | [:head 91 | [:title "HTMX: Click to edit"] 92 | (hp/include-js 93 | "https://cdn.tailwindcss.com" 94 | "https://unpkg.com/htmx.org@1.9.4?plugins=forms") 95 | [:body 96 | [:div {:class "container mx-auto mt-10"} 97 | body]]]) 98 | 99 | (defn item-component 100 | [{:keys [id 101 | title 102 | description 103 | author]}] 104 | [:article.p-6.even:bg-white.odd:bg-slate-100.sm:p-8 105 | [:h2.break-all.text-lg.font-medium.sm:text-xl 106 | [:a.hover:underline 107 | {:href (str "htmx/infinite-scroll/item/" id)} 108 | title]] 109 | [:p.mt-1.break-all.text-sm.text-gray-700 110 | description] 111 | [:div.mt-4.text-xs.font-medium.text-gray-500 112 | [:div.flex.items-center.gap-2 113 | [:span.relative.flex.h-10.w-10.shrink-0.overflow-hidden.rounded-full 114 | [:img.aspect-square.h-full.w-full 115 | {:src (:picture author "https://avataaars.io/?hairColor=BrownDark")}]] 116 | [:span (:name author)]]]]) 117 | 118 | (defn loader 119 | [next-page-number] 120 | [:div.bg-red-100.p-10 121 | {:hx-get (str "/htmx/infinite-scroll/items?page=" next-page-number) 122 | :hx-trigger "revealed" 123 | :hx-target "this" 124 | :hx-swap "outerHTML"} 125 | [:span (format "...loading page %s ..." next-page-number)]]) 126 | 127 | (def root-handler 128 | {:name ::root 129 | :enter 130 | (fn [{:keys [dependencies] :as context}] 131 | (let [initial-page-number 0 132 | 133 | items-page (items->page initial-page-number) 134 | 135 | response 136 | (-> [:div 137 | [:h1 {:class "text-2xl font-bold leading-7 text-gray-900 mb-5 sm:p-0 p-6"} 138 | "Infinite scroll"] 139 | [:div.shadow-xl 140 | (map item-component items-page) 141 | (loader (inc initial-page-number))]] 142 | (layout) 143 | (ok))] 144 | (assoc context :response response)))}) 145 | 146 | 147 | (def get-items-page-handler 148 | {:name ::get 149 | :enter 150 | (fn [{:keys [dependencies] :as context}] 151 | #_(Thread/sleep 1000) 152 | (let [page-number (-> context :request :query-params :page parse-long) 153 | items-page (items->page page-number) 154 | 155 | response (if (seq items-page) 156 | (-> (mapv item-component items-page) 157 | (conj (loader (inc page-number))) 158 | (seq) 159 | (ok)) 160 | (-> [:div.bg-green-100.p-10 161 | [:span "nothing to load"]] 162 | (ok)))] 163 | (assoc context :response response)))}) 164 | 165 | (def routes 166 | #{["/htmx/infinite-scroll" 167 | :get root-handler 168 | :route-name ::root] 169 | ["/htmx/infinite-scroll/items" 170 | :get get-items-page-handler 171 | :route-name ::get]}) -------------------------------------------------------------------------------- /test/persistence/real_world_clojure_api/event_sourcing_test.clj: -------------------------------------------------------------------------------- 1 | (ns persistence.real-world-clojure-api.event-sourcing-test 2 | (:require [cheshire.core :as json] 3 | [clojure.test :refer :all] 4 | [com.stuartsierra.component :as component] 5 | [next.jdbc :as jdbc] 6 | [next.jdbc.result-set :as rs] 7 | [real-world-clojure-api.core :as core] 8 | [honey.sql :as sql]) 9 | (:import (org.postgresql.util PGobject) 10 | (org.testcontainers.containers PostgreSQLContainer))) 11 | 12 | (defmacro with-system 13 | [[bound-var binding-expr] & body] 14 | `(let [~bound-var (component/start ~binding-expr)] 15 | (try 16 | ~@body 17 | (finally 18 | (component/stop ~bound-var))))) 19 | 20 | (defn datasource-only-system 21 | [config] 22 | (component/system-map 23 | :datasource (core/datasource-component config))) 24 | 25 | (defn create-database-container 26 | [] 27 | (PostgreSQLContainer. "postgres:15.4")) 28 | 29 | (defn ->jsonb 30 | [value] 31 | (doto (PGobject.) 32 | (.setType "jsonb") 33 | (.setValue (json/encode value)))) 34 | 35 | (defn <-jsonb 36 | [v] 37 | (json/decode (.getValue v) true)) 38 | 39 | (defn insert-event! 40 | [{:keys [datasource]} event] 41 | (jdbc/execute! 42 | (datasource) 43 | (-> {:insert-into [:events] 44 | :values [(update event :payload ->jsonb)] 45 | :returning :*} 46 | (sql/format)) 47 | {:builder-fn rs/as-unqualified-kebab-maps})) 48 | 49 | (defmulti apply-event 50 | (fn [_ event] 51 | (keyword 52 | (str (:aggregate-type event) 53 | "/" 54 | (:type event))))) 55 | 56 | (defmethod apply-event :order/order-created 57 | [_ event] 58 | (merge 59 | {:resource-type (:aggregate-type event) 60 | :order-id (:aggregate-id event) 61 | :created-at (:created-at event)} 62 | (:payload event))) 63 | 64 | (defmethod apply-event :order/order-paid 65 | [state event] 66 | (merge state 67 | (:payload event) 68 | {:updated-at (:created-at event)})) 69 | 70 | (defmethod apply-event :order/order-dispatched 71 | [state event] 72 | (merge state 73 | (:payload event) 74 | {:updated-at (:created-at event)})) 75 | 76 | (defn project 77 | ([events] 78 | (project {} events)) 79 | ([state events] 80 | (reduce apply-event state events))) 81 | 82 | (defn get-by-aggregate-id 83 | [{:keys [datasource]} aggregate-id] 84 | (let [select-query (-> {:select :* 85 | :from :events 86 | :where [:= :aggregate-id aggregate-id] 87 | :order-by [:created-at]} 88 | (sql/format)) 89 | events (jdbc/execute! 90 | (datasource) 91 | select-query 92 | {:builder-fn rs/as-unqualified-kebab-maps})] 93 | (->> events 94 | (map (fn [event] 95 | (update event :payload <-jsonb))) 96 | (project)))) 97 | 98 | (defn get-all-by-customer-id 99 | "Just an example when you need to search by some field that exits only inside the payload, in this case by the customer id" 100 | [{:keys [datasource]} customer-id] 101 | (let [select-query ["SELECT DISTINCT e1.* FROM events e1 102 | INNER JOIN events e2 using (aggregate_id) 103 | WHERE e2.payload ->> 'customer-id' = ?" customer-id] 104 | events (jdbc/execute! 105 | (datasource) 106 | select-query 107 | {:builder-fn rs/as-unqualified-kebab-maps})] 108 | 109 | (->> events 110 | (map (fn [event] 111 | (update event :payload <-jsonb))) 112 | (group-by :aggregate-id) 113 | (vals) 114 | (sort-by :created-at) 115 | (reverse) 116 | (map project)))) 117 | 118 | (deftest event-sourcing-test 119 | (let [database-container (create-database-container)] 120 | (try 121 | (.start database-container) 122 | (with-system 123 | [sut (datasource-only-system 124 | {:db-spec {:jdbcUrl (.getJdbcUrl database-container) 125 | :username (.getUsername database-container) 126 | :password (.getPassword database-container)}})] 127 | (let [customer-id (str "customer:" (random-uuid)) 128 | order-id (random-uuid) 129 | order-created-event {:aggregate-id order-id 130 | :aggregate-type "order" 131 | :type "order-created" 132 | :payload {:items ["x" "y" "z"] 133 | :customer-id customer-id 134 | :price "100.45" 135 | :status "pending"}} 136 | order-paid-event {:aggregate-id order-id 137 | :aggregate-type "order" 138 | :type "order-paid" 139 | :payload {:status "paid" 140 | :payment-method "CARD"}} 141 | tracking-number (str "TX-" (random-uuid)) 142 | order-dispatched-event {:aggregate-id order-id 143 | :aggregate-type "order" 144 | :type "order-dispatched" 145 | :payload {:status "dispatched" 146 | :tracking-number tracking-number}} 147 | other-order-id (random-uuid) 148 | other-order-created-event {:aggregate-id other-order-id 149 | :aggregate-type "order" 150 | :type "order-created" 151 | :payload {:items ["something"] 152 | :customer-id customer-id 153 | :price "99.99" 154 | :status "pending"}}] 155 | (insert-event! sut order-created-event) 156 | (insert-event! sut other-order-created-event) 157 | (insert-event! sut order-paid-event) 158 | (insert-event! sut order-dispatched-event) 159 | 160 | (testing "can get order by id and project events to a resource" 161 | (let [order (get-by-aggregate-id sut order-id)] 162 | #_(is (= [] order)) 163 | (is (= {:items ["x" 164 | "y" 165 | "z"] 166 | :order-id order-id 167 | :payment-method "CARD" 168 | :price "100.45" 169 | :customer-id customer-id 170 | :resource-type "order" 171 | :status "dispatched"} 172 | (dissoc order :updated-at :created-at :tracking-number))) 173 | (is (some? (:tracking-number order)))) 174 | (let [other-order (get-by-aggregate-id sut other-order-id)] 175 | (is (= {:items ["something"] 176 | :order-id other-order-id 177 | :customer-id customer-id 178 | :price "99.99" 179 | :resource-type "order" 180 | :status "pending"} 181 | (dissoc other-order :created-at))) 182 | (is (some? (:created-at other-order))))) 183 | (testing "example of more complex query to search by payload content" 184 | (let [orders (get-all-by-customer-id sut customer-id)] 185 | (is (= 2 (count orders))) 186 | #_(is (= [] orders)))))) 187 | (finally 188 | (.stop database-container))))) --------------------------------------------------------------------------------