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) +