diff --git a/Capstanfile b/Capstanfile
new file mode 100644
index 0000000..10a3e42
--- /dev/null
+++ b/Capstanfile
@@ -0,0 +1,28 @@
+
+#
+# Name of the base image. Capstan will download this automatically from
+# Cloudius S3 repository.
+#
+#base: cloudius/osv
+base: cloudius/osv-openjdk8
+
+#
+# The command line passed to OSv to start up the application.
+#
+cmdline: /java.so -jar /pastoralist/app.jar
+
+#
+# The command to use to build the application.
+# You can use any build tool/command (make/rake/lein/boot) - this runs locally on your machine
+#
+# For Leiningen, you can use:
+#build: lein uberjar
+# For Boot, you can use:
+#build: boot build
+
+#
+# List of files that are included in the generated image.
+#
+files:
+ /pastoralist/app.jar: ./target/uberjar/pastoralist.jar
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..894ffb6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM openjdk:8-alpine
+
+COPY target/uberjar/pastoralist.jar /pastoralist/app.jar
+
+EXPOSE 3000
+
+CMD ["java", "-jar", "/pastoralist/app.jar"]
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..74a9459
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: java -cp target/uberjar/pastoralist.jar clojure.main -m pastoralist.core
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..938eb42
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+# pastoralist
+
+generated using Luminus version "3.54"
+
+FIXME
+
+## Prerequisites
+
+You will need [Leiningen][1] 2.0 or above installed.
+
+[1]: https://github.com/technomancy/leiningen
+
+## Running
+
+To start a web server for the application, run:
+
+ lein run
+
+## License
+
+Copyright © 2019 FIXME
diff --git a/dev-config.edn b/dev-config.edn
new file mode 100644
index 0000000..cad6e90
--- /dev/null
+++ b/dev-config.edn
@@ -0,0 +1,16 @@
+;; WARNING
+;; The dev-config.edn file is used for local environment variables, such as database credentials.
+;; This file is listed in .gitignore and will be excluded from version control by Git.
+
+{:dev true
+ :port 3000
+ ;; when :nrepl-port is set the application starts the nREPL server on load
+ :nrepl-port 7000
+
+ ;;Twitter used as an example, replace these URIs with the OAuth provider of your choice
+ :request-token-uri "https://api.twitter.com/oauth/request_token"
+ :access-token-uri "https://api.twitter.com/oauth/access_token"
+ :authorize-uri "https://api.twitter.com/oauth/authenticate"
+ ; set your dev database connection URL here
+ ; :database-url "postgresql://localhost/pastoralist_dev?user=simon&password=db_user_password_here"
+}
diff --git a/env/dev/clj/pastoralist/core.clj b/env/dev/clj/pastoralist/core.clj
new file mode 100644
index 0000000..bf4a41d
--- /dev/null
+++ b/env/dev/clj/pastoralist/core.clj
@@ -0,0 +1,77 @@
+(ns pastoralist.core
+ (:require
+ [pastoralist.handler :as handler]
+ [pastoralist.nrepl :as nrepl]
+ [luminus.http-server :as http]
+ [luminus-migrations.core :as migrations]
+ [pastoralist.config :refer [env]]
+ [clojure.tools.cli :refer [parse-opts]]
+ [clojure.tools.logging :as log]
+ [mount.core :as mount])
+ (:gen-class))
+
+;; log uncaught exceptions in threads
+(Thread/setDefaultUncaughtExceptionHandler
+ (reify Thread$UncaughtExceptionHandler
+ (uncaughtException [_ thread ex]
+ (log/error {:what :uncaught-exception
+ :exception ex
+ :where (str "Uncaught exception on" (.getName thread))}))))
+
+(def cli-options
+ [["-p" "--port PORT" "Port number"
+ :parse-fn #(Integer/parseInt %)]])
+
+(mount/defstate ^{:on-reload :noop} http-server
+ :start
+ (http/start
+ (-> env
+ (assoc :handler (handler/app))
+ (update :io-threads #(or % (* 2 (.availableProcessors (Runtime/getRuntime)))))
+ (update :port #(or (-> env :options :port) %))))
+ :stop
+ (http/stop http-server))
+
+(mount/defstate ^{:on-reload :noop} repl-server
+ :start
+ (when (env :nrepl-port)
+ (nrepl/start {:bind (env :nrepl-bind)
+ :port (env :nrepl-port)}))
+ :stop
+ (when repl-server
+ (nrepl/stop repl-server)))
+
+
+(defn init-jndi []
+ (System/setProperty "java.naming.factory.initial"
+ "org.apache.naming.java.javaURLContextFactory")
+ (System/setProperty "java.naming.factory.url.pkgs"
+ "org.apache.naming"))
+
+(defn start-app [args]
+ (init-jndi)
+ (doseq [component (-> args
+ (parse-opts cli-options)
+ mount/start-with-args
+ :started)]
+ (log/info component "started"))
+ (.addShutdownHook (Runtime/getRuntime) (Thread. handler/destroy)))
+
+(defn -main [& args]
+ (mount/start #'pastoralist.config/env)
+ (cond
+ (nil? (:database-url env))
+ (do
+ (log/error "Database configuration not found, :database-url environment variable must be set before running")
+ (System/exit 1))
+ (some #{"init"} args)
+ (do
+ (migrations/init (select-keys env [:database-url :init-script]))
+ (System/exit 0))
+ (migrations/migration? args)
+ (do
+ (migrations/migrate args (select-keys env [:database-url]))
+ (System/exit 0))
+ :else
+ (start-app args)))
+
diff --git a/env/dev/clj/pastoralist/dev_middleware.clj b/env/dev/clj/pastoralist/dev_middleware.clj
new file mode 100644
index 0000000..159d491
--- /dev/null
+++ b/env/dev/clj/pastoralist/dev_middleware.clj
@@ -0,0 +1,11 @@
+(ns pastoralist.dev-middleware
+ (:require
+ [ring.middleware.reload :refer [wrap-reload]]
+ [selmer.middleware :refer [wrap-error-page]]
+ [prone.middleware :refer [wrap-exceptions]]))
+
+(defn wrap-dev [handler]
+ (-> handler
+ wrap-reload
+ wrap-error-page
+ (wrap-exceptions {:app-namespaces ['pastoralist]})))
diff --git a/env/dev/clj/pastoralist/env.clj b/env/dev/clj/pastoralist/env.clj
new file mode 100644
index 0000000..201830c
--- /dev/null
+++ b/env/dev/clj/pastoralist/env.clj
@@ -0,0 +1,15 @@
+(ns pastoralist.env
+ (:require
+ [selmer.parser :as parser]
+ [clojure.tools.logging :as log]
+ [pastoralist.dev-middleware :refer [wrap-dev]]))
+
+(def defaults
+ {:init
+ (fn []
+ (parser/cache-off!)
+ (log/info "\n-=[pastoralist started successfully using the development profile]=-"))
+ :stop
+ (fn []
+ (log/info "\n-=[pastoralist has shut down successfully]=-"))
+ :middleware wrap-dev})
diff --git a/env/dev/clj/pastoralist/figwheel.clj b/env/dev/clj/pastoralist/figwheel.clj
new file mode 100644
index 0000000..323f035
--- /dev/null
+++ b/env/dev/clj/pastoralist/figwheel.clj
@@ -0,0 +1,12 @@
+(ns pastoralist.figwheel
+ (:require [figwheel-sidecar.repl-api :as ra]))
+
+(defn start-fw []
+ (ra/start-figwheel!))
+
+(defn stop-fw []
+ (ra/stop-figwheel!))
+
+(defn cljs []
+ (ra/cljs-repl))
+
diff --git a/env/dev/clj/user.clj b/env/dev/clj/user.clj
new file mode 100644
index 0000000..d81faba
--- /dev/null
+++ b/env/dev/clj/user.clj
@@ -0,0 +1,64 @@
+(ns user
+ "Userspace functions you can run by default in your local REPL."
+ (:require
+ [pastoralist.config :refer [env]]
+ [clojure.pprint]
+ [clojure.spec.alpha :as s]
+ [expound.alpha :as expound]
+ [mount.core :as mount]
+ [pastoralist.figwheel :refer [start-fw stop-fw cljs]]
+ [pastoralist.core :refer [start-app]]
+ [pastoralist.db.core]
+ [conman.core :as conman]
+ [luminus-migrations.core :as migrations]))
+
+(alter-var-root #'s/*explain-out* (constantly expound/printer))
+
+(add-tap (bound-fn* clojure.pprint/pprint))
+
+(defn start
+ "Starts application.
+ You'll usually want to run this on startup."
+ []
+ (mount/start-without #'pastoralist.core/repl-server))
+
+(defn stop
+ "Stops application."
+ []
+ (mount/stop-except #'pastoralist.core/repl-server))
+
+(defn restart
+ "Restarts application."
+ []
+ (stop)
+ (start))
+
+(defn restart-db
+ "Restarts database."
+ []
+ (mount/stop #'pastoralist.db.core/*db*)
+ (mount/start #'pastoralist.db.core/*db*)
+ (binding [*ns* 'pastoralist.db.core]
+ (conman/bind-connection pastoralist.db.core/*db* "sql/queries.sql")))
+
+(defn reset-db
+ "Resets database."
+ []
+ (migrations/migrate ["reset"] (select-keys env [:database-url])))
+
+(defn migrate
+ "Migrates database up for all outstanding migrations."
+ []
+ (migrations/migrate ["migrate"] (select-keys env [:database-url])))
+
+(defn rollback
+ "Rollback latest database migration."
+ []
+ (migrations/migrate ["rollback"] (select-keys env [:database-url])))
+
+(defn create-migration
+ "Create a new up and down migration file with a generated timestamp and `name`."
+ [name]
+ (migrations/create name (select-keys env [:database-url])))
+
+
diff --git a/env/dev/cljs/pastoralist/app.cljs b/env/dev/cljs/pastoralist/app.cljs
new file mode 100644
index 0000000..4e997a6
--- /dev/null
+++ b/env/dev/cljs/pastoralist/app.cljs
@@ -0,0 +1,19 @@
+(ns^:figwheel-no-load pastoralist.app
+ (:require
+ [pastoralist.core :as core]
+ [cljs.spec.alpha :as s]
+ [expound.alpha :as expound]
+ [devtools.core :as devtools]))
+
+(extend-protocol IPrintWithWriter
+ js/Symbol
+ (-pr-writer [sym writer _]
+ (-write writer (str "\"" (.toString sym) "\""))))
+
+(set! s/*explain-out* expound/printer)
+
+(enable-console-print!)
+
+(devtools/install!)
+
+(core/init! true)
diff --git a/env/dev/resources/config.edn b/env/dev/resources/config.edn
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/env/dev/resources/config.edn
@@ -0,0 +1 @@
+{}
diff --git a/env/dev/resources/logback.xml b/env/dev/resources/logback.xml
new file mode 100644
index 0000000..cedeaea
--- /dev/null
+++ b/env/dev/resources/logback.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ UTF-8
+ %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n
+
+
+
+ log/pastoralist.log
+
+ log/pastoralist.%d{yyyy-MM-dd}.%i.log
+
+ 100MB
+
+
+ 30
+
+
+ UTF-8
+ %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n
+
+
+
+
+
+
+
+
+
+
+
diff --git a/env/prod/clj/pastoralist/env.clj b/env/prod/clj/pastoralist/env.clj
new file mode 100644
index 0000000..6170c5a
--- /dev/null
+++ b/env/prod/clj/pastoralist/env.clj
@@ -0,0 +1,11 @@
+(ns pastoralist.env
+ (:require [clojure.tools.logging :as log]))
+
+(def defaults
+ {:init
+ (fn []
+ (log/info "\n-=[pastoralist started successfully]=-"))
+ :stop
+ (fn []
+ (log/info "\n-=[pastoralist has shut down successfully]=-"))
+ :middleware identity})
diff --git a/env/prod/cljs/pastoralist/app.cljs b/env/prod/cljs/pastoralist/app.cljs
new file mode 100644
index 0000000..ec7937e
--- /dev/null
+++ b/env/prod/cljs/pastoralist/app.cljs
@@ -0,0 +1,7 @@
+(ns pastoralist.app
+ (:require [pastoralist.core :as core]))
+
+;;ignore println statements in prod
+(set! *print-fn* (fn [& _]))
+
+(core/init! false)
diff --git a/env/prod/resources/config.edn b/env/prod/resources/config.edn
new file mode 100644
index 0000000..e24ec21
--- /dev/null
+++ b/env/prod/resources/config.edn
@@ -0,0 +1,2 @@
+{:prod true
+ :port 3000}
diff --git a/env/prod/resources/logback.xml b/env/prod/resources/logback.xml
new file mode 100644
index 0000000..e0b6b9a
--- /dev/null
+++ b/env/prod/resources/logback.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ log/pastoralist.log
+
+ log/pastoralist.%d{yyyy-MM-dd}.%i.log
+
+ 100MB
+
+
+ 30
+
+
+ UTF-8
+ %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n
+
+
+
+
+
+
+
+
+
+
diff --git a/env/test/resources/config.edn b/env/test/resources/config.edn
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/env/test/resources/config.edn
@@ -0,0 +1 @@
+{}
diff --git a/env/test/resources/logback.xml b/env/test/resources/logback.xml
new file mode 100644
index 0000000..cedeaea
--- /dev/null
+++ b/env/test/resources/logback.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ UTF-8
+ %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n
+
+
+
+ log/pastoralist.log
+
+ log/pastoralist.%d{yyyy-MM-dd}.%i.log
+
+ 100MB
+
+
+ 30
+
+
+ UTF-8
+ %date{ISO8601} [%thread] %-5level %logger{36} - %msg %n
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pastoralist.adl.xml b/pastoralist.adl.xml
new file mode 100644
index 0000000..f5df9cb
--- /dev/null
+++ b/pastoralist.adl.xml
@@ -0,0 +1,197 @@
+
+
+
+
+ A web-app intended to be used by pastoralists in managing
+ pastures, grazing, and animals.
+
+
+ All holdings used to the system
+
+ Although in the UK and, probably, in many other
+ countries, holdings have a unique, government issued, holding
+ number which could rationally be used as a key, internationally
+ we cannot rely on this.
+
+
+
+
+
+
+
+
+
+
+
+
+ A user is a person authorised to see data on a holding.
+ Some users (e.g. vets, farm secretaries) may be authorised to see data
+ on many holdings.
+
+
+ Records are not normally deleted from this system because
+ of historical data integrity; we may want to trace the history of an
+ animal across holdings even after a holding has ceased to be active
+ in the system.
+
+
+
+
+ A pasture within a holding. TODO: how should common pastures,
+ or pastures otherwise shared between holdings, be handled?
+
+
+
+
+
+
+
+
+
+
+
+
+ Polygon is a first-class datatype in PostGIS, but ADL does not yet
+ support this. Solutions:
+ 1. hack ADL to support native polygons;
+ 2. implement our own polygon datatype;
+ 3. work-around in override code.
+
+ Generally, when pasture boundaries are changed, a pasture record
+ should not be edited; rather it should be marked inactive, and new
+ pasture record(s) created.
+
+
+
+
+ Records are not normally deleted from this system because
+ of historical data integrity; we may want to trace the history of an
+ pasture even after pasture boundaries have been changed.
+
+ Indeed, if we treat subdivisions as pastures (which would be quite sensible),
+ active pastures in the system will change quite frequently, especially if
+ strip-grazing is used.
+
+
+
+
+
+
+ An animal on a holding (or which has been on a holding; the
+ historical record is not wiped when an animal dies, is killed, or is sold)
+
+
+ Although in the UK and, probably, in many other
+ countries, animals have a unique, government registered, identifying
+ number which could rationally be used as a key, internationally
+ we cannot rely on this.
+
+
+
+
+
+
+ The holding the animal is currently on, if any. Not 'required',
+ since the animal may be dead, or simply moved off to a holding which is
+ not part of the system.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The user who recorded this event.
+
+
+
+ Pastures affected by this event.
+
+
+ Animals affected by this event.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An image of the canvasser, so that other members of their
+ team can recognise them.
+
+
+
+ Information the canvasser supplies about themselves; an introduction.
+
+
+
+ Does a canvasser record need a phone field? There is a phone
+ field on the elector record. I suppose, for under-16 canvassers, there may
+ be no elector record.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project.clj b/project.clj
new file mode 100644
index 0000000..53c0e08
--- /dev/null
+++ b/project.clj
@@ -0,0 +1,149 @@
+(defproject pastoralist "0.1.0-SNAPSHOT"
+
+ :description "FIXME: write description"
+ :url "http://example.com/FIXME"
+
+ :dependencies [[adl-support "0.1.6"]
+ [ch.qos.logback/logback-classic "1.2.3"]
+ [cheshire "5.9.0"]
+ [clj-oauth "1.5.5"]
+ [cljs-ajax "0.8.0"]
+ [clojure.java-time "0.3.2"]
+ [com.cognitect/transit-clj "0.8.319"]
+ [conman "0.8.4"]
+ [cprop "0.1.14"]
+ [day8.re-frame/http-fx "0.1.6"]
+ [expound "0.8.1"]
+ [funcool/struct "1.4.0"]
+ [kee-frame "0.3.3" :exclusions [metosin/reitit-core org.clojure/core.async]]
+ [luminus-migrations "0.6.6"]
+ [luminus-transit "0.1.2"]
+ [luminus/ring-ttl-session "0.3.3"]
+ [markdown-clj "1.10.0"]
+ [metosin/muuntaja "0.6.6"]
+ [metosin/reitit "0.3.10"]
+ [metosin/ring-http-response "0.9.1"]
+ [mount "0.1.16"]
+ [nrepl "0.6.0"]
+ [org.clojure/clojure "1.10.1"]
+ [org.clojure/clojurescript "1.10.597" :scope "provided"]
+ [org.clojure/tools.cli "0.4.2"]
+ [org.clojure/tools.logging "0.5.0"]
+ [org.postgresql/postgresql "42.2.8"]
+ [org.webjars.npm/bulma "0.8.0"]
+ [org.webjars.npm/material-icons "0.3.1"]
+ [org.webjars/webjars-locator "0.38"]
+ [re-frame "0.10.9"]
+ [reagent "0.9.0-rc3"]
+ [ring-webjars "0.2.0"]
+ [ring/ring-core "1.8.0"]
+ [ring/ring-defaults "0.3.2"]
+ [ring/ring-servlet "1.7.1"]
+ [selmer "1.12.17"]]
+
+ :min-lein-version "2.0.0"
+
+ :source-paths ["src/clj" "src/cljs" "src/cljc"]
+ :test-paths ["test/clj"]
+ :resource-paths ["resources" "target/cljsbuild"]
+ :target-path "target/%s/"
+ :main ^:skip-aot pastoralist.core
+
+ :plugins [[lein-adl "0.1.8-SNAPSHOT"]
+ [lein-cljsbuild "1.1.7"]
+ [lein-kibit "0.1.2"]
+ [lein-uberwar "0.2.0"]]
+ :uberwar
+ {:handler pastoralist.handler/app
+ :init pastoralist.handler/init
+ :destroy pastoralist.handler/destroy
+ :name "pastoralist.war"}
+
+ :clean-targets ^{:protect false}
+ [:target-path [:cljsbuild :builds :app :compiler :output-dir] [:cljsbuild :builds :app :compiler :output-to]]
+ :figwheel
+ {:http-server-root "public"
+ :server-logfile "log/figwheel-logfile.log"
+ :nrepl-port 7002
+ :css-dirs ["resources/public/css"]
+ :nrepl-middleware [cider.piggieback/wrap-cljs-repl]}
+
+
+ :profiles
+ {:uberjar {:omit-source true
+ :prep-tasks ["compile" ["cljsbuild" "once" "min"]]
+ :cljsbuild{:builds
+ {:min
+ {:source-paths ["src/cljc" "src/cljs" "env/prod/cljs"]
+ :compiler
+ {:output-dir "target/cljsbuild/public/js"
+ :output-to "target/cljsbuild/public/js/app.js"
+ :source-map "target/cljsbuild/public/js/app.js.map"
+ :optimizations :advanced
+ :pretty-print false
+ :infer-externs true
+ :closure-warnings
+ {:externs-validation :off :non-standard-jsdoc :off}
+ :externs ["react/externs/react.js"]}}}}
+
+ :aot :all
+ :uberjar-name "pastoralist.jar"
+ :source-paths ["env/prod/clj"]
+ :resource-paths ["env/prod/resources"]}
+
+ :dev [:project/dev :profiles/dev]
+ :test [:project/dev :project/test :profiles/test]
+
+ :project/dev {:jvm-opts ["-Dconf=dev-config.edn"]
+ :dependencies [[binaryage/devtools "0.9.10"]
+ [cider/piggieback "0.4.2"]
+ [directory-naming/naming-java "0.8"]
+ [doo "0.1.11"]
+ [figwheel-sidecar "0.5.19"]
+ [luminus-jetty "0.1.7"]
+ [pjstadig/humane-test-output "0.10.0"]
+ [prone "2019-07-08"]
+ [re-frisk "0.5.4.1"]
+ [ring/ring-devel "1.8.0"]
+ [ring/ring-mock "0.4.0"]]
+ :plugins [[com.jakemccrary/lein-test-refresh "0.24.1"]
+ [jonase/eastwood "0.3.5"]
+ [lein-doo "0.1.11"]
+ [lein-figwheel "0.5.19"]]
+ :cljsbuild{:builds
+ {:app
+ {:source-paths ["src/cljs" "src/cljc" "env/dev/cljs"]
+ :figwheel {:on-jsload "pastoralist.core/mount-components"}
+ :compiler
+ {:output-dir "target/cljsbuild/public/js/out"
+ :closure-defines {"re_frame.trace.trace_enabled_QMARK_" true}
+ :optimizations :none
+ :preloads [re-frisk.preload]
+ :output-to "target/cljsbuild/public/js/app.js"
+ :asset-path "/js/out"
+ :source-map true
+ :main "pastoralist.app"
+ :pretty-print true}}}}
+
+
+ :doo {:build "test"}
+ :source-paths ["env/dev/clj"]
+ :resource-paths ["env/dev/resources"]
+ :repl-options {:init-ns user}
+ :injections [(require 'pjstadig.humane-test-output)
+ (pjstadig.humane-test-output/activate!)]}
+ :project/test {:jvm-opts ["-Dconf=test-config.edn"]
+ :resource-paths ["env/test/resources"]
+ :cljsbuild
+ {:builds
+ {:test
+ {:source-paths ["src/cljc" "src/cljs" "test/cljs"]
+ :compiler
+ {:output-to "target/test.js"
+ :main "pastoralist.doo-runner"
+ :optimizations :whitespace
+ :pretty-print true}}}}
+
+ }
+ :profiles/dev {}
+ :profiles/test {}})
diff --git a/resources/docs/docs.md b/resources/docs/docs.md
new file mode 100644
index 0000000..da08efe
--- /dev/null
+++ b/resources/docs/docs.md
@@ -0,0 +1,103 @@
+
Congratulations, your Luminus site is ready!
+
+This page will help guide you through the first steps of building your site.
+
+#### Why are you seeing this page?
+
+The `home-routes` handler in the `pastoralist.routes.home` namespace
+defines the route that invokes the `home-page` function whenever an HTTP
+request is made to the `/` URI using the `GET` method.
+
+```
+(defn home-routes []
+ [""
+ {:middleware [middleware/wrap-csrf
+ middleware/wrap-formats]}
+ ["/" {:get home-page}]
+ ["/docs" {:get (fn [_]
+ (-> (response/ok (-> "docs/docs.md" io/resource slurp))
+ (response/header "Content-Type" "text/plain; charset=utf-8")))}]])
+```
+
+The `home-page` function will in turn call the `pastoralist.layout/render` function
+to render the HTML content:
+
+```
+(defn home-page [_]
+ (layout/render "home.html"))
+```
+
+The page contains a link to the compiled ClojureScript found in the `target/cljsbuild/public` folder:
+
+```
+{% script "/js/app.js" %}
+```
+
+The rest of this page is rendered by ClojureScript found in the `src/cljs/pastoralist/core.cljs` file.
+
+
+
+#### Organizing the routes
+
+The routes are aggregated and wrapped with middleware in the `pastoralist.handler` namespace:
+
+```
+(mount/defstate app
+ :start
+ (middleware/wrap-base
+ (ring/ring-handler
+ (ring/router
+ [(home-routes)])
+ (ring/routes
+ (ring/create-resource-handler
+ {:path "/"})
+ (wrap-content-type
+ (wrap-webjars (constantly nil)))
+ (ring/create-default-handler
+ {:not-found
+ (constantly (error-page {:status 404, :title "404 - Page not found"}))
+ :method-not-allowed
+ (constantly (error-page {:status 405, :title "405 - Not allowed"}))
+ :not-acceptable
+ (constantly (error-page {:status 406, :title "406 - Not acceptable"}))})))))
+```
+
+The `app` definition groups all the routes in the application into a single handler.
+A default route group is added to handle the `404`, `405`, and `406` errors.
+
+learn more about routing »
+
+#### Managing your middleware
+
+Request middleware functions are located under the `pastoralist.middleware` namespace.
+
+This namespace is reserved for any custom middleware for the application. Some default middleware is
+already defined here. The middleware is assembled in the `wrap-base` function.
+
+Middleware used for development is placed in the `pastoralist.dev-middleware` namespace found in
+the `env/dev/clj/` source path.
+
+learn more about middleware »
+
+
+
+#### Database configuration is required
+
+If you haven't already, then please follow the steps below to configure your database connection and run the necessary migrations.
+
+* Create the database for your application.
+* Update the connection URL in the `dev-config.edn` and `test-config.edn` files with your database name and login credentials.
+* Run `lein run migrate` in the root of the project to create the tables.
+* Let `mount` know to start the database connection by `require`-ing `pastoralist.db.core` in some other namespace.
+* Restart the application.
+
+
learn more about database access »
+
+
+
+
+
+#### Need some help?
+
+Visit the [official documentation](http://www.luminusweb.net/docs) for examples
+on how to accomplish common tasks with Luminus. The `#luminus` channel on the [Clojurians Slack](http://clojurians.net/) and [Google Group](https://groups.google.com/forum/#!forum/luminusweb) are both great places to seek help and discuss projects with other users.
diff --git a/resources/html/error.html b/resources/html/error.html
new file mode 100644
index 0000000..fd31cc5
--- /dev/null
+++ b/resources/html/error.html
@@ -0,0 +1,49 @@
+
+
+
+ Something Bad Happened
+
+
+ {% style "/assets/bulma/css/bulma.min.css" %}
+
+
+
+
+
+
Error: {{status}}
+
+ {% if title %}
+ {{title}}
+ {% endif %}
+ {% if message %}
+ {{message}}
+ {% endif %}
+
+
+
+
diff --git a/resources/html/home.html b/resources/html/home.html
new file mode 100644
index 0000000..d1193d4
--- /dev/null
+++ b/resources/html/home.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Welcome to pastoralist
+
+
+
+
+
+
+
+
Welcome to pastoralist
+
If you're seeing this message, that means you haven't yet compiled your ClojureScript!
+
Please run lein figwheel
to start the ClojureScript compiler and reload the page.
+
For better ClojureScript development experience in Chrome follow these steps:
+
+ - Open DevTools
+
- Go to Settings ("three dots" icon in the upper right corner of DevTools > Menu > Settings F1 > General > Console)
+
- Check-in "Enable custom formatters"
+
- Close DevTools
+
- Open DevTools
+
+
See ClojureScript documentation for further details.
+
+
+
+
+
+
+ {% style "/assets/bulma/css/bulma.min.css" %}
+ {% style "/assets/material-icons/css/material-icons.min.css" %}
+ {% style "/css/screen.css" %}
+
+
+ {% script "/js/app.js" %}
+
+
diff --git a/resources/migrations/20191227100141-add-users-table.down.sql b/resources/migrations/20191227100141-add-users-table.down.sql
new file mode 100644
index 0000000..cc1f647
--- /dev/null
+++ b/resources/migrations/20191227100141-add-users-table.down.sql
@@ -0,0 +1 @@
+DROP TABLE users;
diff --git a/resources/migrations/20191227100141-add-users-table.up.sql b/resources/migrations/20191227100141-add-users-table.up.sql
new file mode 100644
index 0000000..b9c31f1
--- /dev/null
+++ b/resources/migrations/20191227100141-add-users-table.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE users
+(id VARCHAR(20) PRIMARY KEY,
+ first_name VARCHAR(30),
+ last_name VARCHAR(30),
+ email VARCHAR(30),
+ admin BOOLEAN,
+ last_login TIMESTAMP,
+ is_active BOOLEAN,
+ pass VARCHAR(300));
diff --git a/resources/public/css/screen.css b/resources/public/css/screen.css
new file mode 100644
index 0000000..e90ddb8
--- /dev/null
+++ b/resources/public/css/screen.css
@@ -0,0 +1,35 @@
+html,
+body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+@font-face {
+ font-family: 'Material Icons';
+ font-style: normal;
+ font-weight: 400;
+ src: url(/assets/material-icons/iconfont/MaterialIcons-Regular.eot); /* For IE6-8 */
+ src: local('Material Icons'),
+ local('MaterialIcons-Regular'),
+ url(/assets/material-icons/iconfont/MaterialIcons-Regular.woff2) format('woff2'),
+ url(/assets/material-icons/iconfont/MaterialIcons-Regular.woff) format('woff'),
+ url(/assets/material-icons/iconfont/MaterialIcons-Regular.ttf) format('truetype');
+}
+.material-icons {
+ font-family: 'Material Icons';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px; /* Preferred icon size */
+ display: inline-block;
+ line-height: 1;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: normal;
+ white-space: nowrap;
+ direction: ltr;
+ /* Support for all WebKit browsers. */
+ -webkit-font-smoothing: antialiased;
+ /* Support for Safari and Chrome. */
+ text-rendering: optimizeLegibility;
+ /* Support for Firefox. */
+ -moz-osx-font-smoothing: grayscale;
+}
+
diff --git a/resources/public/favicon.ico b/resources/public/favicon.ico
new file mode 100644
index 0000000..0e50cb2
Binary files /dev/null and b/resources/public/favicon.ico differ
diff --git a/resources/public/img/warning_clojure.png b/resources/public/img/warning_clojure.png
new file mode 100644
index 0000000..78d59e9
Binary files /dev/null and b/resources/public/img/warning_clojure.png differ
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
new file mode 100644
index 0000000..28d2b8c
--- /dev/null
+++ b/resources/sql/queries.sql
@@ -0,0 +1,21 @@
+-- :name create-user! :! :n
+-- :doc creates a new user record
+INSERT INTO users
+(id, first_name, last_name, email, pass)
+VALUES (:id, :first_name, :last_name, :email, :pass)
+
+-- :name update-user! :! :n
+-- :doc updates an existing user record
+UPDATE users
+SET first_name = :first_name, last_name = :last_name, email = :email
+WHERE id = :id
+
+-- :name get-user :? :1
+-- :doc retrieves a user record given the id
+SELECT * FROM users
+WHERE id = :id
+
+-- :name delete-user! :! :n
+-- :doc deletes a user record given the id
+DELETE FROM users
+WHERE id = :id
diff --git a/src/clj/pastoralist/config.clj b/src/clj/pastoralist/config.clj
new file mode 100644
index 0000000..f2fe3e3
--- /dev/null
+++ b/src/clj/pastoralist/config.clj
@@ -0,0 +1,13 @@
+(ns pastoralist.config
+ (:require
+ [cprop.core :refer [load-config]]
+ [cprop.source :as source]
+ [mount.core :refer [args defstate]]))
+
+(defstate env
+ :start
+ (load-config
+ :merge
+ [(args)
+ (source/from-system-props)
+ (source/from-env)]))
diff --git a/src/clj/pastoralist/db/core.clj b/src/clj/pastoralist/db/core.clj
new file mode 100644
index 0000000..38927da
--- /dev/null
+++ b/src/clj/pastoralist/db/core.clj
@@ -0,0 +1,86 @@
+(ns pastoralist.db.core
+ (:require
+ [cheshire.core :refer [generate-string parse-string]]
+ [clojure.java.jdbc :as jdbc]
+ [clojure.tools.logging :as log]
+ [conman.core :as conman]
+ [java-time :as jt]
+ [java-time.pre-java8]
+ [pastoralist.config :refer [env]]
+ [mount.core :refer [defstate]])
+ (:import org.postgresql.util.PGobject
+ java.sql.Array
+ clojure.lang.IPersistentMap
+ clojure.lang.IPersistentVector
+ [java.sql
+ BatchUpdateException
+ PreparedStatement]))
+(defstate ^:dynamic *db*
+ :start (if-let [jdbc-url (env :database-url)]
+ (conman/connect! {:jdbc-url jdbc-url})
+ (do
+ (log/warn "database connection URL was not found, please set :database-url in your config, e.g: dev-config.edn")
+ *db*))
+ :stop (conman/disconnect! *db*))
+
+(conman/bind-connection *db* "sql/queries.sql")
+
+
+(extend-protocol jdbc/IResultSetReadColumn
+ java.sql.Timestamp
+ (result-set-read-column [v _2 _3]
+ (.toLocalDateTime v))
+ java.sql.Date
+ (result-set-read-column [v _2 _3]
+ (.toLocalDate v))
+ java.sql.Time
+ (result-set-read-column [v _2 _3]
+ (.toLocalTime v))
+ Array
+ (result-set-read-column [v _ _] (vec (.getArray v)))
+ PGobject
+ (result-set-read-column [pgobj _metadata _index]
+ (let [type (.getType pgobj)
+ value (.getValue pgobj)]
+ (case type
+ "json" (parse-string value true)
+ "jsonb" (parse-string value true)
+ "citext" (str value)
+ value))))
+
+(defn to-pg-json [value]
+ (doto (PGobject.)
+ (.setType "jsonb")
+ (.setValue (generate-string value))))
+
+(extend-type clojure.lang.IPersistentVector
+ jdbc/ISQLParameter
+ (set-parameter [v ^java.sql.PreparedStatement stmt ^long idx]
+ (let [conn (.getConnection stmt)
+ meta (.getParameterMetaData stmt)
+ type-name (.getParameterTypeName meta idx)]
+ (if-let [elem-type (when (= (first type-name) \_) (apply str (rest type-name)))]
+ (.setObject stmt idx (.createArrayOf conn elem-type (to-array v)))
+ (.setObject stmt idx (to-pg-json v))))))
+
+(extend-protocol jdbc/ISQLValue
+ java.util.Date
+ (sql-value [v]
+ (java.sql.Timestamp. (.getTime v)))
+ java.time.LocalTime
+ (sql-value [v]
+ (jt/sql-time v))
+ java.time.LocalDate
+ (sql-value [v]
+ (jt/sql-date v))
+ java.time.LocalDateTime
+ (sql-value [v]
+ (jt/sql-timestamp v))
+ java.time.ZonedDateTime
+ (sql-value [v]
+ (jt/sql-timestamp v))
+ IPersistentMap
+ (sql-value [value] (to-pg-json value))
+ IPersistentVector
+ (sql-value [value] (to-pg-json value)))
+
diff --git a/src/clj/pastoralist/handler.clj b/src/clj/pastoralist/handler.clj
new file mode 100644
index 0000000..dc98615
--- /dev/null
+++ b/src/clj/pastoralist/handler.clj
@@ -0,0 +1,64 @@
+(ns pastoralist.handler
+ (:require
+ [pastoralist.middleware :as middleware]
+ [pastoralist.layout :refer [error-page]]
+ [pastoralist.routes.home :refer [home-routes]]
+ [pastoralist.routes.services :refer [service-routes]]
+ [pastoralist.routes.oauth :refer [oauth-routes]]
+ [reitit.swagger-ui :as swagger-ui]
+ [reitit.ring :as ring]
+ [ring.middleware.content-type :refer [wrap-content-type]]
+ [ring.middleware.webjars :refer [wrap-webjars]]
+ [pastoralist.env :refer [defaults]]
+ [mount.core :as mount]
+ [clojure.tools.logging :as log]
+ [pastoralist.config :refer [env]]))
+
+(mount/defstate init-app
+ :start ((or (:init defaults) (fn [])))
+ :stop ((or (:stop defaults) (fn []))))
+
+(defn init
+ "init will be called once when
+ app is deployed as a servlet on
+ an app server such as Tomcat
+ put any initialization code here"
+ []
+ (doseq [component (:started (mount/start))]
+ (log/info component "started")))
+
+(defn destroy
+ "destroy will be called when your application
+ shuts down, put any clean up code here"
+ []
+ (doseq [component (:stopped (mount/stop))]
+ (log/info component "stopped"))
+ (shutdown-agents)
+ (log/info "pastoralist has shut down!"))
+
+(mount/defstate app-routes
+ :start
+ (ring/ring-handler
+ (ring/router
+ [(home-routes)
+ (service-routes)
+ (oauth-routes)])
+ (ring/routes
+ (swagger-ui/create-swagger-ui-handler
+ {:path "/swagger-ui"
+ :url "/api/swagger.json"
+ :config {:validator-url nil}})
+ (ring/create-resource-handler
+ {:path "/"})
+ (wrap-content-type
+ (wrap-webjars (constantly nil)))
+ (ring/create-default-handler
+ {:not-found
+ (constantly (error-page {:status 404, :title "404 - Page not found"}))
+ :method-not-allowed
+ (constantly (error-page {:status 405, :title "405 - Not allowed"}))
+ :not-acceptable
+ (constantly (error-page {:status 406, :title "406 - Not acceptable"}))}))))
+
+(defn app []
+ (middleware/wrap-base #'app-routes))
diff --git a/src/clj/pastoralist/layout.clj b/src/clj/pastoralist/layout.clj
new file mode 100644
index 0000000..323176a
--- /dev/null
+++ b/src/clj/pastoralist/layout.clj
@@ -0,0 +1,39 @@
+(ns pastoralist.layout
+ (:require
+ [clojure.java.io]
+ [selmer.parser :as parser]
+ [selmer.filters :as filters]
+ [markdown.core :refer [md-to-html-string]]
+ [ring.util.http-response :refer [content-type ok]]
+ [ring.util.anti-forgery :refer [anti-forgery-field]]
+ [ring.middleware.anti-forgery :refer [*anti-forgery-token*]]
+ [ring.util.response]))
+
+(parser/set-resource-path! (clojure.java.io/resource "html"))
+(parser/add-tag! :csrf-field (fn [_ _] (anti-forgery-field)))
+(filters/add-filter! :markdown (fn [content] [:safe (md-to-html-string content)]))
+
+(defn render
+ "renders the HTML template located relative to resources/html"
+ [request template & [params]]
+ (content-type
+ (ok
+ (parser/render-file
+ template
+ (assoc params
+ :page template
+ :csrf-token *anti-forgery-token*)))
+ "text/html; charset=utf-8"))
+
+(defn error-page
+ "error-details should be a map containing the following keys:
+ :status - error status
+ :title - error title (optional)
+ :message - detailed error message (optional)
+
+ returns a response map with the error page as the body
+ and the status specified by the status key"
+ [error-details]
+ {:status (:status error-details)
+ :headers {"Content-Type" "text/html; charset=utf-8"}
+ :body (parser/render-file "error.html" error-details)})
diff --git a/src/clj/pastoralist/middleware.clj b/src/clj/pastoralist/middleware.clj
new file mode 100644
index 0000000..a2597f2
--- /dev/null
+++ b/src/clj/pastoralist/middleware.clj
@@ -0,0 +1,49 @@
+(ns pastoralist.middleware
+ (:require
+ [pastoralist.env :refer [defaults]]
+ [cheshire.generate :as cheshire]
+ [cognitect.transit :as transit]
+ [clojure.tools.logging :as log]
+ [pastoralist.layout :refer [error-page]]
+ [ring.middleware.anti-forgery :refer [wrap-anti-forgery]]
+ [pastoralist.middleware.formats :as formats]
+ [muuntaja.middleware :refer [wrap-format wrap-params]]
+ [pastoralist.config :refer [env]]
+ [ring-ttl-session.core :refer [ttl-memory-store]]
+ [ring.middleware.defaults :refer [site-defaults wrap-defaults]])
+
+ )
+
+(defn wrap-internal-error [handler]
+ (fn [req]
+ (try
+ (handler req)
+ (catch Throwable t
+ (log/error t (.getMessage t))
+ (error-page {:status 500
+ :title "Something very bad has happened!"
+ :message "We've dispatched a team of highly trained gnomes to take care of the problem."})))))
+
+(defn wrap-csrf [handler]
+ (wrap-anti-forgery
+ handler
+ {:error-response
+ (error-page
+ {:status 403
+ :title "Invalid anti-forgery token"})}))
+
+
+(defn wrap-formats [handler]
+ (let [wrapped (-> handler wrap-params (wrap-format formats/instance))]
+ (fn [request]
+ ;; disable wrap-formats for websockets
+ ;; since they're not compatible with this middleware
+ ((if (:websocket? request) handler wrapped) request))))
+
+(defn wrap-base [handler]
+ (-> ((:middleware defaults) handler)
+ (wrap-defaults
+ (-> site-defaults
+ (assoc-in [:security :anti-forgery] false)
+ (assoc-in [:session :store] (ttl-memory-store (* 60 30)))))
+ wrap-internal-error))
diff --git a/src/clj/pastoralist/middleware/exception.clj b/src/clj/pastoralist/middleware/exception.clj
new file mode 100644
index 0000000..249a000
--- /dev/null
+++ b/src/clj/pastoralist/middleware/exception.clj
@@ -0,0 +1,24 @@
+(ns pastoralist.middleware.exception
+ (:require [clojure.tools.logging :as log]
+ [expound.alpha :as expound]
+ [reitit.coercion :as coercion]
+ [reitit.ring.middleware.exception :as exception]))
+
+(defn coercion-error-handler [status]
+ (let [printer (expound/custom-printer {:print-specs? false})]
+ (fn [exception request]
+ {:status status
+ :headers {"Content-Type" "text/html"}
+ :body (with-out-str (printer (-> exception ex-data :problems)))})))
+
+(def exception-middleware
+ (exception/create-exception-middleware
+ (merge
+ exception/default-handlers
+ {;; log stack-traces for all exceptions
+ ::exception/wrap (fn [handler e request]
+ (log/error e (.getMessage e))
+ (handler e request))
+ ;; human-optimized validation messages
+ ::coercion/request-coercion (coercion-error-handler 400)
+ ::coercion/response-coercion (coercion-error-handler 500)})))
diff --git a/src/clj/pastoralist/middleware/formats.clj b/src/clj/pastoralist/middleware/formats.clj
new file mode 100644
index 0000000..6b41948
--- /dev/null
+++ b/src/clj/pastoralist/middleware/formats.clj
@@ -0,0 +1,15 @@
+(ns pastoralist.middleware.formats
+ (:require
+ [cognitect.transit :as transit]
+ [luminus-transit.time :as time]
+ [muuntaja.core :as m]))
+
+(def instance
+ (m/create
+ (-> m/default-options
+ (update-in
+ [:formats "application/transit+json" :decoder-opts]
+ (partial merge time/time-deserialization-handlers))
+ (update-in
+ [:formats "application/transit+json" :encoder-opts]
+ (partial merge time/time-serialization-handlers)))))
diff --git a/src/clj/pastoralist/nrepl.clj b/src/clj/pastoralist/nrepl.clj
new file mode 100644
index 0000000..df822d1
--- /dev/null
+++ b/src/clj/pastoralist/nrepl.clj
@@ -0,0 +1,27 @@
+(ns pastoralist.nrepl
+ (:require
+ [nrepl.server :as nrepl]
+ [clojure.tools.logging :as log]))
+
+(defn start
+ "Start a network repl for debugging on specified port followed by
+ an optional parameters map. The :bind, :transport-fn, :handler,
+ :ack-port and :greeting-fn will be forwarded to
+ clojure.tools.nrepl.server/start-server as they are."
+ [{:keys [port bind transport-fn handler ack-port greeting-fn]}]
+ (try
+ (log/info "starting nREPL server on port" port)
+ (nrepl/start-server :port port
+ :bind bind
+ :transport-fn transport-fn
+ :handler handler
+ :ack-port ack-port
+ :greeting-fn greeting-fn)
+
+ (catch Throwable t
+ (log/error t "failed to start nREPL")
+ (throw t))))
+
+(defn stop [server]
+ (nrepl/stop-server server)
+ (log/info "nREPL server stopped"))
diff --git a/src/clj/pastoralist/oauth.clj b/src/clj/pastoralist/oauth.clj
new file mode 100644
index 0000000..21f6675
--- /dev/null
+++ b/src/clj/pastoralist/oauth.clj
@@ -0,0 +1,36 @@
+(ns pastoralist.oauth
+ (:require
+ [pastoralist.config :refer [env]]
+ [oauth.client :as oauth]
+ [mount.core :refer [defstate]]
+ [clojure.tools.logging :as log]))
+
+(defstate consumer
+ :start (oauth/make-consumer
+ (env :oauth-consumer-key)
+ (env :oauth-consumer-secret)
+ (env :request-token-uri)
+ (env :access-token-uri)
+ (env :authorize-uri)
+ :hmac-sha1))
+
+(defn oauth-callback-uri
+ "Generates the oauth request callback URI"
+ [{:keys [headers]}]
+ (str (headers "x-forwarded-proto") "://" (headers "host") "/oauth/oauth-callback"))
+
+(defn fetch-request-token
+ "Fetches a request token."
+ [request]
+ (let [callback-uri (oauth-callback-uri request)]
+ (log/info "Fetching request token using callback-uri" callback-uri)
+ (oauth/request-token consumer (oauth-callback-uri request))))
+
+(defn fetch-access-token
+ [request_token]
+ (oauth/access-token consumer request_token (:oauth_verifier request_token)))
+
+(defn auth-redirect-uri
+ "Gets the URI the user should be redirected to when authenticating."
+ [request-token]
+ (str (oauth/user-approval-uri consumer request-token)))
diff --git a/src/clj/pastoralist/routes/home.clj b/src/clj/pastoralist/routes/home.clj
new file mode 100644
index 0000000..22e91c0
--- /dev/null
+++ b/src/clj/pastoralist/routes/home.clj
@@ -0,0 +1,21 @@
+(ns pastoralist.routes.home
+ (:require
+ [pastoralist.layout :as layout]
+ [pastoralist.db.core :as db]
+ [clojure.java.io :as io]
+ [pastoralist.middleware :as middleware]
+ [ring.util.response]
+ [ring.util.http-response :as response]))
+
+(defn home-page [request]
+ (layout/render request "home.html"))
+
+(defn home-routes []
+ [""
+ {:middleware [middleware/wrap-csrf
+ middleware/wrap-formats]}
+ ["/" {:get home-page}]
+ ["/docs" {:get (fn [_]
+ (-> (response/ok (-> "docs/docs.md" io/resource slurp))
+ (response/header "Content-Type" "text/plain; charset=utf-8")))}]])
+
diff --git a/src/clj/pastoralist/routes/oauth.clj b/src/clj/pastoralist/routes/oauth.clj
new file mode 100644
index 0000000..9ef3e74
--- /dev/null
+++ b/src/clj/pastoralist/routes/oauth.clj
@@ -0,0 +1,33 @@
+(ns pastoralist.routes.oauth
+ (:require
+ [ring.util.http-response :refer [ok found]]
+ [clojure.java.io :as io]
+ [pastoralist.oauth :as oauth]
+ [clojure.tools.logging :as log]))
+
+(defn oauth-init
+ "Initiates the Twitter OAuth"
+ [request]
+ (-> (oauth/fetch-request-token request)
+ :oauth_token
+ oauth/auth-redirect-uri
+ found))
+
+(defn oauth-callback
+ "Handles the callback from Twitter."
+ [{:keys [session params]}]
+ ; oauth request was denied by user
+ (if (:denied params)
+ (-> (found "/")
+ (assoc :flash {:denied true}))
+ ; fetch the request token and do anything else you wanna do if not denied.
+ (let [{:keys [user_id screen_name]} (oauth/fetch-access-token params)]
+ (log/info "successfully authenticated as" user_id screen_name)
+ (-> (found "/")
+ (assoc :session
+ (assoc session :user-id user_id :screen-name screen_name))))))
+
+(defn oauth-routes []
+ ["/oauth"
+ ["/oauth-init" {:get oauth-init}]
+ ["/oauth-callback" {:get oauth-callback}]])
diff --git a/src/clj/pastoralist/routes/services.clj b/src/clj/pastoralist/routes/services.clj
new file mode 100644
index 0000000..e8f3869
--- /dev/null
+++ b/src/clj/pastoralist/routes/services.clj
@@ -0,0 +1,91 @@
+(ns pastoralist.routes.services
+ (:require
+ [reitit.swagger :as swagger]
+ [reitit.swagger-ui :as swagger-ui]
+ [reitit.ring.coercion :as coercion]
+ [reitit.coercion.spec :as spec-coercion]
+ [reitit.ring.middleware.muuntaja :as muuntaja]
+ [reitit.ring.middleware.multipart :as multipart]
+ [reitit.ring.middleware.parameters :as parameters]
+ [pastoralist.middleware.formats :as formats]
+ [pastoralist.middleware.exception :as exception]
+ [ring.util.http-response :refer :all]
+ [clojure.java.io :as io]))
+
+(defn service-routes []
+ ["/api"
+ {:coercion spec-coercion/coercion
+ :muuntaja formats/instance
+ :swagger {:id ::api}
+ :middleware [;; query-params & form-params
+ parameters/parameters-middleware
+ ;; content-negotiation
+ muuntaja/format-negotiate-middleware
+ ;; encoding response body
+ muuntaja/format-response-middleware
+ ;; exception handling
+ exception/exception-middleware
+ ;; decoding request body
+ muuntaja/format-request-middleware
+ ;; coercing response bodys
+ coercion/coerce-response-middleware
+ ;; coercing request parameters
+ coercion/coerce-request-middleware
+ ;; multipart
+ multipart/multipart-middleware]}
+
+ ;; swagger documentation
+ ["" {:no-doc true
+ :swagger {:info {:title "my-api"
+ :description "https://cljdoc.org/d/metosin/reitit"}}}
+
+ ["/swagger.json"
+ {:get (swagger/create-swagger-handler)}]
+
+ ["/api-docs/*"
+ {:get (swagger-ui/create-swagger-ui-handler
+ {:url "/api/swagger.json"
+ :config {:validator-url nil}})}]]
+
+ ["/ping"
+ {:get (constantly (ok {:message "pong"}))}]
+
+
+ ["/math"
+ {:swagger {:tags ["math"]}}
+
+ ["/plus"
+ {:get {:summary "plus with spec query parameters"
+ :parameters {:query {:x int?, :y int?}}
+ :responses {200 {:body {:total pos-int?}}}
+ :handler (fn [{{{:keys [x y]} :query} :parameters}]
+ {:status 200
+ :body {:total (+ x y)}})}
+ :post {:summary "plus with spec body parameters"
+ :parameters {:body {:x int?, :y int?}}
+ :responses {200 {:body {:total pos-int?}}}
+ :handler (fn [{{{:keys [x y]} :body} :parameters}]
+ {:status 200
+ :body {:total (+ x y)}})}}]]
+
+ ["/files"
+ {:swagger {:tags ["files"]}}
+
+ ["/upload"
+ {:post {:summary "upload a file"
+ :parameters {:multipart {:file multipart/temp-file-part}}
+ :responses {200 {:body {:name string?, :size int?}}}
+ :handler (fn [{{{:keys [file]} :multipart} :parameters}]
+ {:status 200
+ :body {:name (:filename file)
+ :size (:size file)}})}}]
+
+ ["/download"
+ {:get {:summary "downloads a file"
+ :swagger {:produces ["image/png"]}
+ :handler (fn [_]
+ {:status 200
+ :headers {"Content-Type" "image/png"}
+ :body (-> "public/img/warning_clojure.png"
+ (io/resource)
+ (io/input-stream))})}}]]])
diff --git a/src/cljc/pastoralist/validation.cljc b/src/cljc/pastoralist/validation.cljc
new file mode 100644
index 0000000..1eaf978
--- /dev/null
+++ b/src/cljc/pastoralist/validation.cljc
@@ -0,0 +1,2 @@
+(ns pastoralist.validation
+ (:require [struct.core :as st]))
diff --git a/src/cljs/pastoralist/ajax.cljs b/src/cljs/pastoralist/ajax.cljs
new file mode 100644
index 0000000..c736d80
--- /dev/null
+++ b/src/cljs/pastoralist/ajax.cljs
@@ -0,0 +1,30 @@
+(ns pastoralist.ajax
+ (:require
+ [ajax.core :as ajax]
+ [luminus-transit.time :as time]
+ [cognitect.transit :as transit]
+ [re-frame.core :as rf]))
+
+(defn local-uri? [{:keys [uri]}]
+ (not (re-find #"^\w+?://" uri)))
+
+(defn default-headers [request]
+ (if (local-uri? request)
+ (-> request
+ (update :headers #(merge {"x-csrf-token" js/csrfToken} %)))
+ request))
+
+;; injects transit serialization config into request options
+(defn as-transit [opts]
+ (merge {:raw false
+ :format :transit
+ :response-format :transit
+ :reader (transit/reader :json time/time-deserialization-handlers)
+ :writer (transit/writer :json time/time-serialization-handlers)}
+ opts))
+
+(defn load-interceptors! []
+ (swap! ajax/default-interceptors
+ conj
+ (ajax/to-interceptor {:name "default headers"
+ :request default-headers})))
diff --git a/src/cljs/pastoralist/core.cljs b/src/cljs/pastoralist/core.cljs
new file mode 100644
index 0000000..0b72d02
--- /dev/null
+++ b/src/cljs/pastoralist/core.cljs
@@ -0,0 +1,55 @@
+(ns pastoralist.core
+ (:require
+ [kee-frame.core :as kf]
+ [re-frame.core :as rf]
+ [ajax.core :as http]
+ [pastoralist.ajax :as ajax]
+ [pastoralist.routing :as routing]
+ [pastoralist.view :as view]))
+
+
+(rf/reg-event-fx
+ ::load-about-page
+ (constantly nil))
+
+(kf/reg-controller
+ ::about-controller
+ {:params (constantly true)
+ :start [::load-about-page]})
+
+(rf/reg-sub
+ :docs
+ (fn [db _]
+ (:docs db)))
+
+(kf/reg-chain
+ ::load-home-page
+ (fn [_ _]
+ {:http-xhrio {:method :get
+ :uri "/docs"
+ :response-format (http/raw-response-format)
+ :on-failure [:common/set-error]}})
+ (fn [{:keys [db]} [_ docs]]
+ {:db (assoc db :docs docs)}))
+
+
+(kf/reg-controller
+ ::home-controller
+ {:params (constantly true)
+ :start [::load-home-page]})
+
+;; -------------------------
+;; Initialize app
+(defn mount-components
+ ([] (mount-components true))
+ ([debug?]
+ (rf/clear-subscription-cache!)
+ (kf/start! {:debug? (boolean debug?)
+ :routes routing/routes
+ :hash-routing? true
+ :initial-db {}
+ :root-component [view/root-component]})))
+
+(defn init! [debug?]
+ (ajax/load-interceptors!)
+ (mount-components debug?))
diff --git a/src/cljs/pastoralist/routing.cljs b/src/cljs/pastoralist/routing.cljs
new file mode 100644
index 0000000..993f273
--- /dev/null
+++ b/src/cljs/pastoralist/routing.cljs
@@ -0,0 +1,24 @@
+(ns pastoralist.routing
+ (:require
+ [re-frame.core :as rf]))
+
+(def routes
+ [["/" :home]
+ ["/about" :about]])
+
+(rf/reg-sub
+ :nav/route
+ :<- [:kee-frame/route]
+ identity)
+
+(rf/reg-event-fx
+ :nav/route-name
+ (fn [_ [_ route-name]]
+ {:navigate-to [route-name]}))
+
+
+(rf/reg-sub
+ :nav/page
+ :<- [:nav/route]
+ (fn [route _]
+ (-> route :data :name)))
diff --git a/src/cljs/pastoralist/view.cljs b/src/cljs/pastoralist/view.cljs
new file mode 100644
index 0000000..b8a137d
--- /dev/null
+++ b/src/cljs/pastoralist/view.cljs
@@ -0,0 +1,45 @@
+(ns pastoralist.view
+ (:require
+ [kee-frame.core :as kf]
+ [markdown.core :refer [md->html]]
+ [reagent.core :as r]
+ [re-frame.core :as rf]))
+
+(defn nav-link [title page]
+ [:a.navbar-item
+ {:href (kf/path-for [page])
+ :class (when (= page @(rf/subscribe [:nav/page])) "is-active")}
+ title])
+
+(defn navbar []
+ (r/with-let [expanded? (r/atom false)]
+ [:nav.navbar.is-info>div.container
+ [:div.navbar-brand
+ [:a.navbar-item {:href "/" :style {:font-weight :bold}} "pastoralist"]
+ [:span.navbar-burger.burger
+ {:data-target :nav-menu
+ :on-click #(swap! expanded? not)
+ :class (when @expanded? :is-active)}
+ [:span][:span][:span]]]
+ [:div#nav-menu.navbar-menu
+ {:class (when @expanded? :is-active)}
+ [:div.navbar-start
+ [nav-link "Home" :home]
+ [nav-link "About" :about]]]]))
+
+(defn about-page []
+ [:section.section>div.container>div.content
+ [:img {:src "/img/warning_clojure.png"}]])
+
+(defn home-page []
+ [:section.section>div.container>div.content
+ (when-let [docs @(rf/subscribe [:docs])]
+ [:div {:dangerouslySetInnerHTML {:__html (md->html docs)}}])])
+
+(defn root-component []
+ [:div
+ [navbar]
+ [kf/switch-route (fn [route] (get-in route [:data :name]))
+ :home home-page
+ :about about-page
+ nil [:div ""]]])
diff --git a/test-config.edn b/test-config.edn
new file mode 100644
index 0000000..09d2673
--- /dev/null
+++ b/test-config.edn
@@ -0,0 +1,8 @@
+;; WARNING
+;; The test-config.edn file is used for local environment variables, such as database credentials.
+;; This file is listed in .gitignore and will be excluded from version control by Git.
+
+{:port 3000
+ ; set your test database connection URL here
+ ; :database-url "postgresql://localhost/pastoralist_test?user=simon&password=db_user_password_here"
+}
diff --git a/test/clj/pastoralist/test/db/core.clj b/test/clj/pastoralist/test/db/core.clj
new file mode 100644
index 0000000..8201ee5
--- /dev/null
+++ b/test/clj/pastoralist/test/db/core.clj
@@ -0,0 +1,38 @@
+(ns pastoralist.test.db.core
+ (:require
+ [pastoralist.db.core :refer [*db*] :as db]
+ [java-time.pre-java8]
+ [luminus-migrations.core :as migrations]
+ [clojure.test :refer :all]
+ [clojure.java.jdbc :as jdbc]
+ [pastoralist.config :refer [env]]
+ [mount.core :as mount]))
+
+(use-fixtures
+ :once
+ (fn [f]
+ (mount/start
+ #'pastoralist.config/env
+ #'pastoralist.db.core/*db*)
+ (migrations/migrate ["migrate"] (select-keys env [:database-url]))
+ (f)))
+
+(deftest test-users
+ (jdbc/with-db-transaction [t-conn *db*]
+ (jdbc/db-set-rollback-only! t-conn)
+ (is (= 1 (db/create-user!
+ t-conn
+ {:id "1"
+ :first_name "Sam"
+ :last_name "Smith"
+ :email "sam.smith@example.com"
+ :pass "pass"})))
+ (is (= {:id "1"
+ :first_name "Sam"
+ :last_name "Smith"
+ :email "sam.smith@example.com"
+ :pass "pass"
+ :admin nil
+ :last_login nil
+ :is_active nil}
+ (db/get-user t-conn {:id "1"})))))
diff --git a/test/clj/pastoralist/test/handler.clj b/test/clj/pastoralist/test/handler.clj
new file mode 100644
index 0000000..a5584ae
--- /dev/null
+++ b/test/clj/pastoralist/test/handler.clj
@@ -0,0 +1,52 @@
+(ns pastoralist.test.handler
+ (:require
+ [clojure.test :refer :all]
+ [ring.mock.request :refer :all]
+ [pastoralist.handler :refer :all]
+ [pastoralist.middleware.formats :as formats]
+ [muuntaja.core :as m]
+ [mount.core :as mount]))
+
+(defn parse-json [body]
+ (m/decode formats/instance "application/json" body))
+
+(use-fixtures
+ :once
+ (fn [f]
+ (mount/start #'pastoralist.config/env
+ #'pastoralist.handler/app-routes)
+ (f)))
+
+(deftest test-app
+ (testing "main route"
+ (let [response ((app) (request :get "/"))]
+ (is (= 200 (:status response)))))
+
+ (testing "not-found route"
+ (let [response ((app) (request :get "/invalid"))]
+ (is (= 404 (:status response)))))
+ (testing "services"
+
+ (testing "success"
+ (let [response ((app) (-> (request :post "/api/math/plus")
+ (json-body {:x 10, :y 6})))]
+ (is (= 200 (:status response)))
+ (is (= {:total 16} (m/decode-response-body response)))))
+
+ (testing "parameter coercion error"
+ (let [response ((app) (-> (request :post "/api/math/plus")
+ (json-body {:x 10, :y "invalid"})))]
+ (is (= 400 (:status response)))))
+
+ (testing "response coercion error"
+ (let [response ((app) (-> (request :post "/api/math/plus")
+ (json-body {:x -10, :y 6})))]
+ (is (= 500 (:status response)))))
+
+ (testing "content negotiation"
+ (let [response ((app) (-> (request :post "/api/math/plus")
+ (body (pr-str {:x 10, :y 6}))
+ (content-type "application/edn")
+ (header "accept" "application/transit+json")))]
+ (is (= 200 (:status response)))
+ (is (= {:total 16} (m/decode-response-body response)))))))
diff --git a/test/cljs/pastoralist/core_test.cljs b/test/cljs/pastoralist/core_test.cljs
new file mode 100644
index 0000000..8d62942
--- /dev/null
+++ b/test/cljs/pastoralist/core_test.cljs
@@ -0,0 +1,9 @@
+(ns pastoralist.core-test
+ (:require [cljs.test :refer-macros [is are deftest testing use-fixtures]]
+ [pjstadig.humane-test-output]
+ [reagent.core :as reagent :refer [atom]]
+ [pastoralist.core :as rc]))
+
+(deftest test-home
+ (is (= true true)))
+
diff --git a/test/cljs/pastoralist/doo_runner.cljs b/test/cljs/pastoralist/doo_runner.cljs
new file mode 100644
index 0000000..4ecc104
--- /dev/null
+++ b/test/cljs/pastoralist/doo_runner.cljs
@@ -0,0 +1,6 @@
+(ns pastoralist.doo-runner
+ (:require [doo.runner :refer-macros [doo-tests]]
+ [pastoralist.core-test]))
+
+(doo-tests 'pastoralist.core-test)
+