Browse Source

First commit

Stephen Starkey 5 months ago
commit
264d7d5f6f

+ 18
- 0
.gitignore View File

@@ -0,0 +1,18 @@
1
+/target
2
+/lib
3
+/classes
4
+/checkouts
5
+pom.xml
6
+dev-config.edn
7
+test-config.edn
8
+*.jar
9
+*.class
10
+/.lein-*
11
+profiles.clj
12
+/.env
13
+.nrepl-port
14
+
15
+/node_modules
16
+/log
17
+*.iml
18
+.idea

+ 42
- 0
README.md View File

@@ -0,0 +1,42 @@
1
+# email-counter
2
+
3
+This project is used to demonstrate how to build a simple web service
4
+which, when given a list of email addresses, will return the total
5
+number of unique instances of each address, after stripping out
6
+content that Gmail would normally ignore.
7
+
8
+Given the peculiarities of the clojure web application landscape, 
9
+quite a lot of this project is generated boilerplate using the
10
+Luminus micro-framework. You can learn more at 
11
+<http://www.luminusweb.net/>
12
+
13
+This particular service was generated using Luminus version 3.48
14
+
15
+## Prerequisites
16
+
17
+You will need [Leiningen][1] 2.0 or above installed.
18
+
19
+[1]: https://github.com/technomancy/leiningen
20
+
21
+## Developing
22
+
23
+Unit tests can be found in
24
+
25
+    test/clj/email_counter/test
26
+    
27
+Run the unit tests using:
28
+
29
+    lein test
30
+    
31
+Main API routes can be found in
32
+
33
+    src/clj/email_counter/routes/services.clj
34
+
35
+## Running
36
+
37
+To start a web server for the application, run:
38
+
39
+    lein run 
40
+
41
+Then, after the application has started, open a web browser to
42
+<http://localhost:3000>

+ 11
- 0
env/dev/clj/email_counter/dev_middleware.clj View File

@@ -0,0 +1,11 @@
1
+(ns email-counter.dev-middleware
2
+  (:require
3
+    [ring.middleware.reload :refer [wrap-reload]]
4
+    [selmer.middleware :refer [wrap-error-page]]
5
+    [prone.middleware :refer [wrap-exceptions]]))
6
+
7
+(defn wrap-dev [handler]
8
+  (-> handler
9
+      wrap-reload
10
+      wrap-error-page
11
+      (wrap-exceptions {:app-namespaces ['email-counter]})))

+ 15
- 0
env/dev/clj/email_counter/env.clj View File

@@ -0,0 +1,15 @@
1
+(ns email-counter.env
2
+  (:require
3
+    [selmer.parser :as parser]
4
+    [clojure.tools.logging :as log]
5
+    [email-counter.dev-middleware :refer [wrap-dev]]))
6
+
7
+(def defaults
8
+  {:init
9
+   (fn []
10
+     (parser/cache-off!)
11
+     (log/info "\n-=[email-counter started successfully using the development profile]=-"))
12
+   :stop
13
+   (fn []
14
+     (log/info "\n-=[email-counter has shut down successfully]=-"))
15
+   :middleware wrap-dev})

+ 31
- 0
env/dev/clj/user.clj View File

@@ -0,0 +1,31 @@
1
+(ns user
2
+  "Userspace functions you can run by default in your local REPL."
3
+  (:require
4
+    [email-counter.config :refer [env]]
5
+    [clojure.spec.alpha :as s]
6
+    [expound.alpha :as expound]
7
+    [mount.core :as mount]
8
+    [email-counter.core :refer [start-app]]))
9
+
10
+(alter-var-root #'s/*explain-out* (constantly expound/printer))
11
+
12
+(add-tap (bound-fn* clojure.pprint/pprint))
13
+
14
+(defn start 
15
+  "Starts application.
16
+  You'll usually want to run this on startup."
17
+  []
18
+  (mount/start-without #'email-counter.core/repl-server))
19
+
20
+(defn stop 
21
+  "Stops application."
22
+  []
23
+  (mount/stop-except #'email-counter.core/repl-server))
24
+
25
+(defn restart 
26
+  "Restarts application."
27
+  []
28
+  (stop)
29
+  (start))
30
+
31
+

+ 1
- 0
env/dev/resources/config.edn View File

@@ -0,0 +1 @@
1
+{}

+ 33
- 0
env/dev/resources/logback.xml View File

@@ -0,0 +1,33 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<configuration scan="true" scanPeriod="10 seconds">
3
+    <statusListener class="ch.qos.logback.core.status.NopStatusListener" />
4
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
5
+        <!-- encoders are assigned the type
6
+             ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
7
+        <encoder>
8
+            <charset>UTF-8</charset>
9
+            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
10
+        </encoder>
11
+    </appender>
12
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
13
+        <file>log/email-counter.log</file>
14
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
15
+            <fileNamePattern>log/email-counter.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
16
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
17
+                <maxFileSize>100MB</maxFileSize>
18
+            </timeBasedFileNamingAndTriggeringPolicy>
19
+            <!-- keep 30 days of history -->
20
+            <maxHistory>30</maxHistory>
21
+        </rollingPolicy>
22
+        <encoder>
23
+            <charset>UTF-8</charset>
24
+            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
25
+        </encoder>
26
+    </appender>
27
+    <logger name="org.apache.http" level="warn" />
28
+    <logger name="org.xnio.nio" level="warn" />
29
+    <root level="DEBUG">
30
+        <appender-ref ref="STDOUT" />
31
+        <appender-ref ref="FILE" />
32
+    </root>
33
+</configuration>

+ 11
- 0
env/prod/clj/email_counter/env.clj View File

@@ -0,0 +1,11 @@
1
+(ns email-counter.env
2
+  (:require [clojure.tools.logging :as log]))
3
+
4
+(def defaults
5
+  {:init
6
+   (fn []
7
+     (log/info "\n-=[email-counter started successfully]=-"))
8
+   :stop
9
+   (fn []
10
+     (log/info "\n-=[email-counter has shut down successfully]=-"))
11
+   :middleware identity})

+ 2
- 0
env/prod/resources/config.edn View File

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

+ 24
- 0
env/prod/resources/logback.xml View File

@@ -0,0 +1,24 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<configuration>
3
+    <statusListener class="ch.qos.logback.core.status.NopStatusListener" />
4
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
5
+        <file>log/email-counter.log</file>
6
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
7
+            <fileNamePattern>log/email-counter.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
8
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
9
+                <maxFileSize>100MB</maxFileSize>
10
+            </timeBasedFileNamingAndTriggeringPolicy>
11
+            <!-- keep 30 days of history -->
12
+            <maxHistory>30</maxHistory>
13
+        </rollingPolicy>
14
+        <encoder>
15
+            <charset>UTF-8</charset>
16
+            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
17
+        </encoder>
18
+    </appender>
19
+    <logger name="org.apache.http" level="warn" />
20
+    <logger name="org.xnio.nio" level="warn" />
21
+    <root level="INFO">
22
+        <appender-ref ref="FILE" />
23
+    </root>
24
+</configuration>

+ 1
- 0
env/test/resources/config.edn View File

@@ -0,0 +1 @@
1
+{}

+ 33
- 0
env/test/resources/logback.xml View File

@@ -0,0 +1,33 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<configuration scan="true" scanPeriod="10 seconds">
3
+    <statusListener class="ch.qos.logback.core.status.NopStatusListener" />
4
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
5
+        <!-- encoders are assigned the type
6
+             ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
7
+        <encoder>
8
+            <charset>UTF-8</charset>
9
+            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
10
+        </encoder>
11
+    </appender>
12
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
13
+        <file>log/email-counter.log</file>
14
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
15
+            <fileNamePattern>log/email-counter.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
16
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
17
+                <maxFileSize>100MB</maxFileSize>
18
+            </timeBasedFileNamingAndTriggeringPolicy>
19
+            <!-- keep 30 days of history -->
20
+            <maxHistory>30</maxHistory>
21
+        </rollingPolicy>
22
+        <encoder>
23
+            <charset>UTF-8</charset>
24
+            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
25
+        </encoder>
26
+    </appender>
27
+    <logger name="org.apache.http" level="warn" />
28
+    <logger name="org.xnio.nio" level="warn" />
29
+    <root level="DEBUG">
30
+        <appender-ref ref="STDOUT" />
31
+        <appender-ref ref="FILE" />
32
+    </root>
33
+</configuration>

+ 71
- 0
project.clj View File

@@ -0,0 +1,71 @@
1
+(defproject email-counter "0.1.0-SNAPSHOT"
2
+
3
+  :description "FIXME: write description"
4
+  :url "http://example.com/FIXME"
5
+
6
+  :dependencies [[ch.qos.logback/logback-classic "1.2.3"]
7
+                 [cheshire "5.8.1"]
8
+                 [clojure.java-time "0.3.2"]
9
+                 [com.sun.mail/javax.mail "1.6.0"]
10
+                 [cprop "0.1.14"]
11
+                 [expound "0.7.2"]
12
+                 [funcool/struct "1.4.0"]
13
+                 [javax.mail/javax.mail-api "1.6.2"]
14
+                 [javax.mail/mail "1.4"]
15
+                 [luminus-http-kit "0.1.6"]
16
+                 [luminus-transit "0.1.1"]
17
+                 [luminus/ring-ttl-session "0.3.3"]
18
+                 [markdown-clj "1.10.0"]
19
+                 [metosin/muuntaja "0.6.4"]
20
+                 [metosin/reitit "0.3.9"]
21
+                 [metosin/ring-http-response "0.9.1"]
22
+                 [mount "0.1.16"]
23
+                 [nrepl "0.6.0"]
24
+                 [org.clojure/clojure "1.10.1"]
25
+                 [org.clojure/tools.cli "0.4.2"]
26
+                 [org.clojure/tools.logging "0.5.0"]
27
+                 [org.webjars.npm/bulma "0.7.5"]
28
+                 [org.webjars.npm/material-icons "0.3.0"]
29
+                 [org.webjars/webjars-locator "0.36"]
30
+                 [prismatic/schema "1.1.12"]
31
+                 [ring-webjars "0.2.0"]
32
+                 [ring/ring-core "1.7.1"]
33
+                 [ring/ring-defaults "0.3.2"]
34
+                 [selmer "1.12.14"]]
35
+
36
+  :min-lein-version "2.0.0"
37
+  
38
+  :source-paths ["src/clj"]
39
+  :test-paths ["test/clj"]
40
+  :resource-paths ["resources"]
41
+  :target-path "target/%s/"
42
+  :main ^:skip-aot email-counter.core
43
+
44
+  :plugins []
45
+
46
+  :profiles
47
+  {:uberjar {:omit-source true
48
+             :aot :all
49
+             :uberjar-name "email-counter.jar"
50
+             :source-paths ["env/prod/clj"]
51
+             :resource-paths ["env/prod/resources"]}
52
+
53
+   :dev           [:project/dev :profiles/dev]
54
+   :test          [:project/dev :project/test :profiles/test]
55
+
56
+   :project/dev  {:jvm-opts ["-Dconf=dev-config.edn"]
57
+                  :dependencies [[pjstadig/humane-test-output "0.9.0"]
58
+                                 [prone "2019-07-08"]
59
+                                 [ring/ring-devel "1.7.1"]
60
+                                 [ring/ring-mock "0.4.0"]]
61
+                  :plugins      [[com.jakemccrary/lein-test-refresh "0.24.1"]]
62
+                  
63
+                  :source-paths ["env/dev/clj"]
64
+                  :resource-paths ["env/dev/resources"]
65
+                  :repl-options {:init-ns user}
66
+                  :injections [(require 'pjstadig.humane-test-output)
67
+                               (pjstadig.humane-test-output/activate!)]}
68
+   :project/test {:jvm-opts ["-Dconf=test-config.edn"]
69
+                  :resource-paths ["env/test/resources"]}
70
+   :profiles/dev {}
71
+   :profiles/test {}})

BIN
resources/public/favicon.ico View File


BIN
resources/public/img/warning_clojure.png View File


+ 13
- 0
src/clj/email_counter/config.clj View File

@@ -0,0 +1,13 @@
1
+(ns email-counter.config
2
+  (:require
3
+    [cprop.core :refer [load-config]]
4
+    [cprop.source :as source]
5
+    [mount.core :refer [args defstate]]))
6
+
7
+(defstate env
8
+  :start
9
+  (load-config
10
+    :merge
11
+    [(args)
12
+     (source/from-system-props)
13
+     (source/from-env)]))

+ 58
- 0
src/clj/email_counter/core.clj View File

@@ -0,0 +1,58 @@
1
+(ns email-counter.core
2
+  (:require
3
+    [email-counter.handler :as handler]
4
+    [email-counter.nrepl :as nrepl]
5
+    [luminus.http-server :as http]
6
+    [email-counter.config :refer [env]]
7
+    [clojure.tools.cli :refer [parse-opts]]
8
+    [clojure.tools.logging :as log]
9
+    [mount.core :as mount])
10
+  (:gen-class))
11
+
12
+;; log uncaught exceptions in threads
13
+(Thread/setDefaultUncaughtExceptionHandler
14
+  (reify Thread$UncaughtExceptionHandler
15
+    (uncaughtException [_ thread ex]
16
+      (log/error {:what :uncaught-exception
17
+                  :exception ex
18
+                  :where (str "Uncaught exception on" (.getName thread))}))))
19
+
20
+(def cli-options
21
+  [["-p" "--port PORT" "Port number"
22
+    :parse-fn #(Integer/parseInt %)]])
23
+
24
+(mount/defstate ^{:on-reload :noop} http-server
25
+  :start
26
+  (http/start
27
+    (-> env
28
+        (assoc  :handler (handler/app))
29
+        (update :io-threads #(or % (* 2 (.availableProcessors (Runtime/getRuntime)))))
30
+        (update :port #(or (-> env :options :port) %))))
31
+  :stop
32
+  (http/stop http-server))
33
+
34
+(mount/defstate ^{:on-reload :noop} repl-server
35
+  :start
36
+  (when (env :nrepl-port)
37
+    (nrepl/start {:bind (env :nrepl-bind)
38
+                  :port (env :nrepl-port)}))
39
+  :stop
40
+  (when repl-server
41
+    (nrepl/stop repl-server)))
42
+
43
+
44
+(defn stop-app []
45
+  (doseq [component (:stopped (mount/stop))]
46
+    (log/info component "stopped"))
47
+  (shutdown-agents))
48
+
49
+(defn start-app [args]
50
+  (doseq [component (-> args
51
+                        (parse-opts cli-options)
52
+                        mount/start-with-args
53
+                        :started)]
54
+    (log/info component "started"))
55
+  (.addShutdownHook (Runtime/getRuntime) (Thread. stop-app)))
56
+
57
+(defn -main [& args]
58
+  (start-app args))

+ 33
- 0
src/clj/email_counter/emails.clj View File

@@ -0,0 +1,33 @@
1
+(ns email-counter.emails
2
+  (:require [clojure.string :as str]
3
+            [clojure.tools.logging :as log])
4
+  (:import (javax.mail.internet AddressException InternetAddress)))
5
+
6
+(defn parse-email
7
+  "Attempt to get the address portion of an assumed rfc822-compliant email
8
+   address. If we can't, we return nil."
9
+  [s]
10
+  (try
11
+    (when s (-> s InternetAddress. .getAddress))
12
+    (catch AddressException _)))
13
+
14
+(defn parse-for-gmail
15
+  "Given a valid email address, strip any periods and any content after a +
16
+  from the name portion. If the email is nil, we will simply return nil"
17
+  [email]
18
+  (let [email (parse-email email)
19
+        [_ n d] (when email (re-matches #"^(.*?)(@[^@]+$)" email))]
20
+    (some-> n
21
+            (str/replace #"\." "")
22
+            (str/replace #"\+.*" "")
23
+            (str d))))
24
+
25
+(defn unique-gmail-parsed-emails
26
+  "Given a bunch of email addresses, strip out the bits Gmail would have
27
+  stripped out, lower-case them, and then add them to a hash set"
28
+  [emails]
29
+  (->> emails
30
+       (map parse-for-gmail)
31
+       (remove nil?)
32
+       (map str/lower-case)
33
+       (into #{})))

+ 29
- 0
src/clj/email_counter/handler.clj View File

@@ -0,0 +1,29 @@
1
+(ns email-counter.handler
2
+  (:require
3
+    [email-counter.middleware :as middleware]
4
+    [email-counter.routes.services :refer [service-routes]]
5
+    [reitit.ring :as ring]
6
+    [ring.middleware.content-type :refer [wrap-content-type]]
7
+    [ring.middleware.webjars :refer [wrap-webjars]]
8
+    [email-counter.env :refer [defaults]]
9
+    [mount.core :as mount]))
10
+
11
+(mount/defstate init-app
12
+  :start ((or (:init defaults) (fn [])))
13
+  :stop  ((or (:stop defaults) (fn []))))
14
+
15
+(mount/defstate app-routes
16
+  :start
17
+  (ring/ring-handler
18
+    (ring/router
19
+      [["/" {:get
20
+             {:handler (constantly {:status 301 :headers {"Location" "/api/api-docs/index.html"}})}}]
21
+       (service-routes)])
22
+    (ring/routes
23
+      (ring/create-resource-handler
24
+        {:path "/"})
25
+      (wrap-content-type (wrap-webjars (constantly nil)))
26
+      (ring/create-default-handler))))
27
+
28
+(defn app []
29
+  (middleware/wrap-base #'app-routes))

+ 13
- 0
src/clj/email_counter/middleware.clj View File

@@ -0,0 +1,13 @@
1
+(ns email-counter.middleware
2
+  (:require
3
+    [email-counter.env :refer [defaults]]
4
+    [email-counter.config :refer [env]]
5
+    [ring-ttl-session.core :refer [ttl-memory-store]]
6
+    [ring.middleware.defaults :refer [site-defaults wrap-defaults]]))
7
+
8
+(defn wrap-base [handler]
9
+  (-> ((:middleware defaults) handler)
10
+      (wrap-defaults
11
+        (-> site-defaults
12
+            (assoc-in [:security :anti-forgery] false)
13
+            (assoc-in  [:session :store] (ttl-memory-store (* 60 30)))))))

+ 35
- 0
src/clj/email_counter/middleware/exception.clj View File

@@ -0,0 +1,35 @@
1
+(ns email-counter.middleware.exception
2
+  (:require [clojure.tools.logging :as log]
3
+            [expound.alpha :as expound]
4
+            [reitit.coercion :as coercion]
5
+            [reitit.ring.middleware.exception :as exception]))
6
+
7
+(defn render-error [printer status & [exception]]
8
+  {:status status
9
+   :headers {"Content-Type" "text/html"}
10
+   :body (with-out-str (printer (-> exception ex-data :problems)))})
11
+
12
+(defn coercion-error-handler [printer status]
13
+  (partial render-error printer status))
14
+
15
+(defn log-error [e]
16
+  (log/error e (.getMessage e)))
17
+
18
+(def exception-middleware
19
+  (let [printer (expound/custom-printer {:print-specs? false})]
20
+    (exception/create-exception-middleware
21
+      (merge
22
+        exception/default-handlers
23
+        {;; log stack-traces for all exceptions
24
+         ::exception/wrap (fn [handler e request]
25
+                            (if (= :reitit.coercion/request-coercion
26
+                                   (:type (ex-data e)))
27
+                              {:status 400
28
+                               :headers {"Content-Type" "application/edn"}
29
+                               :body (pr-str (-> e ex-data :errors))}
30
+                              (do
31
+                                (log-error e)
32
+                                (handler e request))))
33
+         ;; human-optimized validation messages
34
+         ::coercion/request-coercion (coercion-error-handler printer 400)
35
+         ::coercion/response-coercion (coercion-error-handler printer 500)}))))

+ 15
- 0
src/clj/email_counter/middleware/formats.clj View File

@@ -0,0 +1,15 @@
1
+(ns email-counter.middleware.formats
2
+  (:require
3
+    [cognitect.transit :as transit]
4
+    [luminus-transit.time :as time]
5
+    [muuntaja.core :as m]))
6
+
7
+(def instance
8
+  (m/create
9
+    (-> m/default-options
10
+        (update-in
11
+          [:formats "application/transit+json" :decoder-opts]
12
+          (partial merge time/time-deserialization-handlers))
13
+        (update-in
14
+          [:formats "application/transit+json" :encoder-opts]
15
+          (partial merge time/time-serialization-handlers)))))

+ 27
- 0
src/clj/email_counter/nrepl.clj View File

@@ -0,0 +1,27 @@
1
+(ns email-counter.nrepl
2
+  (:require
3
+    [nrepl.server :as nrepl]
4
+    [clojure.tools.logging :as log]))
5
+
6
+(defn start
7
+  "Start a network repl for debugging on specified port followed by
8
+  an optional parameters map. The :bind, :transport-fn, :handler,
9
+  :ack-port and :greeting-fn will be forwarded to
10
+  clojure.tools.nrepl.server/start-server as they are."
11
+  [{:keys [port bind transport-fn handler ack-port greeting-fn]}]
12
+  (try
13
+    (log/info "starting nREPL server on port" port)
14
+    (nrepl/start-server :port port
15
+                        :bind bind
16
+                        :transport-fn transport-fn
17
+                        :handler handler
18
+                        :ack-port ack-port
19
+                        :greeting-fn greeting-fn)
20
+
21
+    (catch Throwable t
22
+      (log/error t "failed to start nREPL")
23
+      (throw t))))
24
+
25
+(defn stop [server]
26
+  (nrepl/stop-server server)
27
+  (log/info "nREPL server stopped"))

+ 58
- 0
src/clj/email_counter/routes/services.clj View File

@@ -0,0 +1,58 @@
1
+(ns email-counter.routes.services
2
+  (:require
3
+    [email-counter.emails :as emails]
4
+    [email-counter.middleware.formats :as formats]
5
+    [email-counter.middleware.exception :as exception]
6
+    [reitit.coercion.schema :as schema-coercion]
7
+    [reitit.ring.coercion :as coercion]
8
+    [reitit.ring.middleware.multipart :as multipart]
9
+    [reitit.ring.middleware.muuntaja :as muuntaja]
10
+    [reitit.ring.middleware.parameters :as parameters]
11
+    [reitit.swagger :as swagger]
12
+    [reitit.swagger-ui :as swagger-ui]
13
+    [ring.util.http-response :refer :all]
14
+    [schema.core :as s]))
15
+
16
+(def NaturalNumber (s/constrained s/Int (complement neg?) 'NaturalNumber))
17
+
18
+(def EmailAddress (s/constrained s/Str emails/parse-for-gmail 'EmailAddress))
19
+
20
+(defn count-emails [{{{:keys [emails]} :body} :parameters}]
21
+  (ok {:total (count (emails/unique-gmail-parsed-emails emails))}))
22
+
23
+(defn service-routes []
24
+  ["/api"
25
+   {:coercion schema-coercion/coercion
26
+    :muuntaja formats/instance
27
+    :swagger {:id ::api}
28
+    :middleware [parameters/parameters-middleware
29
+                 muuntaja/format-negotiate-middleware
30
+                 muuntaja/format-response-middleware
31
+                 exception/exception-middleware
32
+                 muuntaja/format-request-middleware
33
+                 coercion/coerce-response-middleware
34
+                 coercion/coerce-request-middleware
35
+                 multipart/multipart-middleware]}
36
+   ["" {:no-doc true
37
+        :swagger {:info {:title "email-count"}}}
38
+
39
+    ["/swagger.json"
40
+     {:get (swagger/create-swagger-handler)}]
41
+
42
+    ["/api-docs/*"
43
+     {:get (swagger-ui/create-swagger-ui-handler
44
+             {:url "/api/swagger.json"
45
+              :config {:validator-url nil}})}]]
46
+   ["/ping"
47
+    {:get (constantly (ok {:message "pong"}))}]
48
+
49
+   ["/v1"
50
+    ["/emailcount"
51
+     {:post {:summary "plus with body parameters"
52
+             :description (str "Given a list of email addresses, count how "
53
+                               "many unique email addresses show up, after "
54
+                               "accounting for Gmail's unique approach to "
55
+                               "parsing.")
56
+             :parameters {:body {:emails [EmailAddress]}}
57
+             :responses {200 {:body {:total NaturalNumber}}}
58
+             :handler count-emails}}]]])

+ 31
- 0
test/clj/email_counter/test/emails.clj View File

@@ -0,0 +1,31 @@
1
+(ns email-counter.test.emails
2
+  (:require [clojure.test :refer :all]
3
+            [email-counter.emails :refer :all]))
4
+
5
+(deftest parse-email-test
6
+  (are [expected given] (= expected (parse-email given))
7
+    nil nil
8
+    nil ""
9
+    "me@here.com" "me@here.com "
10
+    "m.e@here.com" "\n\t m.e@here.com"
11
+    "me@here.com" "Me <me@here.com>"))
12
+
13
+(deftest parse-for-gmail-test
14
+  (are [expected given] (= expected (parse-for-gmail given))
15
+    nil nil
16
+    nil ""
17
+    "me@here.com" "me@here.com"
18
+    "me@here.com" "m.e@here.com"
19
+    "me@here.com" "Me <me@here.com>"))
20
+
21
+(deftest unique-gmail-parsed-emails-test
22
+  (is (= #{"me@here.com"
23
+           "you@there.com"}
24
+         (unique-gmail-parsed-emails
25
+           ["me@here.com"
26
+            "m.e@here.com"
27
+            "Me <me@here.com>"
28
+            "Me <ME@HERE.COM>"
29
+            "you@there.com"
30
+            ""
31
+            nil]))))

+ 70
- 0
test/clj/email_counter/test/handler.clj View File

@@ -0,0 +1,70 @@
1
+(ns email-counter.test.handler
2
+  (:require
3
+    [clojure.test :refer :all]
4
+    [ring.mock.request :refer :all]
5
+    [email-counter.handler :refer :all]
6
+    [muuntaja.core :as m]
7
+    [mount.core :as mount]
8
+    [clojure.string :as str]
9
+    [email-counter.middleware.exception :as exception]))
10
+
11
+(use-fixtures
12
+  :once
13
+  (fn [f]
14
+    (mount/start #'email-counter.config/env
15
+                 #'email-counter.handler/app-routes)
16
+    (f)
17
+    (mount/stop)))
18
+
19
+(defn decode-response-body [{{:strs [Content-Type]} :headers :as response}]
20
+  (case (first (str/split Content-Type #";"))
21
+    "application/json"
22
+    (m/decode-response-body response)
23
+
24
+    "application/edn"
25
+    (:body response)
26
+
27
+    (pr-str response)))
28
+
29
+(defn post-body [url body]
30
+  (let [response ((app) (-> (request :post url)
31
+                            (json-body body)))]
32
+    (-> response
33
+        (assoc :body (decode-response-body response))
34
+        (dissoc :headers))))
35
+
36
+(defn request-emails [emails]
37
+  (post-body "/api/v1/emailcount" {:emails emails}))
38
+
39
+(defn print-exceptions [exceptions-encountered]
40
+  (doseq [e @exceptions-encountered]
41
+    (.printStackTrace e)))
42
+
43
+(deftest email-count
44
+  (let [exceptions-encountered (atom [])
45
+        print-exceptions (partial print-exceptions exceptions-encountered)]
46
+    (with-redefs [exception/log-error
47
+                  (fn [e]
48
+                    (swap! exceptions-encountered conj e))]
49
+      (testing "emailcount"
50
+        (is (= {:status 200, :body {:total 2}}
51
+               (request-emails ["test.email@gmail.com"
52
+                                "test.email+spam@gmail.com"
53
+                                "Test Email <TestEmail@gmail.com>"
54
+                                "scstarkey@gmail.com"]))))
55
+
56
+      (testing "invalid email address"
57
+        (is (= {:status 400,
58
+                :body "{:emails [(not (EmailAddress \"busted\"))]}"}
59
+               (request-emails ["busted"]))))
60
+
61
+      (testing "invalid request"
62
+        (is (= {:status 400,
63
+                :body "{:emails missing-required-key, :test disallowed-key}"}
64
+               (post-body "/api/v1/emailcount" {:test "stuff"}))))
65
+
66
+      (testing "no exceptions were actually encountered"
67
+        (is (= 0 (count @exceptions-encountered))
68
+            (print-exceptions))))))
69
+
70
+(clojure.test/run-tests)