From b78b8cca411ec561407e7bc5d4ec8c5546c8ad3b Mon Sep 17 00:00:00 2001 From: Benjamin Sigonneau Date: Sun, 1 Feb 2026 03:06:00 +0100 Subject: [PATCH] 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. --- README.md | 14 +++ project.clj | 2 +- src/yohoho/app.clj | 58 +++++++++++ src/yohoho/config.clj | 6 ++ src/yohoho/core.clj | 226 ---------------------------------------- src/yohoho/db.clj | 39 +++++++ src/yohoho/handlers.clj | 40 +++++++ src/yohoho/helpers.clj | 17 +++ src/yohoho/routes.clj | 38 +++++++ src/yohoho/schemas.clj | 41 ++++++++ 10 files changed, 254 insertions(+), 227 deletions(-) create mode 100644 src/yohoho/app.clj create mode 100644 src/yohoho/config.clj delete mode 100644 src/yohoho/core.clj create mode 100644 src/yohoho/db.clj create mode 100644 src/yohoho/handlers.clj create mode 100644 src/yohoho/helpers.clj create mode 100644 src/yohoho/routes.clj create mode 100644 src/yohoho/schemas.clj diff --git a/README.md b/README.md index 8f8a55a..2baadc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/project.clj b/project.clj index 17eef23..cea8d79 100644 --- a/project.clj +++ b/project.clj @@ -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"]}}) diff --git a/src/yohoho/app.clj b/src/yohoho/app.clj new file mode 100644 index 0000000..ec3a5a1 --- /dev/null +++ b/src/yohoho/app.clj @@ -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}))) diff --git a/src/yohoho/config.clj b/src/yohoho/config.clj new file mode 100644 index 0000000..b32c179 --- /dev/null +++ b/src/yohoho/config.clj @@ -0,0 +1,6 @@ +(ns yohoho.config + "Yo-ho-ho API config") + +(def config + {:db {:dbtype "sqlite" :dbname "yohoho.db"} + :server {:port 3000}}) diff --git a/src/yohoho/core.clj b/src/yohoho/core.clj deleted file mode 100644 index b360c32..0000000 --- a/src/yohoho/core.clj +++ /dev/null @@ -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}))) diff --git a/src/yohoho/db.clj b/src/yohoho/db.clj new file mode 100644 index 0000000..1dcbd21 --- /dev/null +++ b/src/yohoho/db.clj @@ -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})) diff --git a/src/yohoho/handlers.clj b/src/yohoho/handlers.clj new file mode 100644 index 0000000..103c15a --- /dev/null +++ b/src/yohoho/handlers.clj @@ -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)})) diff --git a/src/yohoho/helpers.clj b/src/yohoho/helpers.clj new file mode 100644 index 0000000..f7b9833 --- /dev/null +++ b/src/yohoho/helpers.clj @@ -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"}})))) \ No newline at end of file diff --git a/src/yohoho/routes.clj b/src/yohoho/routes.clj new file mode 100644 index 0000000..56ba665 --- /dev/null +++ b/src/yohoho/routes.clj @@ -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"}}}}]]) diff --git a/src/yohoho/schemas.clj b/src/yohoho/schemas.clj new file mode 100644 index 0000000..1a3a37f --- /dev/null +++ b/src/yohoho/schemas.clj @@ -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]])