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:
14
README.md
14
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
|
running the API should be as easy as cloning the repo and issuing `lein run`. By
|
||||||
default, the server listens on port 3000.
|
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
|
## Library choices
|
||||||
|
|
||||||
* `reitit`: for handling routes
|
* `reitit`: for handling routes
|
||||||
* `jetty`: web server
|
* `jetty`: web server
|
||||||
* `muuntaja`: JSON handling
|
* `muuntaja`: JSON handling
|
||||||
* `next.jdbc`: database interface (SQLite)
|
* `next.jdbc`: database interface (SQLite)
|
||||||
|
* `malli`: validation
|
||||||
|
|
||||||
## Documentation links
|
## Documentation links
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
[metosin/muuntaja "0.6.11"]
|
[metosin/muuntaja "0.6.11"]
|
||||||
[com.github.seancorfield/next.jdbc "1.3.1086"]
|
[com.github.seancorfield/next.jdbc "1.3.1086"]
|
||||||
[org.xerial/sqlite-jdbc "3.51.1.0"]]
|
[org.xerial/sqlite-jdbc "3.51.1.0"]]
|
||||||
:main ^:skip-aot yohoho.core
|
:main ^:skip-aot yohoho.app
|
||||||
:target-path "target/%s"
|
:target-path "target/%s"
|
||||||
:profiles {:uberjar {:aot :all
|
:profiles {:uberjar {:aot :all
|
||||||
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}})
|
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}})
|
||||||
|
|||||||
58
src/yohoho/app.clj
Normal file
58
src/yohoho/app.clj
Normal 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
6
src/yohoho/config.clj
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
(ns yohoho.config
|
||||||
|
"Yo-ho-ho API config")
|
||||||
|
|
||||||
|
(def config
|
||||||
|
{:db {:dbtype "sqlite" :dbname "yohoho.db"}
|
||||||
|
:server {:port 3000}})
|
||||||
@@ -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
39
src/yohoho/db.clj
Normal 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
40
src/yohoho/handlers.clj
Normal 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
17
src/yohoho/helpers.clj
Normal 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
38
src/yohoho/routes.clj
Normal 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
41
src/yohoho/schemas.clj
Normal 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]])
|
||||||
Reference in New Issue
Block a user