Added Luminus app skeleton; added ADL.

This commit is contained in:
Simon Brooke 2019-12-27 11:28:57 +00:00
parent 8a923c1b33
commit f3c0e728a4
52 changed files with 1761 additions and 0 deletions

28
Capstanfile Normal file
View file

@ -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

7
Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM openjdk:8-alpine
COPY target/uberjar/pastoralist.jar /pastoralist/app.jar
EXPOSE 3000
CMD ["java", "-jar", "/pastoralist/app.jar"]

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: java -cp target/uberjar/pastoralist.jar clojure.main -m pastoralist.core

21
README.md Normal file
View file

@ -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

16
dev-config.edn Normal file
View file

@ -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"
}

77
env/dev/clj/pastoralist/core.clj vendored Normal file
View file

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

View file

@ -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]})))

15
env/dev/clj/pastoralist/env.clj vendored Normal file
View file

@ -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})

12
env/dev/clj/pastoralist/figwheel.clj vendored Normal file
View file

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

64
env/dev/clj/user.clj vendored Normal file
View file

@ -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])))

19
env/dev/cljs/pastoralist/app.cljs vendored Normal file
View file

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

1
env/dev/resources/config.edn vendored Normal file
View file

@ -0,0 +1 @@
{}

35
env/dev/resources/logback.xml vendored Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>log/pastoralist.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/pastoralist.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- keep 30 days of history -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<logger name="org.apache.http" level="warn" />
<logger name="org.xnio.nio" level="warn" />
<logger name="com.zaxxer.hikari" level="warn" />
<logger name="org.eclipse.jetty" level="warn" />
<root level="DEBUG">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>

11
env/prod/clj/pastoralist/env.clj vendored Normal file
View file

@ -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})

7
env/prod/cljs/pastoralist/app.cljs vendored Normal file
View file

@ -0,0 +1,7 @@
(ns pastoralist.app
(:require [pastoralist.core :as core]))
;;ignore println statements in prod
(set! *print-fn* (fn [& _]))
(core/init! false)

2
env/prod/resources/config.edn vendored Normal file
View file

@ -0,0 +1,2 @@
{:prod true
:port 3000}

26
env/prod/resources/logback.xml vendored Normal file
View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>log/pastoralist.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/pastoralist.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- keep 30 days of history -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<logger name="org.apache.http" level="warn" />
<logger name="org.xnio.nio" level="warn" />
<logger name="com.zaxxer.hikari" level="warn" />
<logger name="org.eclipse.jetty" level="warn" />
<root level="INFO">
<appender-ref ref="FILE" />
</root>
</configuration>

1
env/test/resources/config.edn vendored Normal file
View file

@ -0,0 +1 @@
{}

35
env/test/resources/logback.xml vendored Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>log/pastoralist.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/pastoralist.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- keep 30 days of history -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<logger name="org.apache.http" level="warn" />
<logger name="org.xnio.nio" level="warn" />
<logger name="com.zaxxer.hikari" level="warn" />
<logger name="org.eclipse.jetty" level="warn" />
<root level="DEBUG">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>

197
pastoralist.adl.xml Normal file
View file

@ -0,0 +1,197 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- DOCTYPE application PUBLIC "-//JOURNEYMAN//DTD ADL 1.4.1//EN"
"http://www.journeyman.cc/adl/stable/adl/schemas/adl-1.4.1.dtd" -->
<application xmlns="http://bowyer.journeyman.cc/adl/1.4.1/"
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns:adl="http://bowyer.journeyman.cc/adl/1.4.1/" name="pastoralist"
version="0.0.1">
<documentation>A web-app intended to be used by pastoralists in managing
pastures, grazing, and animals.</documentation>
<entity name="holding" magnitude="7" volatility="1">
<documentation>All holdings used to the system</documentation>
<key>
<documentation>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.
</documentation>
<property required="true" type="integer" name="id"
column="id" immutable="true" distinct="system">
<generator action="native"/>
</property>
</key>
<property size="64" type="string" name="holding-identifier" distinct="all">
<prompt prompt="Holding Number" locale="en_GB.UTF-8" immutable="true"/>
</property>
<property size="128" type="string" name="name" distinct="user"/>
<property type="list" name="pastures" entity="pasture"/>
<property type="list" name="animals" entity="animal"/>
<property type="link" name="users" entity="user">
<documentation>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.</documentation>
</property>
<property name="is-active" type="boolean" default="true">
<documentation>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.</documentation>
</property>
</entity>
<entity name="pasture" magnitude="8" volatility="2">
<documentation>A pasture within a holding. TODO: how should common pastures,
or pastures otherwise shared between holdings, be handled?</documentation>
<key>
<property required="true" type="integer" name="id"
column="id" immutable="true" distinct="system">
<generator action="native"/>
</property>
</key>
<property size="128" type="string" name="name" distinct="user"/>
<property type="real" name="area" distinct="user">
<prompt prompt="Area in Ha" locale="en_GB.UTF-8"/>
</property>
<property name="holding" type="entity" entity="holding" required="true"/>
<property name="geometry" type="entity" entity="polygon" required="true">
<documentation>
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.
</documentation>
</property>
<property name="subdivision-of" type="entity" entity="pasture"/>
<property name="is-active" type="boolean" default="true">
<documentation>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.
</documentation>
</property>
<property name="history" type="link" entity="event"/>
</entity>
<entity name="animal" magnitude="9" volatility="5">
<documentation>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)
</documentation>
<key>
<documentation>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.
</documentation>
<property required="true" type="integer" name="id"
column="id" immutable="true" distinct="system">
<generator action="native"/>
</property>
</key>
<property name="holding" type="entity" entity="holding">
<documentation>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.</documentation>
</property>
<property name="dam" type="entity" entity="animal"/>
<property name="sire" type="entity" entity="animal"/>
<property name="born" type="date" immutable="true" required="true"/>
<property name="animal-identifier" type="string" size="64" distinct="user" required="true">
<prompt prompt="Ear-tag Number" locale="en_GB.UTF-8"/>
</property>
<property name="gender" type="entity" entity="gender" required="true"/>
<property name="species" type="entity" entity="species" required="true"/>
<property name="breed" type="entity" entity="breed"/>
<property name="is-pedigree" type="boolean" default="false"/>
<property name="pasture" type="entity" entity="pasture"/>
<property name="history" type="link" entity="event"/>
</entity>
<entity name="gender" magnitude="1" volatility="10">
<key>
<property name="gender" type="string" size="8" distinct="all" immutable="true"/>
</key>
</entity>
<entity name="species" magnitude="2" volatility="5">
<key>
<property name="species" type="string" size="32" distinct="all" immutable="true"/>
</key>
</entity>
<entity name="breed" magnitude="1" volatility="10">
<key>
<property name="breed" type="string" size="32" distinct="all" immutable="true"/>
</key>
<property name="species" type="entity" entity="species"/>
</entity>
<entity name="event" magnitude="9" volatility="2">
<key>
<property required="true" type="integer" name="id"
column="id" immutable="true" distinct="system">
<generator action="native"/>
</property>
</key>
<property name="type" type="entity" entity="event-type"></property>
<property name="actor" type="entity" entity="user" required="true" immutable="true">
<documentation>The user who recorded this event.</documentation>
</property>
<promperty name="date" type="timestamp" required="true" default="now()"/>
<property name="pastures" type="link" entity="pasture">
<documentation>Pastures affected by this event.</documentation>
</property>
<property name="animals" type="link" entity="animal">
<documentation>Animals affected by this event.</documentation>
</property>
<property name="summary" type="string" size="80"/>
<property name="notes" type="text"/>
</entity>
<entity name="user" magnitude="6" volatility="3">
<key>
<property required="true" type="string" name="username"
column="username" immutable="true" distinct="all"/>
</key>
<property required="true" type="string" name="fullname"
column="fullname" size="64" distinct="user">
<prompt prompt="fullname" locale="en_GB.UTF-8"/>
</property>
<property name="avatar" type="image" size="256">
<documentation>An image of the canvasser, so that other members of their
team can recognise them.</documentation>
<prompt prompt="Avatar" locale="en_GB.UTF-8"/>
</property>
<property name="bio" type="text">
<documentation>Information the canvasser supplies about themselves; an introduction.</documentation>
<prompt prompt="Bio" locale="en_GB.UTF-8"/>
</property>
<property type="string" name="phone" column="phone" size="16"
distinct="user">
<documentation>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.</documentation>
<prompt prompt="phone" locale="en_GB.UTF-8"/>
</property>
<property type="string" name="email" column="email" size="128"
distinct="user">
<prompt prompt="email" locale="en_GB.UTF-8"/>
</property>
<property required="true" type="entity" name="authority_id"
entity="authorities" farkey="id">
<prompt prompt="authority_id" locale="en_GB.UTF-8"/>
</property>
<property type="boolean" name="authorised" column="authorised">
<prompt prompt="authorised" locale="en_GB.UTF-8"/>
</property>
</entity>
</application>

149
project.clj Normal file
View file

@ -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 {}})

103
resources/docs/docs.md Normal file
View file

@ -0,0 +1,103 @@
<h2 class="alert alert-success">Congratulations, your <a class="alert-link" href="http://luminusweb.net">Luminus</a> site is ready!</h2>
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.
<a class="btn btn-primary" href="https://metosin.github.io/reitit/basics">learn more about routing »</a>
#### 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.
<a class="btn btn-primary" href="http://www.luminusweb.net/docs/middleware.md">learn more about middleware »</a>
<div class="bs-callout bs-callout-danger">
#### 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.
<a class="btn btn-primary" href="http://www.luminusweb.net/docs/database.md">learn more about database access »</a>
</div>
#### 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.

49
resources/html/error.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>Something Bad Happened</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% style "/assets/bulma/css/bulma.min.css" %}
<style type="text/css">
html {
height: 100%;
min-height: 100%;
min-width: 100%;
overflow: hidden;
width: 100%;
}
html body {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
html .container-fluid {
display: table;
height: 100%;
padding: 0;
width: 100%;
}
html .row-fluid {
display: table-cell;
height: 100%;
vertical-align: middle;
}
</style>
</head>
<body>
<div class="container is-fluid">
<div class="has-text-centered">
<h1><span class="is-size-4 has-text-danger">Error: {{status}}</span></h1>
<hr>
{% if title %}
<h2 class="without-margin">{{title}}</h2>
{% endif %}
{% if message %}
<h4 class="text-danger">{{message}}</h4>
{% endif %}
</div>
</div>
</body>
</html>

41
resources/html/home.html Normal file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to pastoralist</title>
</head>
<body>
<div id="app">
<section class="section">
<div class="container is-fluid">
<div class="content">
<h4 class="title">Welcome to pastoralist</h4>
<p>If you're seeing this message, that means you haven't yet compiled your ClojureScript!</p>
<p>Please run <code>lein figwheel</code> to start the ClojureScript compiler and reload the page.</p>
<h4>For better ClojureScript development experience in Chrome follow these steps:</h4>
<ul>
<li>Open DevTools
<li>Go to Settings ("three dots" icon in the upper right corner of DevTools > Menu > Settings F1 > General > Console)
<li>Check-in "Enable custom formatters"
<li>Close DevTools
<li>Open DevTools
</ul>
<p>See <a href="http://www.luminusweb.net/docs/clojurescript.md">ClojureScript</a> documentation for further details.</p>
</div>
</div>
</section>
</div>
<!-- scripts and styles -->
{% style "/assets/bulma/css/bulma.min.css" %}
{% style "/assets/material-icons/css/material-icons.min.css" %}
{% style "/css/screen.css" %}
<script type="text/javascript">
var csrfToken = "{{csrf-token}}";
</script>
{% script "/js/app.js" %}
</body>
</html>

View file

@ -0,0 +1 @@
DROP TABLE users;

View file

@ -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));

View file

@ -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;
}

Binary file not shown.

After

Width: 16px  |  Height: 16px  |  Size: 1.1 KiB

Binary file not shown.

After

(image error) Size: 21 KiB

21
resources/sql/queries.sql Normal file
View file

@ -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

View file

@ -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)]))

View file

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

View file

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

View file

@ -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)})

View file

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

View file

@ -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)})))

View file

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

View file

@ -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"))

View file

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

View file

@ -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")))}]])

View file

@ -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}]])

View file

@ -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))})}}]]])

View file

@ -0,0 +1,2 @@
(ns pastoralist.validation
(:require [struct.core :as st]))

View file

@ -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})))

View file

@ -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?))

View file

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

View file

@ -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 ""]]])

8
test-config.edn Normal file
View file

@ -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"
}

View file

@ -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"})))))

View file

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

View file

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

View file

@ -0,0 +1,6 @@
(ns pastoralist.doo-runner
(:require [doo.runner :refer-macros [doo-tests]]
[pastoralist.core-test]))
(doo-tests 'pastoralist.core-test)