Refactor: split into multiple files

Given the size of the assignment, having everything in the same file was
OK. However, splitting into different files is a good option for better
maintainance once this API grows, and now is a good time to do it before
it gets more complicated.
This commit is contained in:
2026-02-01 03:06:00 +01:00
parent 1f62fe6f66
commit b78b8cca41
10 changed files with 254 additions and 227 deletions

View File

@@ -15,12 +15,26 @@ This project uses Leiningen. Assuming you already have Leiningen installed,
running the API should be as easy as cloning the repo and issuing `lein run`. By
default, the server listens on port 3000.
## Structure
The source code lies in `/src/yohoho` and is structured as follows:
* `app.clj`: ring app and main entrypoint
* `config.clj`: configuration (database and HTTP server)
* `db.clj`: functions that directly interact with the database
* `handlers.clj`: route handlers
* `helpers.clj`: the infamous file that holds function that are useful but
couldn't fit anywhere else
* `routes.clj`: API routes
* `schemas.cls`: Malli schemas used for validation
## Library choices
* `reitit`: for handling routes
* `jetty`: web server
* `muuntaja`: JSON handling
* `next.jdbc`: database interface (SQLite)
* `malli`: validation
## Documentation links

View File

@@ -13,7 +13,7 @@
[metosin/muuntaja "0.6.11"]
[com.github.seancorfield/next.jdbc "1.3.1086"]
[org.xerial/sqlite-jdbc "3.51.1.0"]]
:main ^:skip-aot yohoho.core
:main ^:skip-aot yohoho.app
:target-path "target/%s"
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}})

58
src/yohoho/app.clj Normal file
View File

@@ -0,0 +1,58 @@
(ns yohoho.app
(:require [reitit.ring :as ring]
[ring.adapter.jetty :as http-server]
[reitit.ring.middleware.muuntaja :as muuntaja]
[muuntaja.core :as m]
[reitit.coercion.malli :as malli]
[reitit.ring.coercion :as coercion]
[reitit.openapi :as openapi]
[reitit.swagger-ui :as swagger-ui]
[yohoho.config :refer [config]]
[yohoho.db :as db]
[yohoho.helpers :refer [post-payload-content-type-json-middleware]]
[yohoho.routes :refer [api-routes]])
(:gen-class))
(def app
(ring/ring-handler
(ring/router
(conj api-routes
;; Add OpenAPI description to the default API routes
["/openapi.json" {:get (openapi/create-openapi-handler)
:no-doc true
:openapi {:info {:title "Yo-ho-ho API"
:description "A take-home assignment for HolidayPirates"
:version "0.42.0"}}}])
;; Middlewares:
;; - wrap-content-type-json: ensure POST routes are sent JSON payload
;; - muuntaja middleware to automatically decode JSON
;; - malli + coercion: handle data validation
{:data {:coercion malli/coercion
:muuntaja m/instance
:middleware [muuntaja/format-middleware
post-payload-content-type-json-middleware
coercion/coerce-exceptions-middleware
coercion/coerce-request-middleware
coercion/coerce-response-middleware]}})
(ring/routes
;; Add Swagger UI
(swagger-ui/create-swagger-ui-handler {:path "/doc" :url "/openapi.json"})
;; Default route: anything not explicitely handled should give a 404
(ring/create-default-handler
{:not-found (constantly {:status 404
:body {:error "Not Found"}})}))))
(defn -main
"HolidayPirates take-home assignement.
Goal: build a (very) small REST API that exposes to endpoints."
[& args]
(let [{{port :port} :server} config]
(println "Initializing SQLite database...")
(db/init!)
(println "Ahoy! Yo-ho-ho API starting on port" port)
(println "API documentation on /openapi.json and on /doc (interactive UI)")
(http-server/run-jetty app {:port port})))

6
src/yohoho/config.clj Normal file
View File

@@ -0,0 +1,6 @@
(ns yohoho.config
"Yo-ho-ho API config")
(def config
{:db {:dbtype "sqlite" :dbname "yohoho.db"}
:server {:port 3000}})

View File

@@ -1,226 +0,0 @@
(ns yohoho.core
(:require [reitit.ring :as ring]
[ring.adapter.jetty :as http-server]
[reitit.ring.middleware.muuntaja :as muuntaja]
[muuntaja.core :as m]
[reitit.coercion.malli :as malli]
[reitit.ring.coercion :as coercion]
[reitit.openapi :as openapi]
[reitit.swagger-ui :as swagger-ui]
[next.jdbc :as jdbc]
[next.jdbc.sql :as sql]
[next.jdbc.result-set :as rs])
(:gen-class))
;; Configuration --------------------------------------------------
(def config
{:db {:dbtype "sqlite" :dbname "yohoho.db"}
:server {:port 3000}})
;; Data schema ----------------------------------------------------
;; Malli schemas for validation
;; Validating an email address is hard.
;; Regexp shamelessly taken from https://emailregex.com/
(def email-regexp #"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])")
(def Email [:re email-regexp])
(def ItemInput
[:map
[:name :string]
[:email Email]])
(def Item
[:map
[:id :int]
[:name :string]
[:email Email]])
(def ItemResponse Item)
(def ItemsResponse
[:map
[:items [:vector Item]]])
(def ErrorResponse
[:map
[:error :string]
[:message {:optional true} :string]])
;; Those are generated by Malli when validation fails
;; TODO: find a way to produce lighter messages on validation error
;; (maybe a custom middleware?)
(def Error400Response
[:map
[:value :map]
[:type :string]
[:coercion :string]
[:in [:vector :string]]
[:humanized :map]])
;; Database stuff -------------------------------------------------
(def db (:db config))
(defn init-db!
"Create database structure if needed"
[]
(jdbc/execute!
db
["CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL)"]))
(defn db-store-item!
"Store a new item in database and returns it ID"
[name email]
(let [db-result (sql/insert! db :items {:name name, :email email})]
;; SQLite returns the generated ID of the new item like this:
;; {:last_insert_rowid() 42}
(get db-result (keyword "last_insert_rowid()"))))
(defn db-get-item
"Retrieve an item given its ID"
[id]
;; sql/* functions default to prefixing map keys with `#:items/`. Meh.
;; Fortunately, builder-fn saves the day so we can have the output we want.
(sql/get-by-id db :items id
{:builder-fn rs/as-unqualified-lower-maps}))
(defn db-get-all-items
"Retrieve every item from the database"
[]
(sql/query db ["select * from items"] {:builder-fn rs/as-unqualified-lower-maps}))
;; Handlers -------------------------------------------------------
(defn ahoy-handler
"Health check"
[_]
{:status 200
:body "Ahoy mate, the ship be sailin' alright"})
(defn get-item-handler
"Return the item whose id is given as a path parameter
If it does not exist, HTTP 404 it is."
[request]
(let [id (get-in request [:path-params :id])
item (db-get-item id)]
(cond
(nil? item) {:status 404 :body {:error "Not Found"}}
:else {:status 200 :body item})))
(defn get-items-handler
"Return the list of every item
TODO: Add pagination"
[_]
{:status 200
:body {:items (db-get-all-items)}})
(defn create-item-handler
"Create a new item from request payload
Item id is autogenerated by the storage backend (SQLite)
Return the complete new item, id included."
[request]
(let [item (:body-params request)
name (:name item)
email (:email item)
id (db-store-item! name email)]
{:status 201
:headers {"Location" (str "/item/" id)}
:body (db-get-item id)}))
;; Custom middleware ----------------------------------------------
(defn wrap-content-type-json
"Middleware that checks is a POST request is bring sent data as JSON"
[handler]
(fn [request]
(let [http-verb (:request-method request)
is-post? (= :post http-verb)
content-type (get-in request [:headers "content-type"])
is-json? (= "application/json" content-type)]
(cond (not is-post?) (handler request)
(and is-post? is-json?) (handler request)
:else {:status 415
:body {:error "Unsupported Media Type"
:message "Content-Type must be application/json"}}))))
;; Routing --------------------------------------------------------
(def app
(ring/ring-handler
(ring/router
[;; Health check
["/ahoy" {:get {:summary "Health check"
:openapi {:tags ["Health"]}
:handler ahoy-handler
:responses {200 {:body :string
:description "Server is alive"}}}}]
;; The real assignment: create and retrieve items
["/item/:id" {:get {:summary "Get an item by id"
:openapi {:tags ["Items"]}
:handler get-item-handler
:parameters {:path [:map [:id :int]]}
:responses {200 {:body ItemResponse
:description "Item found"}
400 {:body Error400Response
:description "Invalid input"}
404 {:body ErrorResponse
:description "Item not found"}}}}]
["/items" {:get {:summary "Get all items"
:openapi {:tags ["Items"]}
:handler get-items-handler
:responses {200 {:body ItemsResponse ;; TODO!
:description "List of all items"}}}
:post {:summary "Create an item"
:openapi {:tags ["Items"]}
:handler create-item-handler
:parameters {:body ItemInput}
:responses {201 {:body Item
:description "Item created successfully"}
400 {:body Error400Response
:description "Invalid input"}
415 {:body ErrorResponse
:description "Unsupported media type"}}}}]
;; OpenAPI routes
["" {:no-doc true
:openapi {:info {:title "Yo-ho-ho API"
:description "A take-home assignment for HolidayPirates"
:version "0.42.0"}}}
["/openapi.json" {:get (openapi/create-openapi-handler)}]
["/doc/*" {:get (swagger-ui/create-swagger-ui-handler {:url "/openapi.json"})}]]]
;; Middlewares:
;; - wrap-content-type-json: ensure POST routes are sent JSON payload
;; - muuntaja middleware to automatically decode JSON
;; - malli + coercion: handle data validation
{:data {:coercion malli/coercion
:muuntaja m/instance
:middleware [wrap-content-type-json
muuntaja/format-middleware
coercion/coerce-exceptions-middleware
coercion/coerce-request-middleware
coercion/coerce-response-middleware]}})
(ring/routes
;; Add trailing slash when missing, eg. for /doc
(ring/redirect-trailing-slash-handler {:method :add})
;; Default route: anything not explicitely handled should give a 404
(ring/create-default-handler
{:not-found (constantly {:status 404
:body {:error "Not Found"}})}))))
;; Entry point ----------------------------------------------------
(defn -main
"HolidayPirates take-home assignement.
Goal: build a (very) small REST API that exposes to endpoints."
[& args]
(let [{{port :port} :server} config]
(println "Initializing SQLite database...")
(init-db!)
(println "Ahoy! Yo-ho-ho API starting on port" port)
(println "API documentation on /openapi.json and on /doc (interactive UI)")
(http-server/run-jetty app {:port port})))

39
src/yohoho/db.clj Normal file
View File

@@ -0,0 +1,39 @@
(ns yohoho.db
"Database related functions"
(:require [next.jdbc :as jdbc]
[next.jdbc.sql :as sql]
[next.jdbc.result-set :as rs]
[yohoho.config :refer [config]]))
(def db (:db config))
(defn init!
"Create database structure if needed"
[]
(jdbc/execute!
db
["CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL)"]))
(defn store-item!
"Store a new item in database and returns it ID"
[name email]
(let [db-result (sql/insert! db :items {:name name, :email email})]
;; SQLite returns the generated ID of the new item like this:
;; {:last_insert_rowid() 42}
(get db-result (keyword "last_insert_rowid()"))))
(defn get-item
"Retrieve an item given its ID"
[id]
;; sql/* functions default to prefixing map keys with `#:items/`. Meh.
;; Fortunately, builder-fn saves the day so we can have the output we want.
(sql/get-by-id db :items id
{:builder-fn rs/as-unqualified-lower-maps}))
(defn get-all-items
"Retrieve every item from the database"
[]
(sql/query db ["select * from items"] {:builder-fn rs/as-unqualified-lower-maps}))

40
src/yohoho/handlers.clj Normal file
View File

@@ -0,0 +1,40 @@
(ns yohoho.handlers
"Route handlers for the Yo-ho-ho API"
(:require [yohoho.db :as db]))
(defn ahoy
"Health check"
[_]
{:status 200
:body "Ahoy mate, the ship be sailin' alright"})
(defn get-item
"Return the item whose id is given as a path parameter
If it does not exist, HTTP 404 it is."
[request]
(let [id (get-in request [:path-params :id])
item (db/get-item id)]
(cond
(nil? item) {:status 404 :body {:error "Not Found"}}
:else {:status 200 :body item})))
(defn get-items
"Return the list of every item
TODO: Add pagination"
[_]
{:status 200
:body {:items (db/get-all-items)}})
(defn create-item
"Create a new item from request payload
Item id is autogenerated by the storage backend (SQLite)
Return the complete new item, id included."
[request]
(let [item (:body-params request)
name (:name item)
email (:email item)
id (db/store-item! name email)]
{:status 201
:headers {"Location" (str "/item/" id)}
:body (db/get-item id)}))

17
src/yohoho/helpers.clj Normal file
View File

@@ -0,0 +1,17 @@
(ns yohoho.helpers
"Helper functions that do not fit elsewhere (yet)")
(defn post-payload-content-type-json-middleware
"Middleware that checks is a POST request is being sent data as JSON"
[handler]
(fn [request]
(let [http-verb (:request-method request)
is-post? (= :post http-verb)
content-type (get-in request [:headers "content-type"])
is-json? (= "application/json" content-type)]
(cond (not is-post?) (handler request)
(and is-post? is-json?) (handler request)
:else {:status 415
:body {:error "Unsupported Media Type"
:message "Content-Type must be application/json"}}))))

38
src/yohoho/routes.clj Normal file
View File

@@ -0,0 +1,38 @@
(ns yohoho.routes
"Routes for the Yo-ho-ho API"
(:require [yohoho.handlers :as handler]
[yohoho.schemas :as s]))
(def api-routes
[;; Health check
["/ahoy" {:get {:summary "Health check"
:openapi {:tags ["Health"]}
:handler handler/ahoy
:responses {200 {:body :string
:description "Server is alive"}}}}]
;; The real assignment: create and retrieve items
["/item/:id" {:get {:summary "Get an item by id"
:openapi {:tags ["Items"]}
:handler handler/get-item
:parameters {:path [:map [:id :int]]}
:responses {200 {:body s/ItemResponse
:description "Item found"}
400 {:body s/Error400Response
:description "Invalid input"}
404 {:body s/ErrorResponse
:description "Item not found"}}}}]
["/items" {:get {:summary "Get all items"
:openapi {:tags ["Items"]}
:handler handler/get-items
:responses {200 {:body s/ItemsResponse ;; TODO!
:description "List of all items"}}}
:post {:summary "Create an item"
:openapi {:tags ["Items"]}
:handler handler/create-item
:parameters {:body s/ItemInput}
:responses {201 {:body s/Item
:description "Item created successfully"}
400 {:body s/Error400Response
:description "Invalid input"}
415 {:body s/ErrorResponse
:description "Unsupported media type"}}}}]])

41
src/yohoho/schemas.clj Normal file
View File

@@ -0,0 +1,41 @@
(ns yohoho.schemas
"Malli schemas for validation")
;; Validating an email address is hard.
;; Regexp shamelessly taken from https://emailregex.com/
(def email-regexp #"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])")
(def Email [:re email-regexp])
(def ItemInput
[:map
[:name :string]
[:email Email]])
(def Item
[:map
[:id :int]
[:name :string]
[:email Email]])
(def ItemResponse Item)
(def ItemsResponse
[:map
[:items [:vector Item]]])
(def ErrorResponse
[:map
[:error :string]
[:message {:optional true} :string]])
;; Error400Response mimicks what is generated by Malli when validation fails
;; TODO: find a way to produce lighter messages on validation error
;; (maybe a custom middleware?)
(def Error400Response
[:map
[:value :map]
[:type :string]
[:coercion :string]
[:in [:vector :string]]
[:humanized :map]])