├── .gitignore ├── Justfile ├── README.md ├── build.clj ├── deps.edn ├── dev-src └── user.clj ├── package.json ├── postcss.config.js ├── resources ├── config.edn ├── data │ ├── pg-1-products.csv │ ├── pg-2-products.csv │ └── product-groups.csv ├── logback.xml └── public │ ├── favicon.ico │ ├── index.html │ └── info.html ├── scripts ├── get-ping.sh ├── info.sh ├── login.sh ├── post-ping.sh ├── product-2-49.sh ├── product-groups.sh └── products-1.sh ├── shadow-cljs.edn ├── src ├── clj │ └── backend │ │ ├── core.clj │ │ ├── db │ │ ├── domain.clj │ │ └── users.clj │ │ └── webserver.clj ├── cljs │ └── frontend │ │ ├── header.cljs │ │ ├── http.cljs │ │ ├── main.cljs │ │ ├── routes │ │ ├── index.cljs │ │ ├── login.cljs │ │ ├── product.cljs │ │ ├── product_groups.cljs │ │ └── products.cljs │ │ ├── state.cljs │ │ └── util.cljs └── css │ └── app.css ├── tailwind.config.js └── test └── clj └── backend ├── db ├── domain_test.clj └── users_test.clj ├── test_config.clj └── webserver_test.clj /.gitignore: -------------------------------------------------------------------------------- 1 | # Shadow 2 | dev-resources/ 3 | node_modules/ 4 | target/ 5 | yarn.lock 6 | package-lock.json 7 | .shadow-cljs/ 8 | prod-resources/ 9 | .clj-kondo/ 10 | .cpcache/ 11 | .lsp/ 12 | tmp/ 13 | personal/ 14 | .calva/ 15 | .nrepl-port 16 | logs/ 17 | scratch/ 18 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | @list: 2 | just --list 3 | 4 | # NOTE: The backend repls scripts are not needed if you use Calva connection "backend + frontend" 5 | # Just start the 'just frontend', since Calva connects to the frontend 6 | # and uses the backend repl via shadow-cljs. 7 | # NOTE: With Calva, see later: frontend 8 | @backend-calva: 9 | clj -M:dev:test:common:backend:calva:kari 10 | @backend-calva-nrepl: 11 | clj -M:dev:test:common:backend:calva:kari -m nrepl.cmdline 12 | 13 | @backend-run-tests: 14 | clj -M:dev:test:common:backend -m kaocha.runner 15 | 16 | # Init node packages. 17 | @init: 18 | mkdir -p target 19 | mkdir -p classes 20 | rm -rf node_modules 21 | npm install 22 | 23 | # Start frontend auto-compilation, and also needed for Calva to connect to the backend. 24 | # Then in Calva give command: `Calva: Connect to a running REPL Server in the Project`. 25 | # With Calva choose this => Then in Calva choose: Connect to a running REPL... => `backend + frontend`. 26 | @frontend: 27 | rm -rf dev-resources 28 | mkdir -p dev-resources/public/js 29 | npx tailwindcss -i ./src/css/app.css -o ./dev-resources/public/index.css 30 | npm run dev 31 | 32 | @tailwind: 33 | npx tailwindcss -i ./src/css/app.css -o ./dev-resources/public/index.css --watch 34 | 35 | @build-uber: 36 | echo "***** Building frontend *****" 37 | rm -rf prod-resources 38 | mkdir -p prod-resources/public/js 39 | npx tailwindcss -i ./src/css/app.css -o ./prod-resources/public/index.css 40 | npx shadow-cljs release app 41 | echo "***** Building backend *****" 42 | clj -T:build uber 43 | 44 | @run-uber: 45 | PROFILE=prod java -jar target/karimarttila/webstore-standalone.jar 46 | 47 | 48 | # Update dependencies. 49 | @outdated: 50 | # Backend 51 | # See: https://github.com/liquidz/antq#clojure-cli-tools-11111139-or-later 52 | clj -Tantq outdated :upgrade true 53 | # Frontend 54 | # Install: npm i -g npm-check-updates 55 | rm -rf node_modules 56 | ncu -u 57 | npm install 58 | 59 | # Clean .cpcache and .shadow-cljs directories, run npm install 60 | @clean: 61 | rm -rf .cpcache/* 62 | rm -rf .shadow-cljs/* 63 | rm -rf target/* 64 | rm -rf dev-resources/* 65 | rm -rf prod-resources/* 66 | rm -rf out/* 67 | npm install 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clojure (backend) / Clojurescript-React (frontend) demonstration app 2 | 3 | ## Introduction 4 | 5 | I previously created a Javascript-Node (backend) / Typescript-React (frontend) demonstration for my personal learning purposes: [js-node-ts-react](https://github.com/karimarttila/js-node-ts-react). I promised to keep a Clojure lecture in my new corporation, and for this lecture, I thought that it might be interesting for the audience to provide two exact same full-stack applications: one written using the Javascript ecosystem, and one using Clojure. This way my colleagues can, later on, compare these two solutions and make it easier to start learning Clojure when you can compare various backend and frontend solutions to a more familiar Javascript/Typescript example. I have actually created several versions of this webstore example using several languages. 6 | 7 | I previously created the same demonstration ([re-frame-demo](https://github.com/karimarttila/clojure/tree/master/webstore-demo/re-frame-demo)), but in this new repo I modernized that solution and made the solution to be as similar as possible to the Javascript/Typescript example. 8 | 9 | There is also a blog post that compares these two solutions: [Comparing Clojure and Javascript](https://www.karimarttila.fi/clojure/2023/02/TODO/comparing-clojure-and-javascript.html). TODO: Update the link when the blog post is published. 10 | 11 | 12 | ## Prerequisites 13 | 14 | - Install [just](https://github.com/casey/just) command line runner. 15 | - See instructions for configuring Calva in [Configuring VSCode/Calva for Clojure programming - Part 3](https://www.karimarttila.fi/clojure/2022/10/18/clojure-calva-part3.html). 16 | 17 | ## Quick Start Guide for Calva Users 18 | 19 | These instructions apply to Calva users. If you are not using Calva, you can still follow the instructions, but you will need to start the backend REPL also. 20 | 21 | Both the backend and the frontend use the same port 7171. 22 | 23 | Using Calva you just need to start the frontend REPL: `just frontend`. 24 | 25 | Then in Calva give command: `Calva: Connect to a running REPL Server in the Project`. Then: `backend + frontend` (see: [Configuring VSCode/Calva for Clojure programming - Part 3](https://www.karimarttila.fi/clojure/2022/10/18/clojure-calva-part3.html)). 26 | 27 | Now you have the REPL in Calva, the output window is the same for clj and cljs repls. 28 | 29 | Once you have both frontend and backend REPLs running, and you have done the Integrant reset (see in file `user.clj`: `(reset)` ), you can see the frontend in browser at `http://localhost:7171`. The backend is also running at the same port, see examples in the `scripts` directory. 30 | 31 | Start Tailwind CSS processing by: `just tailwind`. 32 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:refer-clojure :exclude [test]) 3 | (:require [clojure.tools.build.api :as b])) 4 | 5 | (def lib 'karimarttila/webstore) 6 | (def main 'backend.core) 7 | (def class-dir "target/classes") 8 | 9 | (defn- uber-opts [opts] 10 | (assoc opts 11 | :lib lib 12 | :main main 13 | :uber-file (format "target/%s-standalone.jar" lib) 14 | :basis (b/create-basis {:project "deps.edn" 15 | :aliases [:common :backend :frontend] 16 | } 17 | ) 18 | :class-dir class-dir 19 | :src-dirs ["src/clj" "src/cljc"] 20 | :ns-compile [main])) 21 | 22 | (defn uber [opts] 23 | (println "Cleaning...") 24 | (b/delete {:path "target"}) 25 | (let [opts (uber-opts opts)] 26 | (println "Copying files...") 27 | (b/copy-dir {:src-dirs ["resources" "prod-resources" "src/clj" "src/cljc"] 28 | :target-dir class-dir}) 29 | (println "Compiling files...") 30 | (b/compile-clj opts) 31 | (println "Creating uberjar...") 32 | (b/uber opts))) 33 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["resources"] 2 | :aliases {:dev {:extra-paths ["dev-resources" "dev-src"] 3 | :extra-deps {org.clojure/clojure {:mvn/version "1.11.1"} 4 | thheller/shadow-cljs {:mvn/version "2.22.9"} 5 | clj-kondo/clj-kondo {:mvn/version "2023.03.17"} 6 | day8.re-frame/re-frame-10x {:mvn/version "1.5.0"} 7 | metosin/reagent-dev-tools {:mvn/version "1.0.0"}}} 8 | :test {:extra-paths ["test/clj"] 9 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.80.1274"} 10 | lambdaisland/kaocha-cljs {:mvn/version "1.4.130"}}} 11 | :common {:extra-paths ["src/cljc"] 12 | :extra-deps {metosin/reitit {:mvn/version "0.6.0"} 13 | aero/aero {:mvn/version "1.1.6"} 14 | metosin/potpuri {:mvn/version "0.5.3"} 15 | integrant/repl {:mvn/version "0.3.2"}}} 16 | :backend {:extra-paths ["src/clj"] 17 | :extra-deps {metosin/ring-http-response {:mvn/version "0.9.3"} 18 | integrant/integrant {:mvn/version "0.8.0"} 19 | clj-time/clj-time {:mvn/version "0.15.2"} 20 | org.clojure/data.codec {:mvn/version "0.1.1"} 21 | org.clojure/data.json {:mvn/version "2.4.0"} 22 | org.clojure/data.csv {:mvn/version "1.0.1"} 23 | org.clojure/tools.logging {:mvn/version "1.2.4"} 24 | ch.qos.logback/logback-classic {:mvn/version "1.4.6"} 25 | ring/ring-core {:mvn/version "1.9.6"} 26 | ring/ring-jetty-adapter {:mvn/version "1.9.6"} 27 | ring/ring-json {:mvn/version "0.5.1"} 28 | ring/ring-defaults {:mvn/version "0.3.4"} 29 | buddy/buddy-sign {:mvn/version "3.4.333"} 30 | clj-http/clj-http {:mvn/version "3.12.3"} 31 | nrepl/nrepl {:mvn/version "1.0.0"}}} 32 | 33 | :frontend {:extra-paths ["src/cljs"] 34 | :extra-deps {org.clojure/clojurescript {:mvn/version "1.11.60"} 35 | fipp/fipp {:mvn/version "0.6.26" 36 | :exclusions [org.clojure/core.rrb-vector]} 37 | reagent/reagent {:mvn/version "1.2.0" 38 | :exclusions [cljsjs.react-dom/cljsjs.react-dom]} 39 | re-frame/re-frame {:mvn/version "1.3.0"} 40 | day8.re-frame/http-fx {:mvn/version "0.2.4"} 41 | binaryage/devtools {:mvn/version "1.0.6"}}} 42 | :css {:extra-deps {deraen/sass4clj {:mvn/version "0.5.5"} 43 | ch.qos.logback/logback-classic {:mvn/version "1.4.6"}} 44 | :main-opts ["-m" "sass4clj.main" 45 | "--source-paths" "src/cljs/simplefrontend" 46 | "--inputs" "main.scss" 47 | "--target-path" "target/shadow/dev/resources/public/css"]} 48 | :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"]} 49 | 50 | ;; Test runner in console. 51 | :test-runner {:extra-paths ["test"] 52 | :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" 53 | :sha "7284cda41fb9edc0f3bc6b6185cfb7138fc8a023"}} 54 | :main-opts ["-m" "cognitect.test-runner"]} 55 | 56 | 57 | :clj-kondo {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2023.03.17"}} 58 | :main-opts ["-m" "clj-kondo.main"]} 59 | ;; Calva-specific 60 | :calva {:extra-deps {cider/cider-nrepl {:mvn/version,"0.30.0"}}} 61 | 62 | :build {:deps {io.github.clojure/tools.build {:mvn/version "0.9.4"}} 63 | :ns-default build} 64 | ;; TODO remove later 65 | #_#_:uberjar {:extra-deps {seancorfield/depstar {:mvn/version "2.0.216"}} 66 | :extra-paths ["prod-resources"] 67 | :main-opts ["-m" "hf.depstar.uberjar" "target/simpleserver.jar"]} 68 | }} 69 | 70 | -------------------------------------------------------------------------------- /dev-src/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [integrant.repl :refer [reset]] 3 | ;[integrant.repl :refer [clear go halt prep init reset reset-all]] 4 | [integrant.repl.state :as state] 5 | [backend.core :as core] 6 | )) 7 | 8 | (integrant.repl/set-prep! core/system-config-start) 9 | 10 | (defn system [] (or state/system (throw (ex-info "System not running" {})))) 11 | 12 | (defn env [] (:backend/env (system))) 13 | 14 | (defn profile [] (:backend/profile (system))) 15 | 16 | (defn my-dummy-reset [] 17 | (reset)) 18 | 19 | ;; NOTE: In Cursive, Integrant hot keys are: 20 | ;; M-h: go 21 | ;; M-j: reset 22 | ;; M-k: halt 23 | 24 | ; In Calva the Integrant hot keys are: 25 | ;; C-T alt+h: go 26 | ;; M-j: reset 27 | ;; C-T alt+k: halt 28 | 29 | 30 | #_(comment 31 | (user/system) 32 | (user/env) 33 | ; alt+j hotkey in Cursive 34 | (integrant.repl/reset) 35 | (+ 1 2) 36 | 37 | ) 38 | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clojurescript-frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "concurrently 'npm:watch:cljs'", 7 | "watch:cljs": "shadow-cljs watch app" 8 | }, 9 | "devDependencies": { 10 | "concurrently": "8.0.1", 11 | "shadow-cljs": "2.22.9", 12 | "postcss": "^8.4.21", 13 | "postcss-cli": "^10.1.0", 14 | "tailwindcss": "^3.3.0" 15 | }, 16 | "dependencies": { 17 | "highlight.js": "11.7.0", 18 | "create-react-class": "15.7.0", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "@tanstack/react-table": "^8.8.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /resources/config.edn: -------------------------------------------------------------------------------- 1 | {; Profile injected to Integrant. 2 | :backend/profile #profile {:prod :prod 3 | :dev :dev 4 | :test :test} 5 | 6 | ; Miscellaneous options. 7 | :backend/options {:jwt {:exp #profile {:prod 1000 8 | :dev 2000 9 | :test 5000}}} 10 | 11 | :backend/env {:profile #ig/ref :backend/profile 12 | :data-dir "resources/data" 13 | ;; Simulate database. 14 | ;; In real life we should have the connection to the database here. 15 | :options #ig/ref :backend/options} 16 | 17 | ;; This way we inject our world (env) to the API calls. 18 | :backend/jetty {:port #profile {:dev #long #or [#env PORT 7171] 19 | :prod #long #or [#env PORT 7172]} 20 | :join? false 21 | :env #ig/ref :backend/env} 22 | 23 | :backend/nrepl {:bind #profile {:prod "0.0.0.0" 24 | :dev "localhost"} 25 | :port #profile {:dev #long #or [#env PORT 7131] 26 | :prod #long #or [#env PORT 7132]}}} 27 | 28 | 29 | -------------------------------------------------------------------------------- /resources/data/pg-1-products.csv: -------------------------------------------------------------------------------- 1 | 2001 1 Kalevala 3.95 Elias Lönnrot 1835 Finland Finnish 2 | 2002 1 Moby Dick 45.35 Herman Melville 1851 United States English 3 | 2003 1 Crime and Punishment 10.51 Fyodor Dostoevsky 1866 Russia Russian 4 | 2004 1 War and Peace 2.65 Leo Tolstoy 1867 Russia Russian 5 | 2005 1 The Idiot 13.42 Fyodor Dostoevsky 1869 Russia Russian 6 | 2006 1 Seitsemän veljestä 7.22 Aleksis Kivi 1870 Finland Finnish 7 | 2007 1 Anna Karenina 21.11 Leo Tolstoy 1877 Russia Russian 8 | 2008 1 The Brothers Karamazov 7.83 Fyodor Dostoevsky 1880 Russia Russian 9 | 2009 1 The Adventures of Huckleberry Finn 61.83 Mark Twain 1884 United States English 10 | 2010 1 Buddenbrooks 58.69 Thomas Mann 1901 Germany German 11 | 2011 1 The Trial 57.22 Franz Kafka 1925 Czechoslovakia German 12 | 2012 1 Mrs Dalloway 7 Virginia Woolf 1925 United Kingdom English 13 | 2013 1 The Castle 15.84 Franz Kafka 1926 Czechoslovakia German 14 | 2014 1 The Sound and the Fury 7.22 William Faulkner 1929 United States English 15 | 2015 1 The Stranger 22.22 Albert Camus 1942 Algeria, French Empire French 16 | 2016 1 Pippi Longstocking 54.61 Astrid Lindgren 1945 Sweden Swedish 17 | 2017 1 Sinuhe egyptiläinen 13.42 Mika Waltari 1945 Finland Finnish 18 | 2018 1 Zorba the Greek 29.06 Nikos Kazantzakis 1946 Greece Greek 19 | 2019 1 Nineteen Eighty-Four 3.95 George Orwell 1949 United Kingdom English 20 | 2020 1 Tales 50.6 Edgar Allan Poe 1950 United States English 21 | 2021 1 The Old Man and the Sea 30.63 Ernest Hemingway 1952 United States English 22 | 2022 1 Havukka-ahon ajattelija 58.69 Veikko Huovinen 1952 Finland Finnish 23 | 2023 1 Tuntematon sotilas 21.11 Väinö Linna 1954 Finland Finnish 24 | 2024 1 Taikatalvi 15.84 Tove Jansson 1957 Finland Finnish 25 | 2025 1 The Tin Drum 22.12 Günter Grass 1959 Germany German 26 | 2026 1 Punainen viiva 7 Ilmari Kianto 1959 Finland Finnish 27 | 2027 1 Täällä Pohjantähden alla 29.06 Väinö Linna 1959 Finland Finnish 28 | 2028 1 Maa on syntinen laulu 61.83 Timo K. Mukka 1964 Finland Finnish 29 | 2029 1 One Hundred Years of Solitude 10.66 Gabriel García Márquez 1967 Colombia Spanish 30 | 2030 1 Manillaköysi 57.22 Veijo Meri 1967 Finland Finnish 31 | 2031 1 Simpauttaja 7.83 Heikki Turunen 1973 Finland Finnish 32 | 2032 1 Jäniksen vuosi 22.22 Arto Paasilinna 1975 Finland Finnish 33 | 2033 1 Blindness 10.7 José Saramago 1995 Portugal Portuguese 34 | 2034 1 Klassikko 54.61 Kari Hotakainen 1997 Finland Finnish 35 | 2035 1 Mielensäpahoittaja 50.6 Tuomas Kyrö 2010 Finland Finnish 36 | -------------------------------------------------------------------------------- /resources/data/pg-2-products.csv: -------------------------------------------------------------------------------- 1 | 1 2 Juurakon Hulda 82.92 Valentin Vaala 1937 Finland Drama 2 | 2 2 Varastettu kuolema 161.32 Nyrki Tapiovaara 1938 Finland Drama 3 | 3 2 Rashomon 13.89 Kurosawa, Akira 1950 Japan Drama-Crime 4 | 4 2 Sunset Blvd. 122.3 Wilder, Billy 1950 USA Drama 5 | 5 2 Strangers on a Train 36.16 Hitchcock, Alfred 1951 USA Thriller-Drama-Crime 6 | 6 2 The African Queen 64.15 Huston, John 1951 USA Romance-Adventure 7 | 7 2 A Place in the Sun 18.46 Stevens, George 1951 USA Romance-Drama 8 | 8 2 Singin' in the Rain 36.88 Donen, Stanley/Gene Kelly 1952 USA Musical-Dance-Comedy 9 | 9 2 Ikiru 192.52 Kurosawa, Akira 1952 Japan Drama 10 | 10 2 Valkoinen peura 2.76 Erik Blomberg 1952 Finland Thriller-Action 11 | 11 2 Gentlemen Prefer Blondes 102.91 Hawks, Howard 1953 USA Romance-Musical-Comedy 12 | 12 2 Seven Samurai 84.97 Kurosawa, Akira 1954 Japan Drama-Action-History 13 | 13 2 Rear Window 12.91 Hitchcock, Alfred 1954 USA Thriller-Mystery 14 | 14 2 La Strada 88.26 Fellini, Federico 1954 Italy Drama 15 | 15 2 East of Eden 8.93 Kazan, Elia 1955 USA Drama 16 | 16 2 Rebel Without a Cause 5.31 Ray, Nicholas 1955 USA Drama 17 | 17 2 Tuntematon sotilas 17.36 Edvin Laine 1955 Finland War 18 | 18 2 Elokuu 0.19 Matti Kassila 1956 Finland Drama 19 | 19 2 The Seventh Seal 83.08 Bergman, Ingmar 1957 Sweden Fantasy-Drama 20 | 20 2 Sweet Smell of Success 19.51 Mackendrick, Alexander 1957 USA Drama 21 | 21 2 Nights of Cabiria 111.53 Fellini, Federico 1957 Italy-France Drama 22 | 22 2 Paths of Glory 44.51 Kubrick, Stanley 1957 USA War-Drama 23 | 23 2 Throne of Blood 4.29 Kurosawa, Akira 1957 Japan War-Drama 24 | 24 2 An Affair to Remember 5.92 McCarey, Leo 1957 USA Romance-Drama 25 | 25 2 The Bridge on the River Kwai 55.09 Lean, David 1957 UK War-Drama-Action 26 | 26 2 Some Like it Hot 6.33 Wilder, Billy 1959 USA Romance-Crime-Comedy 27 | 27 2 Rio Bravo 105.46 Hawks, Howard 1959 USA Western 28 | 28 2 Ben-Hur 38.97 Wyler, William 1959 USA Religious-History 29 | 29 2 Psycho 125.33 Hitchcock, Alfred 1960 USA Thriller-Horror 30 | 30 2 Spartacus 60.3 Kubrick, Stanley 1960 USA Adventure-History-Drama 31 | 31 2 Komisario Palmun erehdys 38.78 Matti Kassila 1960 Finland Crime-Drama 32 | 32 2 Yojimbo 0.68 Kurosawa, Akira 1961 Japan Drama-Action 33 | 33 2 Breakfast at Tiffany's 35.71 Edwards, Blake 1961 USA Romance-Drama-Comedy 34 | 34 2 Kaasua, komisario Palmu 7.34 Matti Kassila 1961 Finland Crime 35 | 35 2 The Man Who Shot Liberty Valance 46.85 Ford, John 1962 USA Western 36 | 36 2 To Kill a Mockingbird 30.22 Mulligan, Robert 1962 USA Drama 37 | 37 2 Hatari! 28.43 Hawks, Howard 1962 USA Drama 38 | 38 2 The Manchurian Candidate 25.82 Frankenheimer, John 1962 USA Political-Thriller-Drama 39 | 39 2 Ride the High Country 58.62 Peckinpah, Sam 1962 USA Western 40 | 40 2 Pojat 3.25 Mikko Niskanen 1962 Finland Drama 41 | 41 2 Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb 99.92 Kubrick, Stanley 1964 UK-USA War-Comedy 42 | 42 2 A Hard Day's Night 27.63 Lester, Richard 1964 UK Musical-Comedy 43 | 43 2 The Good, the Bad and the Ugly 12.5 Leone, Sergio 1966 Italy-Spain Western-Action 44 | 44 2 Who's Afraid of Virginia Woolf? 38.78 Nichols, Mike 1966 USA Drama 45 | 45 2 Käpy selän alla 97.13 Mikko Niskanen 1966 Finland Drama 46 | 46 2 The Graduate 27.58 Nichols, Mike 1967 USA Romance-Drama-Comedy 47 | 47 2 Bonnie and Clyde 2.76 Penn, Arthur 1967 USA Drama-Crime-Biography 48 | 48 2 2001: A Space Odyssey 17.36 Kubrick, Stanley 1968 UK-USA Science Fiction 49 | 49 2 Once Upon a Time in the West 14.4 Leone, Sergio 1968 Italy-USA Western 50 | 50 2 Rosemary's Baby 37.22 Polanski, Roman 1968 USA Thriller-Horror-Drama 51 | 51 2 Night of the Living Dead 21.9 Romero, George A. 1968 USA Horror 52 | 52 2 The Wild Bunch 0.66 Peckinpah, Sam 1969 USA Western 53 | 53 2 Easy Rider 69.02 Hopper, Dennis 1969 USA Drama 54 | 54 2 Ruusujen aika 24.29 Risto Jarva 1969 Finland Drama 55 | 55 2 M*A*S*H 17.5 Altman, Robert 1970 USA War-Comedy 56 | 56 2 Naisenkuvia 136.06 Jörn Donner 1970 Finland Drama 57 | 57 2 Dirty Harry 166 Siegel, Don 1971 USA Crime-Action 58 | 58 2 Solaris 96.76 Tarkovsky, Andrei 1972 USSR Science Fiction-Drama-Mystery 59 | 59 2 Last Tango in Paris 33.7 Bertolucci, Bernardo 1972 France-Italy Romance-Drama-Adult 60 | 60 2 Fellini's Roma 82.92 Fellini, Federico 1972 Italy Drama-Comedy 61 | 61 2 Kahdeksan surmanluotia 14.4 Mikko Niskanen 1972 Finland Thriller-Action 62 | 62 2 Kun taivas putoaa... 166.71 Risto Jarva 1972 Finland Drama 63 | 63 2 The Long Goodbye 3.25 Altman, Robert 1973 USA Drama-Crime 64 | 64 2 Pat Garrett and Billy the Kid 8.47 Peckinpah, Sam 1973 USA Western 65 | 65 2 Sensuela 69.02 Teuvo Tulio 1973 Finland Erotic 66 | 66 2 Maa on syntinen laulu 166 Rauni Mollberg 1973 Finland Drama 67 | 67 2 Yhden miehen sota 3.79 Risto Jarva 1973 Finland War 68 | 68 2 Bring Me the Head of Alfredo Garcia 14.7 Peckinpah, Sam 1974 USA Crime-Thriller-Action 69 | 69 2 One Flew Over the Cuckoo's Nest 0.19 Forman, Milos 1975 USA Drama 70 | 70 2 Monty Python and the Holy Grail 4.28 Gilliam, Terry/Terry Jones 1975 UK History-Comedy 71 | 71 2 Taxi Driver 42.05 Scorsese, Martin 1976 USA Drama-Crime 72 | 72 2 The Outlaw Josey Wales 103.31 Eastwood, Clint 1976 USA Western-Drama 73 | 73 2 Loma 4.28 Risto Jarva 1976 Finland Comedy 74 | 74 2 Star Wars 66.77 Lucas, George 1977 USA Science Fiction-Fantasy-Adventure 75 | 75 2 Close Encounters of the Third Kind 72.74 Spielberg, Steven 1977 USA Science Fiction-Adventure 76 | 76 2 Jäniksen vuosi 0.66 Risto Jarva 1977 Finland Comedy 77 | 77 2 The Deer Hunter 161.32 Cimino, Michael 1978 USA War-Drama 78 | 78 2 Apocalypse Now 13.53 Coppola, Francis Ford 1979 USA War-Drama-Adventure 79 | 79 2 Stalker 7.34 Tarkovsky, Andrei 1979 USSR Science Fiction-Mystery 80 | 80 2 Alien 9.64 Scott, Ridley 1979 USA Science Fiction-Horror 81 | 81 2 Monty Python's Life of Brian 4.79 Jones, Terry 1979 UK Religious-Comedy 82 | 82 2 Raging Bull 166.71 Scorsese, Martin 1980 USA Sport-Drama-Biography 83 | 83 2 The Shining 97.13 Kubrick, Stanley 1980 USA Horror 84 | 84 2 The Empire Strikes Back 167.68 Kershner, Irvin 1980 USA Science Fiction-Fantasy-Adventure 85 | 85 2 Kagemusha 136.06 Kurosawa, Akira 1980 Japan War-Drama-History 86 | 86 2 Täältä tullaan elämä! 72.74 Tapio Suominen 1980 Finland Drama 87 | 87 2 Raiders of the Lost Ark 6.97 Spielberg, Steven 1981 USA Adventure-Action 88 | 88 2 Mad Max 2 56.65 Miller, George 1981 Australia Science Fiction-Action-Adventure 89 | 89 2 Excalibur 16.99 Boorman, John 1981 UK Fantasy-Adventure-Action 90 | 90 2 Das Boot 24.29 Petersen, Wolfgang 1981 West Germany Action-Adventure-Drama 91 | 91 2 Reds 12.11 Beatty, Warren 1981 USA History-Drama-Biography 92 | 92 2 Blade Runner 3.79 Scott, Ridley 1982 USA Science Fiction-Thriller-Drama 93 | 93 2 Fanny and Alexander 43.33 Bergman, Ingmar 1982 Sweden Drama 94 | 94 2 E.T. The Extra-Terrestrial 125.49 Spielberg, Steven 1982 USA Science Fiction-Family 95 | 95 2 The Thing 46.87 Carpenter, John 1982 USA Science Fiction-Action-Horror 96 | 96 2 Arvottomat 33.7 Mika Kaurismäki 1982 Finland Drama 97 | 97 2 Scarface 22.71 De Palma, Brian 1983 USA Drama-Crime 98 | 98 2 Once Upon a Time in America 5.59 Leone, Sergio 1984 Italy-USA Drama-Crime 99 | 99 2 Paris, Texas 71.21 Wenders, Wim 1984 USA Drama 100 | 100 2 This is Spinal Tap 17.71 Reiner, Rob 1984 USA Comedy-Musical 101 | 101 2 Amadeus 38.18 Forman, Milos 1984 USA Musical-Biography-Drama 102 | 102 2 The Terminator 23.74 Cameron, James 1984 USA Science Fiction-Action 103 | 103 2 Brazil 19.3 Gilliam, Terry 1985 UK Science Fiction-Fantasy 104 | 104 2 Ran 0.8 Kurosawa, Akira 1985 France-Japan War-History-Drama 105 | 105 2 Back to the Future 35.21 Zemeckis, Robert 1985 USA Science Fiction-Comedy 106 | 106 2 Calamari Union 17.5 Aki Kaurismäki 1985 Finland Drama 107 | 107 2 Tuntematon sotilas 96.76 Rauni Mollberg 1985 Finland War 108 | 108 2 Blue Velvet 102.42 Lynch, David 1986 USA Mystery-Crime-Drama 109 | 109 2 Varjoja paratiisissa 8.47 Aki Kaurismäki 1986 Finland Drama 110 | 110 2 Lumikuningatar 167.68 Päivi Hartzell 1986 Finland Drama 111 | 111 2 Full Metal Jacket 127 Kubrick, Stanley 1987 USA War-Drama-Action 112 | 112 2 Die Hard 7.74 McTiernan, John 1988 USA Thriller-Action 113 | 113 2 The Last Temptation of Christ 1.49 Scorsese, Martin 1988 USA-Canada Religious-Drama 114 | 114 2 Akira 32.64 Otomo, Katsuhiro 1988 Japan Animated-Science Fiction-Action 115 | 115 2 Katsastus 9.64 Matti Ijäs 1988 Finland Drama 116 | 116 2 Do the Right Thing 103.94 Lee, Spike 1989 USA Drama-Comedy 117 | 117 2 The Cook, the Thief, His Wife & Her Lover 0.62 Greenaway, Peter 1989 UK-France Drama 118 | 118 2 Dead Poets Society 4.55 Weir, Peter 1989 USA Drama 119 | 119 2 GoodFellas 52.56 Scorsese, Martin 1990 USA Drama-Crime 120 | 120 2 Miller's Crossing 24.24 Coen, Joel & Ethan Coen 1990 USA Crime 121 | 121 2 Akira Kurosawa's Dreams 150.53 Kurosawa, Akira 1990 USA Fantasy 122 | 122 2 Tulitikkutehtaan tyttö 66.77 Aki Kaurismäki 1990 Finland Drama 123 | 123 2 The Silence of the Lambs 11.04 Demme, Jonathan 1991 USA Thriller-Mystery 124 | 124 2 JFK 49.91 Stone, Oliver 1991 USA History-Drama 125 | 125 2 Thelma & Louise 83.62 Scott, Ridley 1991 USA Drama-Comedy-Adventure 126 | 126 2 Zombie ja kummitusjuna 43.33 Mika Kaurismäki 1991 Finland Drama 127 | 127 2 Unforgiven 23.45 Eastwood, Clint 1992 USA Western 128 | 128 2 Reservoir Dogs 93.73 Tarantino, Quentin 1992 USA Drama-Crime-Thriller 129 | 129 2 Tuhlaajapoika 103.31 Veikko Aaltonen 1992 Finland Drama 130 | 130 2 Takaisin ryssiin 12.11 Jari Halonen 1992 Finland Drama 131 | 131 2 The Piano 0.22 Campion, Jane 1993 New Zealand-Australia-France History-Drama-Romance 132 | 132 2 Schindler's List 3.78 Spielberg, Steven 1993 USA War-History-Drama 133 | 133 2 Groundhog Day 30.52 Ramis, Harold 1993 USA Romance-Fantasy-Comedy 134 | 134 2 Pulp Fiction 64.93 Tarantino, Quentin 1994 USA Drama-Crime-Comedy 135 | 135 2 Forrest Gump 45.49 Zemeckis, Robert 1994 USA Drama-Comedy 136 | 136 2 Léon 20.64 Besson, Luc 1994 France Thriller-Crime 137 | 137 2 Heat 51.55 Mann, Michael 1995 USA Thriller-Drama-Crime 138 | 138 2 Casino 49.07 Scorsese, Martin 1995 USA Drama-Crime 139 | 139 2 The Bridges of Madison County 44.5 Eastwood, Clint 1995 USA Romance-Drama 140 | 140 2 The Usual Suspects 14.98 Singer, Bryan 1995 USA Thriller-Mystery-Crime 141 | 141 2 Fargo 3.86 Coen, Joel & Ethan Coen 1996 USA Drama-Crime-Comedy 142 | 142 2 Trainspotting 0.54 Boyle, Danny 1996 UK Drama-Comedy 143 | 143 2 Kauas pilvet karkaavat 27.58 Aki Kaurismäki 1996 Finland Drama 144 | 144 2 L.A. Confidential 3.83 Hanson, Curtis 1997 USA Thriller-Crime 145 | 145 2 Starship Troopers 5.95 Verhoeven, Paul 1997 USA Science Fiction-Action-Adventure-War 146 | 146 2 Neitoperho 42.05 Auli Mantila 1997 Finland Drama 147 | 147 2 Magnolia 3.47 Anderson, Paul Thomas 1999 USA Drama 148 | 148 2 The Matrix 114.7 Wachowski, Lana & Lilly Wachowski 1999 USA-Australia Science Fiction-Action-Adventure 149 | 149 2 Eyes Wide Shut 23.85 Kubrick, Stanley 1999 USA-UK Thriller-Drama 150 | 150 2 Fight Club 6.8 Fincher, David 1999 USA-Germany Drama 151 | 151 2 American Beauty 139.87 Mendes, Sam 1999 USA Drama-Comedy 152 | 152 2 Pitkä kuuma kesä 6.97 Perttu Leppä 1999 Finland Drama 153 | 153 2 Mulholland Dr. 3.33 Lynch, David 2001 France-USA Mystery-Drama-Thriller 154 | 154 2 A.I. Artificial Intelligence 8.63 Spielberg, Steven 2001 USA Science Fiction-Drama 155 | 155 2 Amélie 183.34 Jeunet, Jean-Pierre 2001 France-Germany Romance-Comedy 156 | 156 2 Mies vailla menneisyyttä 37.22 Aki Kaurismäki 2002 Finland Drama 157 | 157 2 Aleksis Kiven elämä 14.7 Jari Halonen 2002 Finland Drama 158 | 158 2 Million Dollar Baby 37.11 Eastwood, Clint 2004 USA Drama 159 | 159 2 Koirankynnen leikkaaja 4.79 Markku Pölönen 2004 Finland Drama 160 | 160 2 Brokeback Mountain 134.34 Lee, Ang 2005 USA-Canada Romance-Drama 161 | 161 2 A History of Violence 23.66 Cronenberg, David 2005 USA-Germany Crime-Drama-Mystery 162 | 162 2 Paha maa 21.9 Aku Louhimies 2005 Finland Drama 163 | 163 2 Äideistä parhain 125.49 Klaus Härö 2005 Finland Drama 164 | 164 2 There Will Be Blood 117.9 Anderson, Paul Thomas 2007 USA Drama 165 | 165 2 No Country for Old Men 19.93 Coen, Joel & Ethan Coen 2007 USA Crime-Drama 166 | 166 2 Avatar 26.83 Cameron, James 2009 USA-UK Action-Science Fiction-Adventure 167 | 167 2 Postia pappi Jaakobille 56.65 Klaus Härö 2009 Finland Drama 168 | 168 2 Rare Exports 16.99 Jalmari Helander 2010 Finland Thriller-Action 169 | 169 2 Hyvä poika 13.53 Zaida Bergroth 2011 Finland Drama 170 | -------------------------------------------------------------------------------- /resources/data/product-groups.csv: -------------------------------------------------------------------------------- 1 | 1 Books 2 | 2 Movies 3 | -------------------------------------------------------------------------------- /resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 29 | 30 | 31 | logs/backend.log 32 | 33 | 34 | logs/archived/app.%d{yyyy-MM-dd}.%i.log.gz 35 | 36 | 10MB 37 | 38 | 20MB 39 | 40 | 30 41 | 42 | 43 | 44 | %d %p [%t] %m%n 45 | 46 | 47 | 48 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karimarttila/clojure-full-stack-demo/5520c0123d6f6bc1c186b7dc94a6e367768417bb/resources/public/favicon.ico -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Clojure-full-stack-demo 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resources/public/info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Simple Server

6 | 7 |

This is the Simple Server. 8 | 9 | Available commands are: 10 | 11 |

23 | 24 | NOTE: You can see examples how to call these endpoints in the scripts directory (using curl). 25 | 26 |

27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /scripts/get-ping.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | http http://localhost:7171/api/ping 4 | -------------------------------------------------------------------------------- /scripts/info.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | http http://localhost:7171/api/info 3 | -------------------------------------------------------------------------------- /scripts/login.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo '{"username": "jartsa", "password": "joo"}' | http --json POST localhost:7171/api/login 4 | 5 | #http POST http://localhost:7171/api/login username=jartsa password=joo Content-Type:application/json 6 | 7 | #curl -H "Content-Type: application/json" -X POST -d '{"username": "jartsa", "password": "joo"}' http://localhost:7171/api/login 8 | -------------------------------------------------------------------------------- /scripts/post-ping.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -H "Content-Type: application/json" -X POST -d '{"ping":"Jee!"}' http://localhost:7171/api/ping -------------------------------------------------------------------------------- /scripts/product-2-49.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RET=$(http POST http://localhost:7171/api/login username=jartsa password=joo Content-Type:application/json) 4 | TOKEN=$(echo $RET | jq '.token' | tr -d '"') 5 | #echo $TOKEN 6 | 7 | 8 | http http://localhost:7171/api/product/2/49 x-token:$TOKEN -------------------------------------------------------------------------------- /scripts/product-groups.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RET=$(http POST http://localhost:7171/api/login username=jartsa password=joo Content-Type:application/json) 4 | TOKEN=$(echo $RET | jq '.token' | tr -d '"') 5 | #echo $TOKEN 6 | 7 | #http http://localhost:7171/api/product-groups 8 | #http http://localhost:7171/api/product-groups x-token:"WRONG-TOKEN" 9 | http http://localhost:7171/api/product-groups x-token:$TOKEN 10 | -------------------------------------------------------------------------------- /scripts/products-1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RET=$(http POST http://localhost:7171/api/login username=jartsa password=joo Content-Type:application/json) 4 | TOKEN=$(echo $RET | jq '.token' | tr -d '"') 5 | #echo $TOKEN 6 | 7 | http http://localhost:7171/api/products/1 x-token:$TOKEN 8 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | ;; shadow-cljs configuration 2 | {:deps {:aliases [:dev :common :backend :frontend :test :kari :calva]} 3 | :dependencies [] 4 | :builds {:app {:target :browser 5 | :output-dir "dev-resources/public/js" 6 | :asset-path "/js" 7 | :devtools {:watch-dir "dev-resources/public" 8 | :preloads [hashp.core] 9 | ;:preloads [devtools.preload 10 | ; reagent-dev-tools.preload 11 | ; day8.re-frame-10x.preload 12 | ; ] 13 | } 14 | :modules {:main {:entries [frontend.main]}} 15 | :release {:output-dir "prod-resources/public/js" 16 | :compiler-options {:source-map "prod-resources/public/js/main.js.map" 17 | :optimizations :advanced}} 18 | ;:compiler-options {:infer-externs :auto 19 | ; :closure-defines {"re_frame.trace.trace_enabled_QMARK_" true} 20 | ; :external-config {:reagent-dev-tools {:state-atom re-frame.db.app-db}} 21 | ; } 22 | ;; :source-map true 23 | }}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/clj/backend/core.clj: -------------------------------------------------------------------------------- 1 | (ns backend.core 2 | (:require 3 | [clojure.tools.logging :as log] 4 | [clojure.pprint] 5 | [clojure.java.io :as io] 6 | [ring.adapter.jetty :as jetty] 7 | [nrepl.server :as nrepl] 8 | [integrant.repl :as ig-repl] 9 | [integrant.core :as ig] 10 | [aero.core :as aero] 11 | [backend.webserver :as b-webserver] 12 | [backend.db.domain :as b-domain] 13 | [clojure.tools.reader.edn :as edn] 14 | [potpuri.core :as p]) 15 | (:gen-class)) 16 | 17 | (defn env-value [key default] 18 | (some-> (or (System/getenv (name key)) default))) 19 | 20 | (defmethod aero/reader 'ig/ref [_ _ value] (ig/ref value)) 21 | 22 | (defmethod ig/init-key :backend/profile [_ profile] 23 | profile) 24 | 25 | (defn create-users [] 26 | [{:username "jartsa" :password "joo"} 27 | {:username "rane" :password "jee"} 28 | {:username "d" :password "d"}]) 29 | 30 | (defmethod ig/init-key :backend/env [_ {:keys [_profile data-dir] :as m}] 31 | (log/debug "ENTER ig/init-key :backend/env") 32 | ;; Simulates our database. 33 | (conj m {:db (atom {:domain (b-domain/get-domain-data data-dir) 34 | :sessions #{} 35 | :users (create-users)})})) 36 | 37 | (defmethod ig/halt-key! :backend/env [_ this] 38 | (log/debug "ENTER ig/halt-key! :backend/env") 39 | this) 40 | 41 | (defmethod ig/suspend-key! :backend/env [_ this] 42 | (log/debug "ENTER ig/suspend-key! :backend/env") 43 | this) 44 | 45 | (defmethod ig/resume-key :backend/env [_ _ _ old-impl] 46 | (log/debug "ENTER ig/resume-key :backend/env") 47 | old-impl) 48 | 49 | (defmethod ig/init-key :backend/jetty [_ {:keys [port join? env]}] 50 | (log/debug "ENTER ig/init-key :backend/jetty") 51 | (-> (b-webserver/handler (b-webserver/routes env)) 52 | (jetty/run-jetty {:port port :join? join?}))) 53 | 54 | (defmethod ig/halt-key! :backend/jetty [_ server] 55 | (log/debug "ENTER ig/halt-key! :backend/jetty") 56 | (.stop server)) 57 | 58 | (defmethod ig/init-key :backend/options [_ options] 59 | (log/debug "ENTER ig/init-key :backend/options") 60 | options) 61 | 62 | (defmethod ig/init-key :backend/nrepl [_ {:keys [bind port]}] 63 | (log/debug "ENTER ig/init-key :backend/nrepl") 64 | (if (and bind port) 65 | (nrepl/start-server :bind bind :port port) 66 | nil)) 67 | 68 | (defmethod ig/halt-key! :backend/nrepl [_ this] 69 | (log/debug "ENTER ig/halt-key! :backend/nrepl") 70 | (if this 71 | (nrepl/stop-server this))) 72 | 73 | (defmethod ig/suspend-key! :backend/nrepl [_ this] 74 | (log/debug "ENTER ig/suspend-key! :backend/nrepl") 75 | this) 76 | 77 | (defmethod ig/resume-key :backend/nrepl [_ _ _ old-impl] 78 | (log/debug "ENTER ig/resume-key :backend/nrepl") 79 | old-impl) 80 | 81 | ; You can add to the container script PROFILE=prod 82 | (defn read-config [profile] 83 | (let [local-config (let [file (io/file "config-local.edn")] 84 | #_{:clj-kondo/ignore [:missing-else-branch]} 85 | (if (.exists file) (edn/read-string (slurp file))))] 86 | (cond-> (aero/read-config (io/resource "config.edn") {:profile profile}) 87 | local-config (p/deep-merge local-config)))) 88 | 89 | (defn system-config [myprofile] 90 | (let [profile (or myprofile (some-> (System/getenv "PROFILE") keyword) :dev) 91 | _ (log/info "Using profile " profile) 92 | config (read-config profile)] 93 | config)) 94 | 95 | (defn system-config-start [] 96 | (system-config nil)) 97 | 98 | (defn -main [] 99 | (log/info "System starting...") 100 | (let [config (system-config-start) 101 | _ (log/info "Config: " config)] 102 | (ig-repl/set-prep! (constantly config)) 103 | (ig-repl/go))) 104 | 105 | 106 | #_(comment 107 | (ig-repl/reset) 108 | (ig-repl/halt) 109 | (user/system) 110 | (keys (user/system)) 111 | (:backend/env (user/system)) 112 | (keys (:backend/env (user/system))) 113 | (keys @(:backend/env (user/system))) 114 | (type @(:backend/env (user/system))) 115 | (:users @(:backend/env (user/system))) 116 | (:users @(:backend/env (user/system))) 117 | (user/env) 118 | (user/profile) 119 | (System/getenv) 120 | (env-value :PATH :foo) 121 | ) -------------------------------------------------------------------------------- /src/clj/backend/db/domain.clj: -------------------------------------------------------------------------------- 1 | (ns backend.db.domain 2 | "Loads the domain data from csv" 3 | (:require 4 | [clojure.tools.logging :as log] 5 | [clojure.data.csv :as csv] 6 | [clojure.java.io :as io] 7 | [clojure.string :as str]) 8 | (:import (java.io FileNotFoundException))) 9 | 10 | (defn get-product-groups [data-dir] 11 | (log/debug (str "ENTER load-product-groups")) 12 | (let [raw (with-open [reader (io/reader (str data-dir "/product-groups.csv"))] 13 | (doall 14 | (csv/read-csv reader))) 15 | ;;_ #p raw 16 | ] 17 | (into [] (map 18 | (fn [[item]] 19 | (let [[pgId name] (str/split item #"\t")] 20 | {:pgId (Integer/parseInt pgId) :name name})) 21 | raw)))) 22 | 23 | 24 | (defn get-raw-products [data-dir pg-id] 25 | (log/debug (str "ENTER get-raw-products, pg-id: " pg-id)) 26 | (let [raw-products (try 27 | (with-open [reader (io/reader (str data-dir "/pg-" pg-id "-products.csv"))] 28 | (doall 29 | (csv/read-csv reader :separator \tab))) 30 | (catch FileNotFoundException _ nil)) 31 | ;_ #p raw-products 32 | ] 33 | raw-products)) 34 | 35 | 36 | (defn create-book [item] 37 | (try 38 | {:pId (Integer/parseInt (get item 0)) 39 | :pgId (Integer/parseInt (get item 1)) 40 | :title (get item 2) 41 | :price (Double/parseDouble (get item 3)) 42 | :author (get item 4) 43 | :year (Integer/parseInt (get item 5)) 44 | :country (get item 6) 45 | :language (get item 7)} 46 | (catch Exception e {:msg (str "Exception: " (.getMessage e)) 47 | :item item}))) 48 | 49 | 50 | (defn create-movie [item] 51 | (try 52 | {:pId (Integer/parseInt (get item 0)) 53 | :pgId (Integer/parseInt (get item 1)) 54 | :title (get item 2) 55 | :price (Double/parseDouble (get item 3)) 56 | :director (get item 4) 57 | :year (Integer/parseInt (get item 5)) 58 | :country (get item 6) 59 | :genre (get item 7)} 60 | (catch Exception e {:msg (str "Exception: " (.getMessage e)) 61 | :item item}))) 62 | 63 | 64 | (defn convert-products [raw-products] 65 | (map (fn [item] 66 | (if (= (count item) 8) 67 | (let [pg-id (Integer/parseInt (get item 1))] 68 | (cond 69 | (not= (count item) 8) nil 70 | (= pg-id 1) (create-book item) 71 | (= pg-id 2) (create-movie item) 72 | :else nil)) 73 | nil) 74 | ) 75 | raw-products)) 76 | 77 | 78 | (defn get-domain-data [data-dir] 79 | (let [product-groups (get-product-groups data-dir) 80 | ;_ #p product-groups 81 | pg-ids (map (fn [item] (:pgId item)) product-groups) 82 | ;_ #p pg-ids 83 | raw-products (mapcat (fn [pg-id] 84 | (get-raw-products data-dir pg-id)) pg-ids) 85 | ;_ #p raw-products 86 | products (convert-products raw-products)] 87 | {:product-groups product-groups 88 | :products products})) 89 | 90 | 91 | ;(def my-atom (atom nil)) 92 | 93 | 94 | (defn get-products [pg-id products] 95 | (let [;_ (reset! my-atom products) 96 | ] 97 | (filter (fn [item] (= (:pgId item) pg-id)) products))) 98 | 99 | 100 | (defn get-product [pg-id p-id products] 101 | (first (filter (fn [item] (and (= (:pgId item) pg-id) (= (:pId item) p-id))) products))) 102 | 103 | 104 | 105 | 106 | (comment 107 | ;; (count @my-atom) 108 | ;; (count (filter (fn [item] (= (:pgId item) 1)) @my-atom)) 109 | ;; (count (get-products 1 @my-atom)) 110 | 111 | 112 | (def my-data-dir "resources/data") 113 | (def my-product-groups (get-product-groups my-data-dir)) 114 | my-product-groups 115 | (def my-pg-ids (map (fn [item] (:pgId item)) my-product-groups)) 116 | my-pg-ids 117 | (def my-raw-products (mapcat (fn [pg-id] 118 | (get-raw-products my-data-dir pg-id)) my-pg-ids)) 119 | my-raw-products 120 | (count (first my-raw-products)) 121 | (convert-products my-raw-products) 122 | 123 | (get-domain-data "resources/data") 124 | (keys (get-domain-data "resources/data")) 125 | (count (:products (get-domain-data "resources/data"))) 126 | (get-product-groups "resources/data") 127 | (get-raw-products "resources/data" 1) 128 | 129 | (let [products (:products (get-domain-data "resources/data"))] 130 | (first (filter (fn [item] (and (= (:pgId item) 2) (= (:pId item) 49))) products))) 131 | 132 | (get-product 2 49 (:products (get-domain-data "resources/data"))) 133 | 134 | ) 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /src/clj/backend/db/users.clj: -------------------------------------------------------------------------------- 1 | (ns backend.db.users 2 | (:require [clojure.tools.logging :as log] 3 | [buddy.sign.jwt :as buddy-jwt] 4 | [clojure.data.codec.base64 :as base64] 5 | [clj-time.core :as clj-time])) 6 | 7 | 8 | ;; Demonstration: For development purposes. 9 | (def my-atom (atom {})) 10 | #_(comment 11 | @my-atom 12 | (keys @my-atom) 13 | (:db @my-atom) 14 | (keys @(:db @my-atom)) 15 | (:users @(:db @my-atom))) 16 | 17 | (def my-hex-secret 18 | "Creates dynamically a hex secret when the server boots." 19 | ((fn [] 20 | (let [my-chars (->> (range (int \a) (inc (int \z))) (map char)) 21 | my-ints (->> (range (int \0) (inc (int \9))) (map char)) 22 | my-set (lazy-cat my-chars my-ints) 23 | hexify (fn [s] 24 | (apply str 25 | (map #(format "%02x" (int %)) s)))] 26 | (hexify (repeatedly 24 #(rand-nth my-set))))))) 27 | 28 | 29 | (defn check-password [env username password] 30 | (log/debug (str "ENTER check-password")) 31 | ;; For demonstration 32 | ;(reset! my-atom env) 33 | (log/debug (str username)) 34 | (let [users (:users @(:db env))] 35 | (= 1 (count (filter (fn [user] 36 | (and (= (:username user) username) (= (:password user) password))) 37 | users))))) 38 | 39 | (defn generate-token [env username] 40 | (log/debug (str "ENTER generate-token, username: " username)) 41 | (let [my-secret my-hex-secret 42 | exp-time (clj-time/plus (clj-time/now) (clj-time/seconds (get-in env [:options :jwt :exp]))) 43 | my-claim {:username username :exp exp-time} 44 | json-web-token (buddy-jwt/sign my-claim my-secret)] 45 | json-web-token)) 46 | 47 | 48 | (defn add-session [env session] 49 | (log/debug (str "ENTER add-session")) 50 | (let [db (:db env)] 51 | ;; First remove the old session, if exists. 52 | (let [old-session (first (filter (fn [s] 53 | (and (= (:username s) (:username session)) 54 | (= (:password s) (:password session)))) 55 | (:sessions @db)))] 56 | (when old-session 57 | (swap! db update-in [:sessions] disj old-session))) 58 | ;; Then add a new session. 59 | (swap! db update-in [:sessions] conj session))) 60 | 61 | 62 | ;; For testing purposes. 63 | (defn clear-sessions [env] 64 | (log/debug (str "ENTER clear-sessions")) 65 | (let [db (:db env)] 66 | ;; Just reset :sessions key with an empty set. 67 | (swap! db assoc :sessions #{}))) 68 | 69 | ;; For testing purposes. 70 | (defn get-sessions [env] 71 | (log/debug (str "ENTER clear-sessions")) 72 | (let [db (:db env)] 73 | (:sessions @db))) 74 | 75 | 76 | (defn validate-user [env username password] 77 | (let [token (if (check-password env username password) 78 | (generate-token env username) 79 | nil)] 80 | (when token 81 | (let [session {:username username :password password :token token}] 82 | (add-session env session))) 83 | token)) 84 | 85 | 86 | (defn validate-token 87 | [env token] 88 | (log/debug (str "ENTER validate-token, token: " token)) 89 | (let [;_ (reset! my-atom token) 90 | db (:db env) 91 | session (first (filter (fn [s] (= (:token s) token)) 92 | (:sessions @db)))] 93 | ;; Part #1 of validation. 94 | (if (nil? session) 95 | (do 96 | (log/warn (str "Token not found in the session database - unknown token: " token)) 97 | nil) 98 | ;; Part #2 of validation. 99 | (let [decoded-token (try 100 | (buddy-jwt/unsign token my-hex-secret) 101 | (catch Exception e 102 | (if (.contains (.getMessage e) "Token is expired") 103 | (do 104 | (log/debug (str "Token is expired, removing it from my sessions and returning nil: " token)) 105 | ; TODO : remove token 106 | nil) 107 | ; Some other issue. 108 | (do 109 | (log/error (str "Some unknown exception when handling expired token, exception: " (.getMessage e)) ", token: " token) 110 | nil))))] 111 | (if decoded-token 112 | (if (= (:username session) (:username decoded-token)) 113 | decoded-token 114 | (do 115 | (log/error (str "Token username does not match the session username")) 116 | nil)) 117 | nil))))) 118 | 119 | 120 | (comment 121 | @my-atom 122 | (buddy-jwt/unsign @my-atom my-hex-secret)) 123 | 124 | 125 | #_(comment 126 | (keys (user/env)) 127 | (:db (user/env)) 128 | (keys @(:db (user/env))) 129 | (:users @(:db (user/env))) 130 | (:sessions @(:db (user/env))) 131 | (def users (:users @(:db (user/env)))) 132 | users 133 | (filter (fn [user] 134 | (and (= (:username user) "jartsa") (= (:password user) "joo"))) 135 | users) 136 | (= 1 (count (filter (fn [user] 137 | (and (= (:username user) "jartsa") (= (:password user) "joo"))) 138 | users))) 139 | ;; Now move expression to the function above (and change hardcoded valules to function arguments). 140 | (check-password (user/env) "jartsa" "joo") 141 | (check-password (user/env) "jartsa" "WRONG") 142 | @(:db (user/env)) 143 | 144 | (:sessions @(:db (user/env))) 145 | (def sessions (:sessions @(:db (user/env)))) 146 | sessions 147 | (def session {:username "jartsa", 148 | :password "joo", 149 | :token 150 | "JEEE"}) 151 | (filter (fn [s] 152 | (and (= (:username s) (:username session)) (= (:password s) (:password session)))) 153 | (:sessions @(:db (user/env)))) 154 | 155 | (def mydb (atom @(:db (user/env)))) 156 | mydb 157 | (def old-session (first (filter (fn [s] 158 | (and (= (:username s) (:username session)) (= (:password s) (:password session)))) 159 | (:sessions @mydb)))) 160 | 161 | (def session {:username "jartsa", 162 | :password "joo", 163 | :token 164 | "JEEE"}) 165 | 166 | (def mydb (atom {:sessions #{session}, 167 | :users [{:username "jartsa", :password "joo"}]})) 168 | 169 | 170 | 171 | @mydb 172 | (swap! mydb update-in [:sessions] disj session) 173 | 174 | (get-sessions (user/env)) 175 | (add-session (user/env) {:token "jee"}) 176 | (clear-sessions (user/env)) 177 | 178 | ) -------------------------------------------------------------------------------- /src/clj/backend/webserver.clj: -------------------------------------------------------------------------------- 1 | (ns backend.webserver 2 | (:require [clojure.tools.logging :as log] 3 | [clojure.string :as string] 4 | [clojure.data.codec.base64 :as base64] 5 | [ring.util.http-response :as ring-response] 6 | [reitit.ring :as reitit-ring] 7 | [reitit.coercion.malli] 8 | [reitit.swagger :as reitit-swagger] 9 | [reitit.swagger-ui :as reitit-swagger-ui] 10 | [reitit.ring.malli] 11 | [reitit.ring.coercion :as reitit-coercion] 12 | [reitit.ring.middleware.muuntaja :as reitit-muuntaja] 13 | [reitit.ring.middleware.exception :as reitit-exception] 14 | [reitit.ring.middleware.parameters :as reitit-parameters] 15 | [reitit.ring.middleware.dev] 16 | [muuntaja.core :as mu-core] 17 | [backend.db.users :as b-users] 18 | [backend.db.domain :as b-domain])) 19 | 20 | 21 | (defn make-response [response-value] 22 | (if (= (:ret response-value) :ok) 23 | (ring-response/ok response-value) 24 | (ring-response/bad-request response-value))) 25 | 26 | 27 | (defn info 28 | "Gets the info." 29 | [_] 30 | (log/debug "ENTER info") 31 | {:status 200 :body {:info "/info.html => Info in HTML format"}}) 32 | 33 | (defn validate-parameters 34 | "Extremely simple validator - just checks that all fields must have some value. 35 | `field-values` - a list of fields to validate." 36 | [field-values] 37 | (every? #(seq %) field-values)) 38 | 39 | (defn make-response [response-value] 40 | (if (= (:ret response-value) :ok) 41 | (ring-response/ok response-value) 42 | (ring-response/bad-request response-value))) 43 | 44 | 45 | (defn login 46 | "Provides API for login page." 47 | [env username password] 48 | (log/debug "ENTER login") 49 | (let [validation-passed (validate-parameters [username password]) 50 | token (if validation-passed 51 | (b-users/validate-user env username password) 52 | nil) 53 | response-value (if (not validation-passed) 54 | {:ret :failed :msg "Validation failed - some fields were empty"} 55 | (if (not token) 56 | {:ret :failed :msg "Login failed"} 57 | {:ret :ok :token token}))] 58 | (make-response response-value))) 59 | 60 | 61 | (defn product-groups 62 | "Gets product groups." 63 | [env token] 64 | (log/debug "ENTER -product-groups") 65 | (let [token-ok (b-users/validate-token env token) 66 | response-value (if token-ok 67 | (let [db (:db env) 68 | domain (:domain @db) 69 | product-groups (:product-groups domain)] 70 | {:ret :ok, :product-groups product-groups}) 71 | {:ret :failed, :msg "Given token is not valid"})] 72 | (make-response response-value))) 73 | 74 | (defn products 75 | "Gets products." 76 | [env token pg-id] 77 | (log/debug "ENTER products") 78 | (let [token-ok (b-users/validate-token env token) 79 | response-value (if token-ok 80 | (let [db (:db env) 81 | domain (:domain @db) 82 | all-products (:products domain) 83 | pg-products (b-domain/get-products pg-id all-products)] 84 | {:ret :ok, :pgId pg-id :products pg-products}) 85 | {:ret :failed, :msg "Given token is not valid"})] 86 | (make-response response-value))) 87 | 88 | 89 | (defn product 90 | "Gets product." 91 | [env token pg-id p-id] 92 | (log/debug "ENTER product") 93 | (let [token-ok (b-users/validate-token env token) 94 | response-value (if token-ok 95 | (let [db (:db env) 96 | domain (:domain @db) 97 | all-products (:products domain) 98 | my-product (b-domain/get-product pg-id p-id all-products)] 99 | {:ret :ok, :pgId pg-id :pId p-id :product my-product}) 100 | {:ret :failed, :msg "Given token is not valid"})] 101 | (make-response response-value))) 102 | 103 | ;; UI is in http://localhost:7171/index.html 104 | (defn routes 105 | "Routes." 106 | [env] 107 | ;; http://localhost:7171/swagger.json 108 | [["/swagger.json" 109 | {:get {:no-doc true 110 | :swagger {:info {:title "simpleserver api" 111 | :description "SimpleServer Api"} 112 | :tags [{:name "api", :description "api"}]} 113 | :handler (reitit-swagger/create-swagger-handler)}}] 114 | ;; http://localhost:7171/api-docs/index.html 115 | ["/api-docs/*" 116 | {:get {:no-doc true 117 | :handler (reitit-swagger-ui/create-swagger-ui-handler 118 | {:config {:validatorUrl nil} 119 | :url "/swagger.json"})}}] 120 | ["/api" 121 | {:swagger {:tags ["api"]}} 122 | ; For development purposes. Try curl http://localhost:7171/api/ping 123 | ["/ping" {:get {:summary "ping get" 124 | ; Don't allow any query parameters. 125 | :parameters {:query [:map]} 126 | :responses {200 {:description "Ping success"}} 127 | :handler (constantly (make-response {:ret :ok, :reply "pong" :env env}))} 128 | :post {:summary "ping post" 129 | :responses {200 {:description "Ping success"}} 130 | ;; reitit adds mt/strip-extra-keys-transformer - probably changes in reitit 1.0, 131 | ;; and therefore {:closed true} is not used with reitit < 1.0. 132 | :parameters {:body [:map {:closed true} [:ping string?]]} 133 | :handler (fn [req] 134 | (let [body (get-in req [:parameters :body]) 135 | myreq (:ping body)] 136 | (-> {:ret :ok :request myreq :reply "pong"} 137 | (make-response))))}}] 138 | ["/info" {:get {:summary "Get info regarding the api" 139 | :parameters {:query [:map]} 140 | :responses {200 {:description "Info success"}} 141 | :handler (fn [{}] (info env))}}] 142 | ["/login" {:post {:summary "Login to the web-store" 143 | :responses {200 {:description "Login success"}} 144 | :parameters {:body [:map 145 | [:username string?] 146 | [:password string?]]} 147 | :handler (fn [req] 148 | (let [body (get-in req [:parameters :body]) 149 | {:keys [username password]} body] 150 | (login env username password)))}}] 151 | ["/product-groups" {:get {:summary "Get products groups" 152 | :responses {200 {:description "Product groups success"}} 153 | :parameters {:query [:map]} 154 | :handler (fn [req] 155 | (let [token (get-in req [:headers "x-token"])] 156 | (if (not token) 157 | (make-response {:ret :failed, :msg "Token missing in request"}) 158 | (product-groups env token))))}}] 159 | ["/products/:pg-id" {:get {:summary "Get products" 160 | :responses {200 {:description "Products success"}} 161 | :parameters {:query [:map] 162 | :path [:map [:pg-id string?]]} 163 | :handler (fn [req] 164 | (let [token (get-in req [:headers "x-token"]) 165 | pg-id (Integer/parseInt (get-in req [:parameters :path :pg-id]))] 166 | (if (not token) 167 | (make-response {:ret :failed, :msg "Token missing in request"}) 168 | (products env token pg-id))))}}] 169 | ["/product/:pg-id/:p-id" {:get {:summary "Get product" 170 | :responses {200 {:description "Product success"}} 171 | :parameters {:query [:map] 172 | :path [:map [:pg-id string?] [:p-id string?]]} 173 | :handler (fn [req] 174 | (let [token (get-in req [:headers "x-token"]) 175 | pg-id (Integer/parseInt (get-in req [:parameters :path :pg-id])) 176 | p-id (Integer/parseInt (get-in req [:parameters :path :p-id]))] 177 | (if (not token) 178 | (make-response {:ret :failed, :msg "Token missing in request"}) 179 | (product env token pg-id p-id))))}}]]]) 180 | 181 | ;; NOTE: If you want to check what middleware does you can uncomment rows 67-69 in: 182 | ;; https://github.com/metosin/reitit/blob/master/examples/ring-swagger/src/example/server.clj#L67-L69 183 | 184 | (defn handler 185 | "Handler." 186 | [routes] 187 | (-> 188 | (reitit-ring/ring-handler 189 | (reitit-ring/router routes {; Use this to debug middleware handling: 190 | ;:reitit.middleware/transform reitit.ring.middleware.dev/print-request-diffs 191 | :data {:muuntaja mu-core/instance 192 | :coercion (reitit.coercion.malli/create 193 | {;; set of keys to include in error messages 194 | :error-keys #{:type :coercion :in #_:schema #_:value #_:errors :humanized #_:transformed} 195 | ;; validate request & response 196 | :validate true 197 | ;; top-level short-circuit to disable request & response coercion 198 | :enabled true 199 | ;; strip-extra-keys (effects only predefined transformers) 200 | :strip-extra-keys true 201 | ;; add/set default values 202 | :default-values true 203 | ;; malli options 204 | :options nil}) ;; malli 205 | :middleware [;; swagger feature 206 | reitit-swagger/swagger-feature 207 | ;; query-params & form-params 208 | reitit-parameters/parameters-middleware 209 | ;; content-negotiation 210 | reitit-muuntaja/format-negotiate-middleware 211 | ;; encoding response body 212 | reitit-muuntaja/format-response-middleware 213 | ;; exception handling 214 | (reitit-exception/create-exception-middleware 215 | (merge 216 | reitit-exception/default-handlers 217 | {::reitit-exception/wrap (fn [handler ^Exception e request] 218 | (log/error e (.getMessage e)) 219 | (handler e request))})) 220 | ;; decoding request body 221 | reitit-muuntaja/format-request-middleware 222 | ;; coercing response bodys 223 | reitit-coercion/coerce-response-middleware 224 | ;; coercing request parameters 225 | reitit-coercion/coerce-request-middleware]}}) 226 | (reitit-ring/routes 227 | (reitit-ring/redirect-trailing-slash-handler) 228 | (reitit-ring/create-file-handler {:path "/", :root "target/shadow/dev/resources/public"}) 229 | (reitit-ring/create-resource-handler {:path "/"}) 230 | (reitit-ring/create-default-handler))))) 231 | 232 | ; Rich comment. 233 | #_(comment 234 | 235 | (require '[clj-http.client]) 236 | 237 | (clj-http.client/get 238 | (str "http://localhost:7171/api/info") {:debug true :accept "application/json"}) 239 | 240 | (clj-http.client/get 241 | (str "http://localhost:7171/index.html") {:debug true}) 242 | 243 | (clj-http.client/get 244 | (str "http://localhost:7171") {:debug true}) 245 | 246 | (clj-http.client/get 247 | (str "http://localhost:7171/index.html") {:debug true}) 248 | 249 | (user/system) 250 | (handler {:routes (routes (user/env))}) 251 | 252 | (clj-http.client/get 253 | (str "https://reqres.in/api/users/2") {:debug true :accept "application/json"}) 254 | 255 | (clj-http.client/get 256 | (str "http://localhost:7171/info") {:debug true :accept "application/edn"})) 257 | 258 | 259 | -------------------------------------------------------------------------------- /src/cljs/frontend/header.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.header 2 | (:require [re-frame.core :as re-frame] 3 | [cljs.pprint] 4 | [frontend.state :as f-state])) 5 | 6 | 7 | (defn header [] 8 | (fn [] 9 | (let [login-status @(re-frame/subscribe [::f-state/login-status]) 10 | username @(re-frame/subscribe [::f-state/username])] 11 | [:div.flex.grow.bg-gray-200.p-4 12 | [:div.flex.flex-col.grow 13 | [:div.flex.justify-end 14 | (when (= login-status :logged-in) 15 | [:div.flex.justify-right.gap-2 16 | [:p username] 17 | ;; NOTE: CSS for a is defined in app.css 18 | [:a {:on-click #(re-frame/dispatch [::f-state/logout]) } "Logout"]])] 19 | [:div.flex.justify-center 20 | [:h1.text-3xl.text-center.font-bold "Demo Webtore"]]] 21 | ]))) 22 | 23 | 24 | ;:className "font-medium text-blue-600 dark:text-blue-500 hover:underline" 25 | -------------------------------------------------------------------------------- /src/cljs/frontend/http.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.http 2 | (:require [frontend.util :as f-util] 3 | [ajax.core :as ajax :refer []])) ; NOTE: Empty refer for clj-kondo 4 | 5 | (defn get-headers [db] 6 | (let [token (get-in db [:token]) 7 | ret (cond-> {:Accept "application/json" :Content-Type "application/json"} 8 | token (assoc :x-token token)) 9 | _ (f-util/clog "get-headers, ret" ret)] 10 | ret)) 11 | 12 | ;; See: https://github.com/day8/re-frame-http-fx 13 | (defn http [method db uri data on-success on-failure] 14 | (f-util/clog "http, uri" uri) 15 | (let [xhrio (cond-> {:debug true 16 | :method method 17 | :uri uri 18 | :headers (get-headers db) 19 | :format (ajax/json-request-format) 20 | :response-format (ajax/json-response-format {:keywords? true}) 21 | :on-success [on-success] 22 | :on-failure [on-failure]} 23 | data (assoc :params data))] 24 | {:http-xhrio xhrio 25 | :db db})) 26 | 27 | (def http-post (partial http :post)) 28 | (def http-get (partial http :get)) 29 | 30 | -------------------------------------------------------------------------------- /src/cljs/frontend/main.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.main 2 | (:require [re-frame.core :as re-frame] 3 | [re-frame.db] 4 | [reagent.dom :as r-dom] 5 | [day8.re-frame.http-fx] ; Needed to register :http-xhrio to re-frame. 6 | [reagent-dev-tools.core :as dev-tools] 7 | [reitit.coercion.spec :as rss] 8 | [reitit.frontend :as rf] 9 | [reitit.frontend.controllers :as rfc] 10 | [reitit.frontend.easy :as rfe] 11 | [frontend.util :as f-util] 12 | [frontend.header :as f-header] 13 | [frontend.routes.index :as f-index] 14 | [frontend.state :as f-state] 15 | [frontend.routes.login :as f-login] 16 | [frontend.routes.product-groups :as f-product-group] 17 | [frontend.routes.products :as f-products] 18 | [frontend.routes.product :as f-product] 19 | )) 20 | 21 | ;; ****************************************************************** 22 | ;; NOTE: When starting ClojureScript REPL, give first command: 23 | ; (shadow.cljs.devtools.api/repl :app) 24 | ; to connect the REPL to the app running in the browser. 25 | ;; ****************************************************************** 26 | 27 | ;;; Events ;;; 28 | 29 | 30 | (re-frame/reg-event-db 31 | ::initialize-db 32 | (fn [_ _] 33 | {:current-route nil 34 | :token nil 35 | :debug true 36 | :login-status nil 37 | :username nil 38 | :product-groups nil 39 | :products nil 40 | :product nil 41 | })) 42 | 43 | (re-frame/reg-event-fx 44 | ::f-state/navigate 45 | (fn [_ [_ & route]] 46 | ;; See `navigate` effect in routes.cljs 47 | {::navigate! route})) 48 | 49 | (re-frame/reg-event-db 50 | ::f-state/navigated 51 | (fn [db [_ new-match]] 52 | (let [old-match (:current-route db) 53 | new-path (:path new-match) 54 | controllers (rfc/apply-controllers (:controllers old-match) new-match)] 55 | (js/console.log (str "new-path: " new-path)) 56 | (cond-> (assoc db :current-route (assoc new-match :controllers controllers)) 57 | (= "/" new-path) (-> (assoc :login-status nil) 58 | (assoc :user nil)))))) 59 | 60 | (re-frame/reg-event-fx 61 | ::f-state/logout 62 | (fn [cofx [_]] 63 | (let [db (:db cofx)] 64 | {:db (-> db 65 | (assoc-in [:login] nil) 66 | (assoc-in [:username] nil) 67 | (assoc-in [:login-status] nil) 68 | (assoc-in [:token] nil)) 69 | :fx [[:dispatch [::f-state/navigate ::f-state/login]]]}))) 70 | 71 | #_(re-frame/reg-event-fx 72 | ::f-state/logout 73 | (fn [cofx [_]] 74 | {:db (assoc (:db cofx) :token nil) 75 | :fx [[:dispatch [::f-state/navigate ::f-state/home]]]})) 76 | 77 | ;;; Views ;;; 78 | 79 | 80 | (defn home-page [] 81 | (let [token @(re-frame/subscribe [::f-state/token])] 82 | ; If we have jwt in app db we are logged-in. 83 | (f-util/clog "ENTER home-page") 84 | [f-index/landing-page] 85 | #_(if jwt 86 | (re-frame/dispatch [::f-state/navigate ::f-state/product-group]) 87 | ;; NOTE: You need the div here or you are going to see only the debug-panel! 88 | [:div 89 | (welcome) 90 | (f-util/debug-panel {:token token})]))) 91 | 92 | 93 | ;;; Effects ;;; 94 | 95 | ;; Triggering navigation from events. 96 | (re-frame/reg-fx 97 | ::navigate! 98 | (fn [route] 99 | (apply rfe/push-state route))) 100 | 101 | ;;; Routes ;;; 102 | 103 | (defn href 104 | "Return relative url for given route. Url can be used in HTML links." 105 | ([k] 106 | (href k nil nil)) 107 | ([k params] 108 | (href k params nil)) 109 | ([k params query] 110 | (rfe/href k params query))) 111 | 112 | 113 | (def routes-dev 114 | ["/" 115 | ["" 116 | {:name ::f-state/home 117 | :view home-page 118 | :link-text "Home" 119 | :controllers 120 | [{:start (fn [& params] (js/console.log (str "Entering home page, params: " params))) 121 | :stop (fn [& params] (js/console.log (str "Leaving home page, params: " params)))}]}] 122 | ["login" 123 | {:name ::f-state/login 124 | :view f-login/login 125 | :link-text "Login" 126 | :controllers [{:start (fn [& params] (js/console.log (str "Entering login, params: " params))) 127 | :stop (fn [& params] (js/console.log (str "Leaving login, params: " params)))}]}] 128 | ["product-group" 129 | {:name ::f-state/product-groups 130 | :view f-product-group/product-groups 131 | :link-text "Product group" 132 | :controllers [{:start (fn [& params] (js/console.log (str "Entering product-group, params: " params))) 133 | :stop (fn [& params] (js/console.log (str "Leaving product-group, params: " params)))}]}] 134 | ["products/:pgid" 135 | {:name ::f-state/products 136 | :parameters {:path {:pgid int?}} 137 | :view f-products/products 138 | :link-text "Products" 139 | :controllers [{:start (fn [& params] (js/console.log (str "Entering products, params: " params))) 140 | :stop (fn [& params] (js/console.log (str "Leaving products, params: " params)))}]}] 141 | ["product/:pgid/:pid" 142 | {:name ::f-state/product 143 | :parameters {:path {:pgid int? 144 | :pid int?}} 145 | :view f-product/product 146 | :link-text "Product" 147 | :controllers [{:start (fn [& params] (js/console.log (str "Entering product, params: " params))) 148 | :stop (fn [& params] (js/console.log (str "Leaving product, params: " params)))}]}]]) 149 | 150 | (def routes routes-dev) 151 | 152 | (defn on-navigate [new-match] 153 | (f-util/clog "on-navigate, new-match" new-match) 154 | (when new-match 155 | (re-frame/dispatch [::f-state/navigated new-match]))) 156 | 157 | (def router 158 | (rf/router 159 | routes 160 | {:data {:coercion rss/coercion}})) 161 | 162 | (defn init-routes! [] 163 | (js/console.log "initializing routes") 164 | (rfe/start! 165 | router 166 | on-navigate 167 | {:use-fragment true})) 168 | 169 | (defn router-component [_] ; {:keys [router] :as params} 170 | (f-util/clog "ENTER router-component") 171 | (let [current-route @(re-frame/subscribe [::f-state/current-route]) 172 | path-params (:path-params current-route) 173 | _ (f-util/clog "router-component, path-params" path-params)] 174 | [:div 175 | [f-header/header] 176 | ; NOTE: when you supply the current-route to the view it can parse path-params there (from path) 177 | (when current-route 178 | [(-> current-route :data :view) current-route])])) 179 | 180 | ;;; Setup ;;; 181 | 182 | (def debug? ^boolean goog.DEBUG) 183 | 184 | (defn dev-setup [] 185 | (when debug? 186 | (enable-console-print!) 187 | (println "dev mode"))) 188 | 189 | 190 | (defn ^:dev/after-load start [] 191 | (js/console.log "ENTER start") 192 | (re-frame/clear-subscription-cache!) 193 | (init-routes!) 194 | (r-dom/render [router-component {:router router} 195 | ;; TODO. 196 | ;; (if (:open? @dev-tools/dev-state) 197 | ;; {:style {:padding-bottom (str (:height @dev-tools/dev-state) "px")}}) 198 | ] 199 | (.getElementById js/document "root"))) 200 | 201 | 202 | (defn ^:export init [] 203 | (js/console.log "ENTER init") 204 | (re-frame/dispatch-sync [::initialize-db]) 205 | (dev-tools/start! {:state-atom re-frame.db/app-db}) 206 | (dev-setup) 207 | (start)) 208 | 209 | (comment 210 | ; With Calva, no need for this: 211 | ;(shadow.cljs.devtools.api/repl :app) 212 | ; But you have to start the browser since the browser is the runtime for the repl! 213 | ; http://localhost:7171 214 | (+ 1 2) 215 | ;(reagent.dom/render []) 216 | ;(require '[hashp.core :include-macros true]) 217 | ;(let [a #p (range 5)] a) 218 | ) 219 | 220 | 221 | -------------------------------------------------------------------------------- /src/cljs/frontend/routes/index.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.routes.index 2 | (:require [re-frame.core :as re-frame] 3 | [frontend.state :as f-state] 4 | )) 5 | 6 | (defn landing-page [] 7 | [:div.app 8 | [:div 9 | [:div.p-4 10 | [:p.text-left.p-4 11 | "This is a demo webstore built for learning the following technologies:"] 12 | [:div.p-4 13 | [:p.text-left.font-bold "Backend:"] 14 | [:ul.list-disc.list-inside 15 | [:li "Clojure"] 16 | [:li "JVM"]]] 17 | [:div.p-4 18 | [:p.text-left.font-bold "Frontend:"] 19 | [:ul.list-disc.list-inside 20 | [:li "Clojurescript"] 21 | [:li "React with Reagent"] 22 | [:li "Re-frame"] 23 | [:li "Tailwind"]]] 24 | [:div.p-4.text-center 25 | [:button 26 | {:on-click (fn [e] 27 | (.preventDefault e) 28 | (re-frame/dispatch [::f-state/navigate ::f-state/product-groups]))} 29 | "Enter"]]]]]) -------------------------------------------------------------------------------- /src/cljs/frontend/routes/login.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.routes.login 2 | (:require [reagent.core :as r] 3 | [re-frame.core :as re-frame] 4 | [frontend.http :as f-http] 5 | [frontend.state :as f-state] 6 | [frontend.util :as f-util])) 7 | 8 | (defn empty-creds [] 9 | {:username "" :password ""}) 10 | 11 | 12 | (re-frame/reg-event-db 13 | ::login-ret-ok 14 | (fn [db [_ res-body]] 15 | (-> db 16 | (assoc-in [:login :response] {:ret :ok :msg (:msg res-body)}) 17 | (assoc-in [:token] (:token res-body)) 18 | (assoc-in [:login-status] :logged-in)))) 19 | 20 | 21 | (re-frame/reg-event-db 22 | ::login-ret-failed 23 | (fn [db [_ res-body]] 24 | (f-util/clog "reg-event-db failed" db) 25 | (assoc-in db [:login :response] {:ret :failed 26 | :msg (get-in res-body [:response :msg])}))) 27 | 28 | (re-frame/reg-event-db 29 | ::save-username 30 | (fn [db [_ username]] 31 | (-> db 32 | (assoc-in [:username] username)))) 33 | 34 | 35 | (re-frame/reg-sub 36 | ::login-response 37 | (fn [db] 38 | (f-util/clog "reg-sub" db) 39 | (:response (:login db)))) 40 | 41 | 42 | (re-frame/reg-event-fx 43 | ::login-user 44 | (fn [{:keys [db]} [_ user-data]] 45 | (f-util/clog "login-user, user-data" user-data) 46 | (f-http/http-post db "/api/login" user-data ::login-ret-ok ::login-ret-failed))) 47 | 48 | 49 | (defn login [] 50 | (let [login-data (r/atom (empty-creds))] 51 | (fn [] 52 | ; NOTE: The re-frame subscription needs to be inside the rendering function or the watch 53 | ; is not registered to the rendering function. 54 | (let [_ (f-util/clog "ENTER login") 55 | title "You need to login to use the web store" 56 | {:keys [ret _msg] :as _r-body} @(re-frame/subscribe [::login-response]) 57 | _ (when (= ret :ok) (re-frame/dispatch [::f-state/navigate ::f-state/product-groups])) 58 | #_#__ (f-util/clog "********************** ret" ret)] 59 | [:div.app 60 | [:div.p-4 61 | [:p.text-left.text-lg.font-bold.p-4 title] 62 | (when (= ret :failed) 63 | [:div {:className "flex grow w-3/4 p-4"} 64 | (re-frame/dispatch [::save-username nil]) 65 | [f-util/error-message "Login failed!" "Username or password is wrong."]])] 66 | [:div.flex.grow.justify-center.items-center 67 | [:div {:className "flex grow w-1/2 p-4"} 68 | [:form 69 | [:div.mt-3 70 | [f-util/input "Username" :username "text" login-data] 71 | [f-util/input "Password" :password "password" login-data] 72 | [:div.flex.flex-col.justify-center.items-center.mt-5 73 | [:button {:className "login-button" 74 | :on-click (fn [e] 75 | (.preventDefault e) 76 | (re-frame/dispatch [::login-user @login-data]) 77 | (re-frame/dispatch [::save-username (:username @login-data)]) 78 | )} 79 | "Login"]]]]]]])))) 80 | 81 | -------------------------------------------------------------------------------- /src/cljs/frontend/routes/product.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.routes.product 2 | (:require 3 | [re-frame.core :as re-frame] 4 | [day8.re-frame.http-fx] 5 | [frontend.http :as f-http] 6 | [frontend.state :as f-state] 7 | [frontend.util :as f-util])) 8 | 9 | 10 | (re-frame/reg-event-db 11 | ::ret-ok 12 | (fn [db [_ res-body]] 13 | (f-util/clog "reg-event-db ok: " res-body) 14 | (let [pgid (:pg-id res-body) 15 | pid (:p-id res-body)] 16 | (-> db 17 | (assoc-in [:product :response] {:ret :ok :res-body res-body}) 18 | (assoc-in [:product :data] (:product res-body)))))) 19 | 20 | 21 | (re-frame/reg-event-db 22 | ::ret-failed 23 | (fn [db [_ res-body]] 24 | (f-util/clog "reg-event-db failed" db) 25 | (assoc-in db [:product :response] {:ret :failed 26 | :msg (get-in res-body [:response :msg])}))) 27 | 28 | 29 | (re-frame/reg-sub 30 | ::product-data 31 | (fn [db params] 32 | (f-util/clog "::product-data, params" params) 33 | (let [data (get-in db [:product :data]) ] 34 | data))) 35 | 36 | 37 | (re-frame/reg-event-fx 38 | ::get-product 39 | (fn [{:keys [db]} [_ pg-id p-id]] 40 | (f-util/clog "get-product, pg-id, p-id" {:pg-id pg-id :p-id p-id}) 41 | (f-http/http-get db (str "/api/product/" pg-id "/" p-id) nil ::ret-ok ::ret-failed))) 42 | 43 | 44 | (defn product-table [data] 45 | [:table 46 | [:thead 47 | [:tr 48 | [:th "Field"] 49 | [:th "Value"]]] 50 | [:tbody 51 | (map (fn [item] 52 | (let [[field value] item] 53 | [:tr {:key field} 54 | [:td field] 55 | [:td value]])) 56 | data)]]) 57 | 58 | 59 | (defn product 60 | "Product view." 61 | [match] 62 | (let [_ (f-util/clog "ENTER product-page, match" match) 63 | {:keys [path]} (:parameters match) 64 | {:keys [pgid pid]} path 65 | pgid (str pgid) 66 | pid (str pid) 67 | _ (f-util/clog "path" path) 68 | _ (f-util/clog "pgid" pgid) 69 | _ (f-util/clog "pid" pid)] 70 | 71 | (fn [] 72 | (let [title "Product" 73 | login-status @(re-frame/subscribe [::f-state/login-status]) 74 | token @(re-frame/subscribe [::f-state/token]) 75 | _ (when-not (and login-status token) (re-frame/dispatch [::f-state/navigate ::f-state/login])) 76 | data @(re-frame/subscribe [::product-data pgid pid]) 77 | _ (when-not data (re-frame/dispatch [::get-product pgid pid]))] 78 | [:div.app 79 | [:div.p-4 80 | [:p.text-left.text-lg.font-bold.p-4 title] 81 | [:div.p-4 82 | [product-table data]]]])))) 83 | 84 | -------------------------------------------------------------------------------- /src/cljs/frontend/routes/product_groups.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.routes.product-groups 2 | (:require [re-frame.core :as re-frame] 3 | [reitit.frontend.easy :as rfe] 4 | ["@tanstack/react-table" :as rt] 5 | ["react" :as react :default useMemo] 6 | [frontend.state :as f-state] 7 | [frontend.http :as f-http] 8 | [frontend.util :as f-util] 9 | [reagent.core :as r])) 10 | 11 | (re-frame/reg-event-db 12 | ::ret-ok 13 | (fn [db [_ res-body]] 14 | (f-util/clog "reg-event-db ok: " res-body) 15 | (-> db 16 | (assoc-in [:product-groups :response] {:ret :ok :res-body res-body}) 17 | (assoc-in [:product-groups :data] (:product-groups res-body))))) 18 | 19 | (re-frame/reg-event-db 20 | ::ret-failed 21 | (fn [db [_ res-body]] 22 | (f-util/clog "reg-event-db failed" db) 23 | (assoc-in db [:product-groups :response] {:ret :failed 24 | :msg (get-in res-body [:response :msg])}))) 25 | 26 | (re-frame/reg-sub 27 | ::product-groups-data 28 | (fn [db] 29 | (get-in db [:product-groups :data]))) 30 | 31 | 32 | (re-frame/reg-event-fx 33 | ::get-product-groups 34 | (fn [{:keys [db]} [_]] 35 | (f-util/clog "get-product-groups") 36 | (f-http/http-get db "/api/product-groups" nil ::ret-ok ::ret-failed))) 37 | 38 | 39 | ;; Let's implement a simple basic html table first, 40 | ;; and later provide an example using @tanstack/react-table. 41 | (defn product-groups-simple-table 42 | [data] 43 | (let [_ (f-util/clog "ENTER product-groups-table")] 44 | [:div.p-4 45 | [:table 46 | [:thead 47 | [:tr 48 | [:th "Id"] 49 | [:th "Name"]]] 50 | [:tbody 51 | (map (fn [item] 52 | (let [{pg-id :pgId pg-name :name} item] 53 | [:tr {:key pg-id} 54 | [:td [:a {:href (rfe/href ::f-state/products {:pgid pg-id})} pg-id]] 55 | [:td pg-name]])) 56 | data)]]])) 57 | 58 | 59 | (defn mylink 60 | [pg-id] 61 | [:<> 62 | [:a {:href (rfe/href ::f-state/products {:pgid pg-id})} pg-id]]) 63 | 64 | ;; Example of Clojurescript / Javascript interop. 65 | ;; Compare to equivalent JSX implementation: 66 | ;; https://github.com/karimarttila/js-node-ts-react/blob/main/frontend/src/routes/product_groups.tsx#L36 67 | (defn product-groups-react-table 68 | [data] 69 | (let [_ (f-util/clog "ENTER product-groups-table") 70 | columnHelper (rt/createColumnHelper) 71 | columns #js [(.accessor columnHelper "pgId" #js {:header "Id" :cell (fn [info] (reagent.core/as-element [mylink (.getValue info)]))}) 72 | (.accessor columnHelper "name" #js {:header "Name" :cell (fn [info] (.getValue info))})] 73 | table (rt/useReactTable #js {:columns columns :data (clj->js data) :getCoreRowModel (rt/getCoreRowModel)}) 74 | ^js headerGroups (.getHeaderGroups table)] 75 | [:div.p-4 76 | [:table 77 | [:thead 78 | (for [^js headerGroup headerGroups] 79 | [:tr {:key (.-id headerGroup) } 80 | (for [^js header (.-headers headerGroup)] 81 | [:th {:key (.-id header) } 82 | (if (.-isPlaceholder header) 83 | nil 84 | (rt/flexRender (.. header -column -columnDef -header) (.getContext header)))])])] 85 | [:tbody 86 | (for [^js row (.-rows (.getRowModel table))] 87 | [:tr {:key (.-id row)} 88 | (for [^js cell (.getVisibleCells row)] 89 | [:td {:key (.-id cell)} 90 | (rt/flexRender (.. cell -column -columnDef -cell) (.getContext cell))])])]]])) 91 | 92 | 93 | (defn product-groups [] 94 | (let [_ (f-util/clog "ENTER product-groups")] 95 | (fn [] 96 | (let [title " Product Groups" 97 | login-status @(re-frame/subscribe [::f-state/login-status]) 98 | token @(re-frame/subscribe [::f-state/token]) 99 | _ (when-not (and login-status token) (re-frame/dispatch [::f-state/navigate ::f-state/login])) 100 | product-groups-data @(re-frame/subscribe [::product-groups-data]) 101 | _ (when-not product-groups-data (re-frame/dispatch [::get-product-groups])) 102 | ] 103 | [:div.app 104 | [:div.p-4 105 | [:p.text-left.text-lg.font-bold.p-4 title] 106 | [:div.p-4 107 | [product-groups-simple-table product-groups-data] 108 | ;[:f> product-groups-react-table product-groups-data] 109 | ]]])))) 110 | 111 | -------------------------------------------------------------------------------- /src/cljs/frontend/routes/products.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.routes.products 2 | (:require 3 | [re-frame.core :as re-frame] 4 | [reitit.frontend.easy :as rfe] 5 | [day8.re-frame.http-fx] 6 | [frontend.http :as f-http] 7 | [frontend.state :as f-state] 8 | [frontend.util :as f-util])) 9 | 10 | 11 | (re-frame/reg-event-db 12 | ::ret-ok 13 | (fn [db [_ res-body]] 14 | (f-util/clog "reg-event-db") 15 | (let [pg-id (:pgId res-body)] 16 | (-> db 17 | (assoc-in [:products :response] {:ret :ok :res-body res-body}) 18 | (assoc-in [:products :data pg-id] (:products res-body)))))) 19 | 20 | (re-frame/reg-event-db 21 | ::ret-failed 22 | (fn [db [_ res-body]] 23 | (f-util/clog "reg-event-db failed" db) 24 | (assoc-in db [:products :response] {:ret :failed 25 | :msg (get-in res-body [:response :msg])}))) 26 | 27 | (re-frame/reg-sub 28 | ::products-data 29 | (fn [db params] 30 | (f-util/clog "::products-data") 31 | (f-util/clog "params: " params) 32 | (let [myParseInt (fn [s] (js/parseInt s 10)) 33 | pgid (f-util/myParseInt (second params)) 34 | data (get-in db [:products :data])] 35 | (get-in data [pgid])))) 36 | 37 | 38 | 39 | (re-frame/reg-sub 40 | ::product-group-name 41 | (fn [db params] 42 | (f-util/clog "::product-group-name, params" params) 43 | (let [pg-id (f-util/myParseInt (second params))] 44 | (:name (first (filter (fn [item] 45 | (= pg-id (:pgId item))) 46 | (get-in db [:product-groups :data]))))))) 47 | 48 | 49 | ;; We could just get the specific product from re-frame :products key, 50 | ;; but let's fetch it using the backend API. 51 | (re-frame/reg-event-fx 52 | ::get-products 53 | (fn [{:keys [db]} [_ pg-id]] 54 | (f-util/clog "get-product, pg-id" pg-id) 55 | (f-http/http-get db (str "/api/products/" pg-id) nil ::ret-ok ::ret-failed))) 56 | 57 | 58 | (re-frame/reg-event-db 59 | ::reset-product 60 | (fn [db param] 61 | (f-util/clog "reg-event-db reset-product" param) 62 | (assoc-in db [:product] nil))) 63 | 64 | (defn products-table [data] 65 | [:table 66 | [:thead 67 | [:tr 68 | [:th "Id"] 69 | [:th "Name"]]] 70 | [:tbody 71 | (map (fn [item] 72 | (let [{pg-id :pgId p-id :pId title :title} item] 73 | [:tr {:key p-id} 74 | [:td [:a {:href (rfe/href ::f-state/product {:pgid pg-id :pid p-id})} p-id]] 75 | [:td title]])) 76 | data)]]) 77 | 78 | 79 | (defn products 80 | "Products view." 81 | [match] ; NOTE: This is the current-route given as parameter to the view. You can get the pgid also from :path-params. 82 | (let [_ (f-util/clog "ENTER products-page, match" match) 83 | {:keys [path]} (:parameters match) 84 | {:keys [pgid]} path 85 | pgid (str pgid) 86 | _ (f-util/clog "path" path) 87 | _ (f-util/clog "pgid" pgid)] 88 | (fn [] 89 | (let [;; Reset product so that we are forced to fetch the new product in the product page. 90 | _ (re-frame/dispatch [::reset-product]) 91 | login-status @(re-frame/subscribe [::f-state/login-status]) 92 | token @(re-frame/subscribe [::f-state/token]) 93 | _ (when-not (and login-status token) (re-frame/dispatch [::f-state/navigate ::f-state/login])) 94 | data @(re-frame/subscribe [::products-data pgid]) 95 | product-group-name @(re-frame/subscribe [::product-group-name pgid]) 96 | title (str "Products - " product-group-name) 97 | _ (when-not data (re-frame/dispatch [::get-products pgid]))] 98 | [:div.app 99 | [:div.p-4 100 | [:p.text-left.text-lg.font-bold.p-4 title] 101 | [:div.p-4 102 | [products-table data]]]])))) 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/cljs/frontend/state.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.state 2 | (:require [re-frame.core :as re-frame])) 3 | 4 | ;; Subscriptions 5 | 6 | (re-frame/reg-sub 7 | ::current-route 8 | (fn [db] 9 | (:current-route db))) 10 | 11 | (re-frame/reg-sub 12 | ::token 13 | (fn [db] 14 | (:token db))) 15 | 16 | (re-frame/reg-sub 17 | ::username 18 | (fn [db] 19 | (:username db))) 20 | 21 | (re-frame/reg-sub 22 | ::login-status 23 | (fn [db] 24 | (:login-status db))) 25 | 26 | (re-frame/reg-sub 27 | ::debug 28 | (fn [db] 29 | (:debug db))) 30 | -------------------------------------------------------------------------------- /src/cljs/frontend/util.cljs: -------------------------------------------------------------------------------- 1 | (ns frontend.util 2 | (:require [re-frame.core :as re-frame] 3 | [cljs.pprint] 4 | ;[clojure.pprint] 5 | [frontend.state :as sf-state])) 6 | 7 | 8 | ;; Application wide properties. 9 | (def backend-host-config {:host "localhost" :port 7171}) 10 | 11 | 12 | (defn valid? 13 | "Simple validator. Checks in [k v] v is a string and not empty." 14 | [[_ v]] 15 | (and (string? v) (seq v))) 16 | 17 | 18 | (defn save! 19 | "Fetches an event value and swaps the value of given atom a's key k to this value." 20 | [a k] 21 | #(swap! a assoc k (-> % .-target .-value))) 22 | 23 | 24 | (defn input 25 | "Input field component for e.g. First name, Last name, Email address and Password." 26 | [label k type state] 27 | [:div.flex.flex-wrap.gap-2.items-center.mt-1 28 | [:label {:htmlFor (name k) :className "login-label"} label] 29 | [:div 30 | [:input {:type "text" 31 | :id (name k) 32 | :name (name k) 33 | :className "login-input" 34 | :placeholder (name k) 35 | :value (k @state) 36 | :on-change (save! state k) 37 | :required true}]]]) 38 | 39 | 40 | (defn get-base-url 41 | [] 42 | (let [host (:host backend-host-config) 43 | port (:port backend-host-config) 44 | url (str "http://" host ":" port)] 45 | url)) 46 | 47 | 48 | (defn debug-panel 49 | "Debug panel - you can use this panel in any view to show some page specific debug data." 50 | [data] 51 | (let [debug @(re-frame/subscribe [::sf-state/debug])] 52 | #_(js/console.log (str "ENTER debug-panel, debug: " debug)) 53 | (if debug 54 | [:div.sf-debug-panel 55 | [:hr.sf-debug-panel.hr] 56 | [:h3.sf-debug-panel.header "DEBUG-PANEL"] 57 | [:pre.sf-debug-panel.body (with-out-str (cljs.pprint/pprint data))]]))) 58 | 59 | 60 | (defn clog 61 | "Javascript console logger helper." 62 | ([msg] (clog msg nil)) 63 | ([msg data] 64 | (let [buf (if data 65 | (str msg ": " data) 66 | msg)] 67 | (js/console.log buf)))) 68 | 69 | 70 | (defn error-message 71 | [title msg] 72 | [:<> 73 | [:div.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.relative 74 | {:role "alert"} 75 | [:strong.font-bold.mr-2 title] 76 | [:span.block.sm:inline msg]]]) 77 | 78 | 79 | (defn myParseInt 80 | [s] 81 | (js/parseInt s 10)) -------------------------------------------------------------------------------- /src/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | font-family: sans-serif; 7 | font-size: 14px; 8 | @apply h-screen bg-gradient-to-b from-gray-100 to-gray-300 9 | } 10 | 11 | table { 12 | border: 1px solid lightgray; 13 | } 14 | 15 | tbody { 16 | border-bottom: 1px solid lightgray; 17 | } 18 | 19 | th { 20 | border-bottom: 1px solid lightgray; 21 | border-right: 1px solid lightgray; 22 | padding: 2px 4px; 23 | } 24 | 25 | 26 | a { 27 | @apply underline text-blue-600 hover:text-blue-800 visited:text-purple-600 cursor-pointer 28 | } 29 | 30 | button { 31 | @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded 32 | } 33 | 34 | label { 35 | @apply font-medium text-gray-700; 36 | } 37 | 38 | .login-input { 39 | @apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500; 40 | } 41 | 42 | .login-label { 43 | @apply block mb-2 text-sm font-medium text-gray-900 dark:text-white; 44 | } 45 | 46 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./src/**/*.cljs" 4 | ], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } 10 | -------------------------------------------------------------------------------- /test/clj/backend/db/domain_test.clj: -------------------------------------------------------------------------------- 1 | (ns backend.db.domain-test 2 | (:require [clojure.test :refer [deftest use-fixtures is testing]] 3 | [clojure.tools.logging :as log] 4 | [backend.db.domain :as b-domain] 5 | [backend.test-config :as test-config])) 6 | 7 | (defn init-fixture [] 8 | ;; Do nothing. 9 | ) 10 | 11 | 12 | (defn domain-test-fixture 13 | [f] 14 | (log/debug "ENTER domain-test-fixture") 15 | (test-config/test-system-fixture-runner init-fixture f) 16 | (log/debug "EXIT domain-test-fixture")) 17 | 18 | 19 | (use-fixtures :each domain-test-fixture) 20 | 21 | 22 | ;; Experimental testing. 23 | ;; (def my-atom (atom nil)) 24 | 25 | 26 | (deftest get-domain-data-test 27 | (log/debug "ENTER get-domain-data-test") 28 | (testing "Testing get domain data" 29 | (let [domain-data (b-domain/get-domain-data (-> (test-config/test-config) :backend/env :data-dir)) 30 | ;;_ (reset! my-atom domain-data) 31 | ] 32 | (testing "Testing product groups" 33 | (let [product-groups (:product-groups domain-data) 34 | product-groups-len (count product-groups) 35 | right-vec [{:pgId 1, :name "Books"} {:pgId 2, :name "Movies"}]] 36 | (is (= 2 product-groups-len)) 37 | (is (= right-vec product-groups)))) 38 | (testing "Testing products" 39 | (let [products (:products domain-data) 40 | products-len (count products)] 41 | (is (= 204 products-len)))) 42 | (testing "Testing specific product" 43 | (let [products (:products domain-data) 44 | product (b-domain/get-product 2 49 products) 45 | right-product {:pId 49, 46 | :pgId 2, 47 | :title "Once Upon a Time in the West", 48 | :price 14.4, 49 | :director "Leone, Sergio", 50 | :year 1968, 51 | :country "Italy-USA", 52 | :genre "Western"}] 53 | (is (= right-product product)))))) 54 | (testing "Using the domain data in the internal test system" 55 | (let [domain-data (:domain @(-> @test-config/test-system :backend/env :db))] 56 | (testing "Testing product groups" 57 | (let [product-groups (:product-groups domain-data) 58 | product-groups-len (count product-groups) 59 | right-vec [{:pgId 1, :name "Books"} {:pgId 2, :name "Movies"}]] 60 | (is (= 2 product-groups-len)) 61 | (is (= right-vec product-groups)))) 62 | (testing "Testing products" 63 | (let [products (:products domain-data) 64 | products-len (count products)] 65 | (is (= 204 products-len)))) 66 | (testing "Testing products of a specific product group" 67 | (let [products (:products domain-data) 68 | products-1 (b-domain/get-products 1 products) 69 | products-2 (b-domain/get-products 2 products)] 70 | (is (= 35 (count products-1))) 71 | (is (= 169 (count products-2))) 72 | (is (= 204 (+ (count products-1) (count products-2)))))) 73 | (testing "Testing specific product" 74 | (let [products (:products domain-data) 75 | product (b-domain/get-product 2 49 products) 76 | right-product {:pId 49, 77 | :pgId 2, 78 | :title "Once Upon a Time in the West", 79 | :price 14.4, 80 | :director "Leone, Sergio", 81 | :year 1968, 82 | :country "Italy-USA", 83 | :genre "Western"}] 84 | (is (= right-product product)))))) 85 | 86 | ) 87 | 88 | 89 | (comment 90 | (test-config/test-config) 91 | (-> (test-config/test-config) :backend/env :data-dir) 92 | ;; @my-atom 93 | ;; (keys @my-atom) 94 | ;; (:product-groups @my-atom) 95 | ;; (:products @my-atom) 96 | ;; (count (:products @my-atom)) 97 | 98 | ) -------------------------------------------------------------------------------- /test/clj/backend/db/users_test.clj: -------------------------------------------------------------------------------- 1 | (ns backend.db.users-test 2 | (:require [clojure.test :refer [deftest use-fixtures is testing]] 3 | [clojure.tools.logging :as log] 4 | [backend.db.users :as b-users] 5 | [backend.test-config :as test-config])) 6 | 7 | (defn init-fixture [] 8 | ;; Do nothing. 9 | ) 10 | 11 | 12 | (defn domain-test-fixture 13 | [f] 14 | (log/debug "ENTER domain-test-fixture") 15 | (test-config/test-system-fixture-runner init-fixture f) 16 | (log/debug "EXIT domain-test-fixture")) 17 | 18 | 19 | (use-fixtures :each domain-test-fixture) 20 | 21 | 22 | ;; Experimental testing. 23 | ;; (def my-atom (atom nil)) 24 | 25 | 26 | (deftest get-users-test 27 | (log/debug "ENTER get-users-test") 28 | (testing "Testing users" 29 | (let [env (:backend/env @test-config/test-system)] 30 | (b-users/clear-sessions env) 31 | (is (= (count (b-users/get-sessions env)) 0)) 32 | (let [token-jartsa (b-users/validate-user env "jartsa" "joo")] 33 | (is (not (nil? token-jartsa))) 34 | (let [decoded-token (b-users/validate-token env token-jartsa)] 35 | (is (not (nil? token-jartsa))) 36 | (is (= "jartsa" (:username decoded-token))) 37 | (let [sessions (b-users/get-sessions env) 38 | session (first sessions)] 39 | (is (= 1 (count sessions))) 40 | (is (= "jartsa" (:username session))) 41 | (is (= "joo" (:password session))) 42 | (is (= token-jartsa (:token session))))) 43 | (let [_token-rane (b-users/validate-user env "rane" "jee")] 44 | (let [sessions (b-users/get-sessions env)] 45 | (is (= 2 (count sessions))))) 46 | ;; Create a new session for jartsa: the old one should be removed 47 | (let [token-jartsa2 (b-users/validate-user env "jartsa" "joo")] 48 | (is (not (nil? token-jartsa2))) 49 | (let [sessions (b-users/get-sessions env)] 50 | (is (= 2 (count sessions))))))))) 51 | 52 | (deftest user-validation-fails-test 53 | (log/debug "ENTER user-validation-fails-test") 54 | (testing "User validation fails" 55 | (let [env (:backend/env @test-config/test-system)] 56 | (b-users/clear-sessions env) 57 | (is (= 0 (count (b-users/get-sessions env)))) 58 | (let [token-jartsa (b-users/validate-user env "jartsa" "WRONG")] 59 | (is (nil? token-jartsa)) 60 | (let [sessions (b-users/get-sessions env)] 61 | (is (= 0 (count sessions)))))))) -------------------------------------------------------------------------------- /test/clj/backend/test_config.clj: -------------------------------------------------------------------------------- 1 | (ns backend.test-config 2 | (:require 3 | [clojure.tools.logging :as log] 4 | [clojure.pprint] 5 | [integrant.core :as ig] 6 | [clj-http.client :as http-client] 7 | [backend.core :as core]) 8 | (:import (java.net ServerSocket))) 9 | 10 | (defonce test-system (atom nil)) 11 | 12 | (defn random-port [] 13 | (with-open [s (ServerSocket. 0)] 14 | (.getLocalPort s))) 15 | 16 | (defn test-config [] 17 | (let [test-port (random-port) 18 | ; Overriding the port with random port, see TODO below. 19 | _ (log/debug (str "test-config, using web-server test port: " test-port))] 20 | (-> (core/system-config :test) 21 | ;; Use the same data dir also for test system. It just initializes data. 22 | (assoc-in [:backend/env :data-dir] "resources/data") 23 | (assoc-in [:backend/jetty :port] test-port) 24 | (assoc-in [:backend/nrepl :bind] nil) 25 | (assoc-in [:backend/nrepl :port] nil) 26 | (assoc-in [:backend/env :port] nil)))) 27 | 28 | 29 | ;; (defmethod ig/init-key :backend/env [_ {:keys [_profile data-dir]}] 30 | ;; (log/debug "ENTER ig/init-key :backend/env") 31 | ;; (atom {:domain (b-domain/get-domain-data data-dir) 32 | ;; :sessions #{} 33 | ;; :users (create-users)})) 34 | 35 | 36 | ;; TODO: For some reason Integrant does not always run halt-key for webserver, really weird. 37 | ;; And you lose the reference to the web server and web server keeps the port binded => you have to restart REPL. 38 | #_(defn test-config-orig [] 39 | (let [test-port (get-in (ss-config/create-config) [:web-server :test-server-port])] 40 | (-> (core/system-config) 41 | ; Overriding the port with test-port. 42 | (assoc-in [::core/web-server :port] test-port)))) 43 | 44 | (defn halt [] 45 | (swap! test-system #(if % (ig/halt! %)))) 46 | 47 | (defn go [] 48 | (halt) 49 | (reset! test-system (ig/init (test-config)))) 50 | 51 | 52 | (defn test-system-fixture-runner [init-test-data, testfunc] 53 | (try 54 | (go) 55 | (init-test-data) 56 | (testfunc) 57 | (finally 58 | (halt)))) 59 | 60 | (defn call-api [verb path headers body] 61 | (let [my-port (-> @test-system :backend/jetty .getConnectors first .getPort) 62 | my-fn (cond 63 | (= verb :get) http-client/get 64 | (= verb :post) http-client/post)] 65 | (try 66 | (select-keys 67 | (my-fn (str "http://localhost:" my-port "/api/" path) 68 | {:as :json 69 | :form-params body 70 | :headers headers 71 | :content-type :json 72 | :throw-exceptions false 73 | :coerce :always}) [:status :body]) 74 | (catch Exception e 75 | (log/error (str "ERROR in -call-api: " (.getMessage e))))))) 76 | 77 | 78 | ; Rich comment. 79 | #_(comment 80 | (user/env) 81 | (test-config) 82 | *ns* 83 | (backend.test-config/-call-api :get "info" nil nil) 84 | 85 | (backend.test-config/go) 86 | (backend.test-config/halt) 87 | (backend.test-config/test-config) 88 | backend.test-config/test-system 89 | ;(backend.test-config/test-env) 90 | (backend.test-config/test-service) 91 | 92 | (backend.test-config/go) 93 | 94 | 95 | (:domain @(-> @test-system :backend/env :db)) 96 | 97 | (backend.test-config/halt) 98 | (->> (:out (clojure.java.shell/sh "netstat" "-an")) (clojure.string/split-lines) (filter #(re-find #".*:::61.*LISTEN.*" %))) 99 | ) 100 | -------------------------------------------------------------------------------- /test/clj/backend/webserver_test.clj: -------------------------------------------------------------------------------- 1 | (ns backend.webserver-test 2 | (:require [clojure.test :refer [deftest use-fixtures is testing]] 3 | [clojure.tools.logging :as log] 4 | [clojure.data.codec.base64 :as base64] 5 | [backend.db.users :as b-users] 6 | [backend.test-config :as test-config])) 7 | 8 | 9 | (defn init-fixture 10 | [] 11 | (log/debug "ENTER init-fixture") 12 | (b-users/clear-sessions (:backend/env @test-config/test-system))) 13 | 14 | (defn webserver-test-fixture 15 | [f] 16 | (log/debug "ENTER webserver-test-fixture") 17 | (test-config/test-system-fixture-runner init-fixture f) 18 | (log/debug "EXIT webserver-test-fixture")) 19 | 20 | (use-fixtures :each webserver-test-fixture) 21 | 22 | (deftest info-test 23 | (log/debug "ENTER info-test") 24 | (testing "GET: /api/info" 25 | (let [ret (test-config/call-api :get "info" nil nil)] 26 | (is (= 200 (ret :status) 200)) 27 | (is (= {:info "/info.html => Info in HTML format"} (ret :body)))))) 28 | 29 | (deftest ping-get-test 30 | (log/debug "ENTER ping-get-test") 31 | (testing "GET: /api/ping" 32 | (let [ret (test-config/call-api :get "ping" nil nil)] 33 | (is (= 200 (ret :status))) 34 | (let [body (ret :body)] 35 | (is (= "pong" (:reply body))) 36 | (is (= "ok" (:ret body))))))) 37 | 38 | (deftest failed-ping-get-extra-query-params-test 39 | (log/debug "ENTER failed-ping-get-extra-query-params-test") 40 | (testing "GET: /api/ping" 41 | (let [ret (test-config/call-api :get "ping?a=1" nil nil)] 42 | (is (= 400 (ret :status) 400)) 43 | (is (= {:coercion "malli" 44 | :humanized {:a ["disallowed key"]} 45 | :in ["request" 46 | "query-params"] 47 | :type "reitit.coercion/request-coercion"} 48 | (ret :body)))))) 49 | 50 | (deftest ping-post-test 51 | (log/debug "ENTER ping-post-test") 52 | (testing "POST: /api/ping" 53 | (let [ret (test-config/call-api :post "ping" nil {:ping "hello"})] 54 | (is (= 200 (ret :status))) 55 | (is (= {:reply "pong" :request "hello" :ret "ok"} (ret :body) ))))) 56 | 57 | (deftest failed-ping-post-missing-key-test 58 | (log/debug "ENTER failed-ping-post-missing-key-test") 59 | (testing "POST: /api/ping" 60 | (let [ret (test-config/call-api :post "ping" nil {:wrong-key "hello"})] 61 | (is (= 400 (ret :status) 400)) 62 | (is (= {:coercion "malli" 63 | :humanized {:ping ["missing required key"]} 64 | :in ["request" 65 | "body-params"] 66 | :type "reitit.coercion/request-coercion"} 67 | (ret :body)))))) 68 | 69 | (deftest login-test 70 | (log/debug "ENTER login-test") 71 | (testing "POST: /api/login" 72 | ; First with good credentials. 73 | (let [good-test-body {:username "jartsa" :password "joo"} 74 | bad-test-body {:username "jartsa" :password "WRONG-PASSWORD"}] 75 | (let [ret (test-config/call-api :post "login" nil good-test-body)] 76 | (is (= 200 (ret :status))) 77 | (is (= "ok" (get-in ret [:body :ret]) "ok")) 78 | (is (= java.lang.String (type (get-in ret [:body :token])))) 79 | (is (< 30 (count (get-in ret [:body :token]))))) 80 | ; Next with bad credentials. 81 | (let [ret (test-config/call-api :post "login" nil bad-test-body)] 82 | (is (= 400 (ret :status))) 83 | (is (= "failed" (get-in ret [:body :ret]))) 84 | (is (= "Login failed" (get-in ret [:body :msg]))))))) 85 | 86 | (deftest product-groups-test 87 | (log/debug "ENTER product-groups-test") 88 | (testing "GET: /api/product-groups" 89 | (let [creds {:username "jartsa" :password "joo"} 90 | login-ret (test-config/call-api :post "login" nil creds) 91 | _ (log/debug (str "Got login-ret: " login-ret)) 92 | token (get-in login-ret [:body :token]) 93 | params {:x-token token} 94 | get-ret (test-config/call-api :get "/product-groups" params nil) 95 | status (:status get-ret) 96 | body (:body get-ret) 97 | right-body {:ret "ok", :product-groups [{:pgId 1, :name "Books"} {:pgId 2, :name "Movies"}]}] 98 | (is (= true (not (nil? token)))) 99 | (is (= 200 status)) 100 | (is (= right-body body))))) 101 | 102 | 103 | (deftest products-test 104 | (log/debug "ENTER products-test") 105 | (testing "GET: /api/products" 106 | (let [creds {:username "jartsa" :password "joo"} 107 | login-ret (test-config/call-api :post "login" nil creds) 108 | _ (log/debug (str "Got login-ret: " login-ret)) 109 | token (get-in login-ret [:body :token]) 110 | params {:x-token token} 111 | get-ret (test-config/call-api :get "/products/1" params nil) 112 | status (:status get-ret) 113 | body (:body get-ret)] 114 | (is (= (not (nil? token)) true)) 115 | (is (= 200 status)) 116 | (is (= "ok" (:ret body))) 117 | (is (= 1 (:pgId body))) 118 | (is (= 35 (count (:products body))))))) 119 | 120 | 121 | (deftest product-test 122 | (log/debug "ENTER product-test") 123 | (testing "GET: /api/product" 124 | (let [creds {:username "jartsa" :password "joo"} 125 | login-ret (test-config/call-api :post "login" nil creds) 126 | _ (log/debug (str "Got login-ret: " login-ret)) 127 | token (get-in login-ret [:body :token]) 128 | params {:x-token token} 129 | get-ret (test-config/call-api :get "/product/2/49" params nil) 130 | status (:status get-ret) 131 | body (:body get-ret)] 132 | (is (= (not (nil? token)) true)) 133 | (is (= 200 status)) 134 | (is (= "ok" (:ret body))) 135 | (is (= 2 (:pgId body))) 136 | (is (= 49 (:pId body))) 137 | (is (= {:pId 49, 138 | :pgId 2, 139 | :title "Once Upon a Time in the West", 140 | :price 14.4, 141 | :director "Leone, Sergio", 142 | :year 1968, 143 | :country "Italy-USA", 144 | :genre "Western"} 145 | (:product body)))))) 146 | 147 | 148 | --------------------------------------------------------------------------------