diff --git a/.github/workflows/faas_fn_build_invoke.yml b/.github/workflows/faas_fn_build_invoke.yml index 7cc1690..45e6461 100644 --- a/.github/workflows/faas_fn_build_invoke.yml +++ b/.github/workflows/faas_fn_build_invoke.yml @@ -62,13 +62,13 @@ jobs: (docker stop bb-http-map || exit 0) (docker rm bb-http-map || exit 0) docker run -d --name bb-http-map ghcr.io/${{ github.repository_owner }}/bb-http-map:latest bb --main index - if [ "$(docker exec bb-http-map curl -X POST -d '{"foo": "bar", "spam": "eggs"}' -H 'Content-Type: application/json' --retry 3 --retry-delay 2 --retry-connrefused http://127.0.0.1:8082)" != '[["foo","spam"],["bar","eggs"]]' ]; then + if [ "$(docker exec bb-http-map curl -X POST -d '{"foo": "bar", "spam": "eggs"}' -H 'content-type: application/json' --retry 3 --retry-delay 2 --retry-connrefused http://127.0.0.1:8082)" != '[["foo","spam"],["bar","eggs"]]' ]; then exit 6 fi (docker stop bb-http-map-context || exit 0) (docker rm bb-http-map-context || exit 0) docker run -d --name bb-http-map-context ghcr.io/${{ github.repository_owner }}/bb-http-map-context:latest bb --main index - if [ "$(docker exec bb-http-map-context curl -X POST -d '{"foo": "bar", "spam": "eggs"}' -H 'Content-Type: application/json' --retry 3 --retry-delay 2 --retry-connrefused http://127.0.0.1:8082)" != '[["foo","spam"],["bar","eggs"],"application/json","http://127.0.0.1:8082"]' ]; then + if [ "$(docker exec bb-http-map-context curl -X POST -d '{"foo": "bar", "spam": "eggs"}' -H 'content-type: application/json' --retry 3 --retry-delay 2 --retry-connrefused http://127.0.0.1:8082)" != '[["foo","spam"],["bar","eggs"],"application/json","http://127.0.0.1:8082"]' ]; then exit 7 fi diff --git a/README.adoc b/README.adoc index e902f90..8c7fe44 100644 --- a/README.adoc +++ b/README.adoc @@ -48,33 +48,36 @@ Although the Function namespace is called `function.handler`, the files themselv my-function: handler: ./anything/my-function ... ----- +---- -The `handler` function can be defined with one or two arguments. +=== Defining Function handler -Defining `handler` with one argument containing the request payload: +In `bb` language: [source, clojure] ---- -This works for both `bb` and `bb-streaming` languages. - -(defn handler [payload] ...) +(defn handler [{:keys [headers body context] :as event}] + ...) ---- -Defining `handler` with two arguments containing the request payload and context: +`event` is a map containing `:headers`, `:body` and `:context` keys. + +`:headers` contains headers, as such: [source, clojure] ---- -(defn handler [payload context] ...) +{:content-type "application/json"} ---- -This works for `bb` language only. - -The `context` will contain a map of `:headers` and `:env` keys. -The `:headers` key will contain a map of the request headers, as such: -[source, clojure] +`:body` is the payload body. When passing a JSON payload and using `bb` language, the payload will be automatically parsed as a Clojure map with keyword keys. There are cases where it's preferable to have string keys in the payload body, and it's possible to support them by setting `keywords: false` in the Function in `stack.yml`: +[source, yml] ---- -{:content-type "application/json"} +my-function: + lang: bb + handler: ./anything/my-function + image: ${DOCKER_REGISTRY_IMG_ORG_PATH}/my-function + environment: + keywords: false ---- -The `:env` map contains a map with the environment variables. Additional environment variables can be defined in the `stack.yml` file, as such: +`:context` contains environment variables. Additional environment variables can be defined in the `stack.yml` file, as such: [source, yml] ---- my-function: @@ -85,23 +88,19 @@ my-function: MY_ENV1: foo MY_ENV2: 2 ---- -The `:env` key will contain: +`:context` will contain: [source, clojure] ---- {:my-env1 "foo" :my-env2 2} ---- -When passing a JSON payload and using `bb` language, the payload will be automatically parsed as a Clojure map with keyword keys. There are cases where string keys are preferable, and it's possible to support them by setting `keywords: false` in the Function in `stack.yml`: -[source, yml] +In `bb-streaming` language: +[source, clojure] ---- -my-function: - lang: bb - handler: ./anything/my-function - image: ${DOCKER_REGISTRY_IMG_ORG_PATH}/my-function - environment: - keywords: false +(defn handler [event] ...) ---- +The `event` is the payload body, and the function return is the payload body for the response. == link:examples[Function examples] @@ -132,7 +131,14 @@ The template may benefit from some common middleware functions, such as those of == Third party code -link:template/bb/ring/middleware/json.clj[ring.middleware.json] is derived from https://github.com/ring-clojure/ring-json/blob/master/src/ring/middleware/json.clj[ring-son] to work with Babashka, originally authored by James Reeves and used under the MIT license. +The following files are derived from https://github.com/ring-clojure[ring] to work with Babashka, originally authored by James Reeves and contributors, and used under the MIT license: + +- link:template/bb/lib/ring/middleware/json.clj[ring.middleware.json] +- link:template/bb/lib/ring/util/io.clj[ring.util.io] +- link:template/bb/lib/ring/util/mime_type.clj[ring.util.mime-type] +- link:template/bb/lib/ring/util/parsing.clj[ring.util.parsing] +- link:template/bb/lib/ring/util/response.clj[ring.util.response] +- link:template/bb/lib/ring/util/time.clj[ring.util.time] == link:LICENSE[License] diff --git a/examples/http/bb-hello/handler.clj b/examples/http/bb-hello/handler.clj index a94951d..4f19cf0 100644 --- a/examples/http/bb-hello/handler.clj +++ b/examples/http/bb-hello/handler.clj @@ -1,4 +1,4 @@ (ns function.handler) (defn handler [{:keys [body]}] - {:body (str "Hello, " (slurp body))}) + {:body (str "Hello, " body)}) diff --git a/examples/http/bb-map-context/handler.clj b/examples/http/bb-map-context/handler.clj index 2a54258..52c29e1 100644 --- a/examples/http/bb-map-context/handler.clj +++ b/examples/http/bb-map-context/handler.clj @@ -1,4 +1,4 @@ (ns function.handler) -(defn handler [content {:keys [headers env]}] - [(keys content) (vals content) (:content-type headers) (:upstream-url env)]) +(defn handler [{:keys [body headers context]}] + [(keys body) (vals body) (:content-type headers) (:upstream-url context)]) diff --git a/examples/http/bb-map/handler.clj b/examples/http/bb-map/handler.clj index 3df3031..5a97313 100644 --- a/examples/http/bb-map/handler.clj +++ b/examples/http/bb-map/handler.clj @@ -1,4 +1,4 @@ (ns function.handler) -(defn handler [content] - [(keys content) (vals content)]) +(defn handler [{:keys [body]}] + [(keys body) (vals body)]) diff --git a/template/bb/Dockerfile b/template/bb/Dockerfile index 33c31dc..eaf1dbb 100644 --- a/template/bb/Dockerfile +++ b/template/bb/Dockerfile @@ -19,10 +19,10 @@ USER app WORKDIR $HOME COPY index.clj function/bb.edn ./ -COPY ring ./ring +COPY lib ./ COPY function function -RUN bb prepare +RUN bb --deps-root function prepare && bb --deps-root function print-deps ENV mode="http" ENV upstream_url="http://127.0.0.1:8082" diff --git a/template/bb/bb.edn b/template/bb/bb.edn index 3592389..8a951cf 100644 --- a/template/bb/bb.edn +++ b/template/bb/bb.edn @@ -1,2 +1,2 @@ -{:paths ["."] +{:paths ["." "lib"] :deps {eg/eg {:mvn/version "0.5.6-alpha"}}} diff --git a/template/bb/index.clj b/template/bb/index.clj index eb7dd23..349876e 100755 --- a/template/bb/index.clj +++ b/template/bb/index.clj @@ -1,58 +1,34 @@ -(ns index ^{:author "Carlos da Cunha Fontes" - :url "https://github.com/ccfontes/faas-bb" - :license {:name "Distributed under the MIT License" - :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} +(ns index + ^{:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} (:require - [clojure.walk :refer [keywordize-keys]] - [clojure.string :as str :refer [lower-case]] - [clojure.edn :as edn] [org.httpkit.server :refer [run-server]] - [ring.middleware.json :as json-middleware] + [ring.middleware.json :refer [wrap-json-body]] + [ring.middleware.text :refer [wrap-text-body]] + [ring.middleware.headers :refer [wrap-lowercase-headers wrap-friendly-headers]] + [ring.middleware.headers] + [ring.util.walk :as ring-walk] + [compojure.response :as response] [function.handler :as function])) -(defn read-string [s] - (try - (let [res (edn/read-string s)] - (if (symbol? res) (str res) res)) - (catch Exception _ - s))) +(def keywords? #(if (nil? %) true %)) -(defn keywords? [env-val] - (if-some [keywords (edn/read-string env-val)] - keywords - true)) +(defn ->handler [f env] + (fn [request] + (response/render f + (assoc request :context env)))) -(defn ->kebab-case [s] - (lower-case (str/replace s #"_" "-"))) - -(def fn-arg-cnt (comp count first :arglists meta)) - -(defn format-context [m] - (->> m - (map (fn [[k v]] [(->kebab-case k) (read-string v)])) - (into {}) - (keywordize-keys))) - -(defn ->context [headers env] - {:headers (format-context headers) - :env (format-context env)}) - -(def response {:status 200}) - -(defn ->handler [f-var env] - (let [f (var-get f-var) - faas-fn (case (fn-arg-cnt f-var) - 1 (comp f :body) - 2 #(f (:body %) (->context (:headers %) env)))] - (fn [request] - (merge {:body (faas-fn request)} response)))) - -(defn ->app [f-var env] - (-> (->handler f-var env) - (json-middleware/wrap-json-body {:keywords? (keywords? (get env "keywords"))}) - (json-middleware/wrap-json-response))) +(defn ->app [f env] + (-> (->handler f env) + (wrap-friendly-headers) + (wrap-text-body) + (wrap-json-body {:keywords? (-> env :keywords keywords?)}) + (wrap-lowercase-headers))) (defn -main [] - (run-server (->app #'function/handler (System/getenv)) - {:port 8082}) - @(promise)) + (let [env (ring-walk/format-context (System/getenv))] + (run-server (->app function/handler env) + {:port 8082}) + @(promise))) diff --git a/template/bb/lib/compojure/response.clj b/template/bb/lib/compojure/response.clj new file mode 100644 index 0000000..e4f28a7 --- /dev/null +++ b/template/bb/lib/compojure/response.clj @@ -0,0 +1,80 @@ +(ns compojure.response + "A protocol for generating Ring response maps" + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/weavejester/compojure" + :license {:name "Distributed under the MIT License, the same as Ring."}} + (:refer-clojure :exclude [send]) + (:require [ring.util.mime-type :as mime] + [ring.util.response :as response])) + +(defprotocol Renderable + "A protocol that tells Compojure how to handle the return value of routes + defined by [[GET]], [[POST]], etc. + + This protocol supports rendering strings, maps, functions, refs, files, seqs, + input streams and URLs by default, and may be extended to cover many custom + types." + (render [x request] + "Render `x` into a form suitable for the given request map.")) + +(defprotocol Sendable + "A protocol that tells Compojure how to handle the return value of + asynchronous routes, should they require special attention." + (send* [x request respond raise])) + +(defn send + "Send `x` as a Ring response. Checks to see if `x` satisfies [[Sendable]], + and if not, falls back to [[Renderable]]." + [x request respond raise] + (if (satisfies? Sendable x) + (send* x request respond raise) + (respond (render x request)))) + +(defn- guess-content-type [response name] + (if-let [mime-type (mime/ext-mime-type (str name))] + (response/content-type response mime-type) + response)) + +(extend-protocol Renderable + nil + (render [_ _] nil) + String + (render [body _] + (-> (response/response body) + (response/content-type "text/html; charset=utf-8"))) + clojure.lang.IPersistentMap + (render [resp-map _] + (merge (with-meta (response/response "") (meta resp-map)) + resp-map)) + clojure.lang.Fn + (render [func request] (render (func request) request)) + clojure.lang.MultiFn + (render [func request] (render (func request) request)) + clojure.lang.IDeref + (render [ref request] (render (deref ref) request)) + java.io.File + (render [file _] + (-> (response/file-response (str file)) + (guess-content-type file))) + clojure.lang.ISeq + (render [coll _] + (-> (response/response coll))) + clojure.lang.PersistentVector + (render [coll _] + (-> (response/response coll))) + java.io.InputStream + (render [stream _] (response/response stream)) + java.net.URL + (render [url _] + (-> (response/url-response url) + (guess-content-type url)))) + +(extend-protocol Sendable + clojure.lang.Fn + (send* [func request respond raise] + (func request #(send % request respond raise) raise)) + clojure.lang.MultiFn + (send* [func request respond raise] + (func request #(send % request respond raise) raise))) + \ No newline at end of file diff --git a/template/bb/lib/ring/middleware/headers.clj b/template/bb/lib/ring/middleware/headers.clj new file mode 100644 index 0000000..91a84bd --- /dev/null +++ b/template/bb/lib/ring/middleware/headers.clj @@ -0,0 +1,30 @@ +(ns ring.middleware.headers + {:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} + (:require [clojure.walk :refer [keywordize-keys stringify-keys]] + [ring.util.walk :as ring-walk])) + +(defn wrap-friendly-headers + "Middleware that converts all header: + keys to keywords in the request, and back to strings in the response. + values to clojure values in the request, and back to strings in the response. + Purposed to be used right after the set defined handler, at the beginning of the middleware stack, or first of '->'" + [handler] + (fn [request] + (let [->friendly-headers-req (comp ring-walk/read-val-strings keywordize-keys) + ->friendly-headers-resp (comp stringify-keys ring-walk/write-val-strings) + response (handler (update request :headers ->friendly-headers-req))] + (update response :headers ->friendly-headers-resp)))) + +(defn wrap-lowercase-headers + "Middleware that converts all header keys in ring request and response to lowercase strings. + Assumes that all middleware applied before this is configured with lower-case. + Prevents outside world from breaking this stack prepared for HTTP/2. + Prevents any user or middleware set headers from breaking HTTP/2 in the outside world. + Purposed to be used at the end of the middleware stack, or last of '->'" + [handler] + (fn [request] + (let [response (handler (update request :headers ring-walk/lowerify-keys))] + (update response :headers ring-walk/lowerify-keys)))) diff --git a/template/bb/ring/middleware/json.clj b/template/bb/lib/ring/middleware/json.clj similarity index 90% rename from template/bb/ring/middleware/json.clj rename to template/bb/lib/ring/middleware/json.clj index caf5138..30a26d5 100644 --- a/template/bb/ring/middleware/json.clj +++ b/template/bb/lib/ring/middleware/json.clj @@ -1,9 +1,9 @@ (ns ring.middleware.json "Ring middleware for parsing JSON requests and generating JSON responses." - ^{:author "James Reeves" - :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" - :url "https://github.com/ring-clojure/ring-json" - :license {:name "Distributed under the MIT License, the same as Ring."}} + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring-json" + :license {:name "Distributed under the MIT License, the same as Ring."}} (:require [cheshire.core :as json]) (:import [java.io InputStream])) @@ -41,10 +41,10 @@ (assoc-in resp [:headers name] (str value))) (defn content-type - "Returns an updated Ring response with the a Content-Type header corresponding + "Returns an updated Ring response with the a 'content-type header corresponding to the given content-type." [resp content-type] - (header resp "Content-Type" content-type)) + (header resp "content-type" content-type)) (defn- json-request? [request] (when-let [type (get-in request [:headers "content-type"])] @@ -65,18 +65,18 @@ (def ^{:doc "The default response to return when a JSON request is malformed."} default-malformed-response {:status 400 - :headers {"Content-Type" "text/plain"} + :headers {"content-type" "text/plain"} :body "Malformed JSON in request body."}) (defn json-body-request "Parse a JSON request body and assoc it back into the :body key. Returns nil - if the JSON is malformed. See: wrap-json-body." + if the JSON is malformed. See: wrap-json-body-request." [request options] (if-let [[valid? json] (read-json request options)] (when valid? (assoc request :body json)) request)) -(defn wrap-json-body +(defn wrap-json-body-request "Middleware that parses the body of JSON request maps, and replaces the :body key with the parsed data structure. Requests without a JSON content type are unaffected. @@ -149,7 +149,7 @@ [response options] (if (coll? (:body response)) (let [json-resp (update-in response [:body] json/generate-string options)] - (if (contains? (:headers response) "Content-Type") + (if (contains? (:headers response) "content-type") json-resp (content-type json-resp "application/json; charset=utf-8"))) response)) @@ -171,3 +171,5 @@ (json-response (handler request) options)) ([request respond raise] (handler request (fn [response] (respond (json-response response options))) raise)))) + +(def wrap-json-body (comp wrap-json-response wrap-json-body-request)) diff --git a/template/bb/lib/ring/middleware/text.clj b/template/bb/lib/ring/middleware/text.clj new file mode 100644 index 0000000..ff29a93 --- /dev/null +++ b/template/bb/lib/ring/middleware/text.clj @@ -0,0 +1,16 @@ +(ns ring.middleware.text + {:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}}) + +(defn text-request? [request] + (boolean + (when-let [type (get-in request [:headers "content-type"])] + (seq (re-find #"^text/plain" type))))) + +(defn wrap-text-body [handler] + (fn [request] + (if (text-request? request) + (handler (update request :body slurp)) + (handler request)))) diff --git a/template/bb/lib/ring/util/io.clj b/template/bb/lib/ring/util/io.clj new file mode 100644 index 0000000..6d4f109 --- /dev/null +++ b/template/bb/lib/ring/util/io.clj @@ -0,0 +1,58 @@ +(ns ring.util.io + "Utility functions for handling I/O." + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring" + :license {:name "Distributed under the MIT License, the same as Ring."}} + (:import [java.io PipedInputStream + PipedOutputStream + ByteArrayInputStream + File + IOException])) + +(defn piped-input-stream + "Create an input stream from a function that takes an output stream as its + argument. The function will be executed in a separate thread. The stream + will be automatically closed after the function finishes. + + For example: + + (piped-input-stream + (fn [ostream] + (spit ostream \"Hello\")))" + {:added "1.1"} + [func] + (let [input (PipedInputStream.) + output (PipedOutputStream.)] + (.connect input output) + (future + (try + (func output) + (finally (.close output)))) + input)) + +(defn string-input-stream + "Returns a ByteArrayInputStream for the given String." + {:added "1.1"} + ([^String s] + (ByteArrayInputStream. (.getBytes s))) + ([^String s ^String encoding] + (ByteArrayInputStream. (.getBytes s encoding)))) + +(defn close! + "Ensure a stream is closed, swallowing any exceptions." + {:added "1.2"} + [stream] + (when (instance? java.io.Closeable stream) + (try + (.close ^java.io.Closeable stream) + (catch IOException _ nil)))) + +(defn last-modified-date + "Returns the last modified date for a file, rounded down to the nearest + second." + {:added "1.2"} + [^File file] + (-> (.lastModified file) + (/ 1000) (long) (* 1000) + (java.util.Date.))) \ No newline at end of file diff --git a/template/bb/lib/ring/util/mime_type.clj b/template/bb/lib/ring/util/mime_type.clj new file mode 100644 index 0000000..3546400 --- /dev/null +++ b/template/bb/lib/ring/util/mime_type.clj @@ -0,0 +1,120 @@ +(ns ring.util.mime-type + "Utility functions for determining the mime-types files." + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring" + :license {:name "Distributed under the MIT License, the same as Ring."}} + (:require [clojure.string :as str])) + +(def ^{:doc "A map of file extensions to mime-types."} + default-mime-types + {"7z" "application/x-7z-compressed" + "aac" "audio/aac" + "ai" "application/postscript" + "appcache" "text/cache-manifest" + "asc" "text/plain" + "atom" "application/atom+xml" + "avi" "video/x-msvideo" + "bin" "application/octet-stream" + "bmp" "image/bmp" + "bz2" "application/x-bzip" + "class" "application/octet-stream" + "cer" "application/pkix-cert" + "crl" "application/pkix-crl" + "crt" "application/x-x509-ca-cert" + "css" "text/css" + "csv" "text/csv" + "deb" "application/x-deb" + "dart" "application/dart" + "dll" "application/octet-stream" + "dmg" "application/octet-stream" + "dms" "application/octet-stream" + "doc" "application/msword" + "dvi" "application/x-dvi" + "edn" "application/edn" + "eot" "application/vnd.ms-fontobject" + "eps" "application/postscript" + "etx" "text/x-setext" + "exe" "application/octet-stream" + "flv" "video/x-flv" + "flac" "audio/flac" + "gif" "image/gif" + "gz" "application/gzip" + "htm" "text/html" + "html" "text/html" + "ico" "image/x-icon" + "iso" "application/x-iso9660-image" + "jar" "application/java-archive" + "jpe" "image/jpeg" + "jpeg" "image/jpeg" + "jpg" "image/jpeg" + "js" "text/javascript" + "json" "application/json" + "lha" "application/octet-stream" + "lzh" "application/octet-stream" + "mov" "video/quicktime" + "m3u8" "application/x-mpegurl" + "m4v" "video/mp4" + "mjs" "text/javascript" + "mp3" "audio/mpeg" + "mp4" "video/mp4" + "mpd" "application/dash+xml" + "mpe" "video/mpeg" + "mpeg" "video/mpeg" + "mpg" "video/mpeg" + "oga" "audio/ogg" + "ogg" "audio/ogg" + "ogv" "video/ogg" + "pbm" "image/x-portable-bitmap" + "pdf" "application/pdf" + "pgm" "image/x-portable-graymap" + "png" "image/png" + "pnm" "image/x-portable-anymap" + "ppm" "image/x-portable-pixmap" + "ppt" "application/vnd.ms-powerpoint" + "ps" "application/postscript" + "qt" "video/quicktime" + "rar" "application/x-rar-compressed" + "ras" "image/x-cmu-raster" + "rb" "text/plain" + "rd" "text/plain" + "rss" "application/rss+xml" + "rtf" "application/rtf" + "sgm" "text/sgml" + "sgml" "text/sgml" + "svg" "image/svg+xml" + "swf" "application/x-shockwave-flash" + "tar" "application/x-tar" + "tif" "image/tiff" + "tiff" "image/tiff" + "ts" "video/mp2t" + "ttf" "font/ttf" + "txt" "text/plain" + "wasm" "application/wasm" + "webm" "video/webm" + "webp" "image/webp" + "wmv" "video/x-ms-wmv" + "woff" "font/woff" + "woff2" "font/woff2" + "xbm" "image/x-xbitmap" + "xls" "application/vnd.ms-excel" + "xlsx" "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + "xml" "text/xml" + "xpm" "image/x-xpixmap" + "xwd" "image/x-xwindowdump" + "zip" "application/zip"}) + +(defn- filename-ext + "Returns the file extension of a filename or filepath." + [filename] + (when-let [ext (second (re-find #"\.([^./\\]+)$" filename))] + (str/lower-case ext))) + +(defn ext-mime-type + "Get the mimetype from the filename extension. Takes an optional map of + extensions to mimetypes that overrides values in the default-mime-types map." + ([filename] + (ext-mime-type filename {})) + ([filename mime-types] + (let [mime-types (merge default-mime-types mime-types)] + (mime-types (filename-ext filename))))) \ No newline at end of file diff --git a/template/bb/lib/ring/util/parsing.clj b/template/bb/lib/ring/util/parsing.clj new file mode 100644 index 0000000..97edd33 --- /dev/null +++ b/template/bb/lib/ring/util/parsing.clj @@ -0,0 +1,34 @@ +(ns ring.util.parsing + "Regular expressions for parsing HTTP. + For internal use." + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring" + :license {:name "Distributed under the MIT License, the same as Ring."}}) + +(def ^{:doc "HTTP token: 1*. See RFC2068" + :added "1.3"} + re-token + #"[!#$%&'*\-+.0-9A-Z\^_`a-z\|~]+") + +(def ^{:doc "HTTP quoted-string: <\"> * <\">. See RFC2068." + :added "1.3"} + re-quoted + #"\"((?:\\\"|[^\"])*)\"") + +(def ^{:doc "HTTP value: token | quoted-string. See RFC2109" + :added "1.3"} + re-value + (str "(" re-token ")|" re-quoted)) + +(def ^{:doc "Pattern for pulling the charset out of the content-type header" + :added "1.6"} + re-charset + (re-pattern (str ";(?:.*\\s)?(?i:charset)=(?:" re-value ")\\s*(?:;|$)"))) + +(defn find-content-type-charset + "Return the charset of a given a content-type string." + {:added "1.8.1"} + [s] + (when-let [m (re-find re-charset s)] + (or (m 1) (m 2)))) diff --git a/template/bb/lib/ring/util/response.clj b/template/bb/lib/ring/util/response.clj new file mode 100644 index 0000000..6e5123d --- /dev/null +++ b/template/bb/lib/ring/util/response.clj @@ -0,0 +1,320 @@ +(ns ring.util.response + "Functions for generating and augmenting response maps." + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring" + :license {:name "Distributed under the MIT License, the same as Ring."}} + (:require [clojure.java.io :as io] + [clojure.string :as str] + [ring.util.io :refer [last-modified-date]] + [ring.util.parsing :as parsing] + [ring.util.time :refer [format-date]]) + (:import [java.io File] + [java.net URL URLDecoder URLEncoder])) + +(def ^{:added "1.4"} redirect-status-codes + "Map a keyword to a redirect status code." + {:moved-permanently 301 + :found 302 + :see-other 303 + :temporary-redirect 307 + :permanent-redirect 308}) + +(defn redirect + "Returns a Ring response for an HTTP 302 redirect. Status may be + a key in redirect-status-codes or a numeric code. Defaults to 302" + ([url] (redirect url :found)) + ([url status] + {:status (redirect-status-codes status status) + :headers {"Location" url} + :body ""})) + +(defn redirect-after-post + "Returns a Ring response for an HTTP 303 redirect. Deprecated in favor + of using redirect with a :see-other status." + {:deprecated "1.4"} + [url] + {:status 303 + :headers {"Location" url} + :body ""}) + +(defn created + "Returns a Ring response for a HTTP 201 created response." + {:added "1.2"} + ([url] (created url nil)) + ([url body] + {:status 201 + :headers {"Location" url} + :body body})) + +(defn bad-request + "Returns a 400 'bad request' response." + {:added "1.7"} + [body] + {:status 400 + :headers {} + :body body}) + +(defn not-found + "Returns a 404 'not found' response." + {:added "1.1"} + [body] + {:status 404 + :headers {} + :body body}) + +(defn response + "Returns a skeletal Ring response with the given body, status of 200, and no + headers." + [body] + {:status 200 + :headers {} + :body body}) + +(defn status + "Returns an updated Ring response with the given status." + ([status] + {:status status + :headers {} + :body nil}) + ([resp status] + (assoc resp :status status))) + +(defn header + "Returns an updated Ring response with the specified header added." + [resp name value] + (assoc-in resp [:headers name] (str value))) + +(defn- canonical-path ^String [^File file] + (str (.getCanonicalPath file) + (when (.isDirectory file) File/separatorChar))) + +(defn- safe-path? [^String root ^String path] + (.startsWith (canonical-path (File. root path)) + (canonical-path (File. root)))) + +(defn- directory-transversal? + "Check if a path contains '..'." + [^String path] + (-> (str/split path #"/|\\") + (set) + (contains? ".."))) + +(defn- find-file-named [^File dir ^String filename] + (let [path (File. dir filename)] + (when (.isFile path) + path))) + +(defn- find-file-starting-with [^File dir ^String prefix] + (first + (filter + #(.startsWith (.toLowerCase (.getName ^File %)) prefix) + (.listFiles dir)))) + +(defn- find-index-file + "Search the directory for an index file." + [^File dir] + (or (find-file-named dir "index.html") + (find-file-named dir "index.htm") + (find-file-starting-with dir "index."))) + +(defn- safely-find-file [^String path opts] + (if-let [^String root (:root opts)] + (when (or (safe-path? root path) + (and (:allow-symlinks? opts) (not (directory-transversal? path)))) + (File. root path)) + (File. path))) + +(defn- find-file [^String path opts] + (when-let [^File file (safely-find-file path opts)] + (cond + (.isDirectory file) + (and (:index-files? opts true) (find-index-file file)) + (.exists file) + file))) + +(defn- file-data [^File file] + {:content file + :content-length (.length file) + :last-modified (last-modified-date file)}) + +(defn- content-length [resp len] + (if len + (header resp "Content-Length" len) + resp)) + +(defn- last-modified [resp last-mod] + (if last-mod + (header resp "Last-Modified" (format-date last-mod)) + resp)) + +(defn file-response + "Returns a Ring response to serve a static file, or nil if an appropriate + file does not exist. + Options: + :root - take the filepath relative to this root path + :index-files? - look for index.* files in directories (defaults to true) + :allow-symlinks? - allow symlinks that lead to paths outside the root path + (defaults to false)" + ([filepath] + (file-response filepath {})) + ([filepath options] + (when-let [file (find-file filepath options)] + (let [data (file-data file)] + (-> (response (:content data)) + (content-length (:content-length data)) + (last-modified (:last-modified data))))))) + +;; In Clojure 1.5.1, the as-file function does not correctly decode +;; UTF-8 byte sequences. +;; +;; See: http://dev.clojure.org/jira/browse/CLJ-1177 +;; +;; As a work-around, we'll backport the fix from CLJ-1177 into +;; url-as-file. + +(defn- url-as-file ^File [^java.net.URL u] + (-> (.getFile u) + (str/replace \/ File/separatorChar) + (str/replace "+" (URLEncoder/encode "+" "UTF-8")) + (URLDecoder/decode "UTF-8") + io/as-file)) + +(defn content-type + "Returns an updated Ring response with the a 'content-type' header corresponding + to the given content-type." + [resp content-type] + (header resp "content-type" content-type)) + +(defn find-header + "Looks up a header in a Ring response (or request) case insensitively, + returning the header map entry, or nil if not present." + {:added "1.4"} + [resp ^String header-name] + (->> (:headers resp) + (filter #(.equalsIgnoreCase header-name (key %))) + (first))) + +(defn get-header + "Looks up a header in a Ring response (or request) case insensitively, + returning the value of the header, or nil if not present." + {:added "1.2"} + [resp header-name] + (some-> resp (find-header header-name) val)) + +(defn update-header + "Looks up a header in a Ring response (or request) case insensitively, + then updates the header with the supplied function and arguments in the + manner of update-in." + {:added "1.4"} + [resp header-name f & args] + (let [header-key (or (some-> resp (find-header header-name) key) header-name)] + (update-in resp [:headers header-key] #(apply f % args)))) + +(defn charset + "Returns an updated Ring response with the supplied charset added to the + 'content-type' header." + {:added "1.1"} + [resp charset] + (update-header resp "content-type" + (fn [content-type] + (-> (or content-type "text/plain") + (str/replace #";\s*charset=[^;]*" "") + (str "; charset=" charset))))) + +(defn get-charset + "Gets the character encoding of a Ring response." + {:added "1.6"} + [resp] + (some-> (get-header resp "content-type") + parsing/find-content-type-charset)) + +(defn set-cookie + "Sets a cookie on the response. Requires the handler to be wrapped in the + wrap-cookies middleware." + {:added "1.1"} + [resp name value & [opts]] + (assoc-in resp [:cookies name] (merge {:value value} opts))) + +(defn response? + "True if the supplied value is a valid response map." + {:added "1.1"} + [resp] + (and (map? resp) + (integer? (:status resp)) + (map? (:headers resp)))) + +(defmulti resource-data + "Returns data about the resource specified by url, or nil if an + appropriate resource does not exist. + + The return value is a map with optional values for: + :content - the content of the URL, suitable for use as the :body + of a ring response + :content-length - the length of the :content, nil if not available + :last-modified - the Date the :content was last modified, nil if not + available + + This dispatches on the protocol of the URL as a keyword, and + implementations are provided for :file and :jar. If you are on a + platform where (Class/getResource) returns URLs with a different + protocol, you will need to provide an implementation for that + protocol. + + This function is used internally by url-response." + {:arglists '([url]), :added "1.4"} + (fn [^java.net.URL url] + (keyword (.getProtocol url)))) + +(defmethod resource-data :file + [url] + (when-let [file (url-as-file url)] + (when-not (.isDirectory file) + (file-data file)))) + +(defn url-response + "Return a response for the supplied URL." + {:added "1.2"} + [^URL url] + (when-let [data (resource-data url)] + (-> (response (:content data)) + (content-length (:content-length data)) + (last-modified (:last-modified data))))) + +(defn- get-resources [path ^ClassLoader loader] + (-> (or loader (.getContextClassLoader (Thread/currentThread))) + (.getResources path) + (enumeration-seq))) + +(defn- safe-file-resource? [{:keys [body]} {:keys [root loader allow-symlinks?]}] + (or allow-symlinks? + (nil? root) + (let [root (.replaceAll (str root) "^/" "")] + (or (str/blank? root) + (let [path (canonical-path body)] + (some #(and (= "file" (.getProtocol ^URL %)) + (.startsWith path (canonical-path (url-as-file %)))) + (get-resources root loader))))))) + +(defn resource-response + "Returns a Ring response to serve a packaged resource, or nil if the + resource does not exist. + Options: + :root - take the resource relative to this root + :loader - resolve the resource in this class loader + :allow-symlinks? - allow symlinks that lead to paths outside the root + classpath directories (defaults to false)" + ([path] + (resource-response path {})) + ([path options] + (let [path (-> (str "/" path) (.replace "//" "/")) + root+path (-> (str (:root options) path) (.replaceAll "^/" "")) + load #(if-let [loader (:loader options)] + (io/resource % loader) + (io/resource %))] + (when-not (directory-transversal? root+path) + (when-let [resource (load root+path)] + (let [response (url-response resource)] + (when (or (not (instance? File (:body response))) + (safe-file-resource? response options)) + response))))))) diff --git a/template/bb/lib/ring/util/string.clj b/template/bb/lib/ring/util/string.clj new file mode 100644 index 0000000..244a4da --- /dev/null +++ b/template/bb/lib/ring/util/string.clj @@ -0,0 +1,25 @@ +(ns ring.util.string + {:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} + (:require + [clojure.string :as str :refer [lower-case]] + [clojure.edn :as edn])) + +(defn ->kebab-case [s] + (lower-case (str/replace s #"_" "-"))) + +(defn read-string [s] + (try + (let [res (edn/read-string s)] + (if (or (symbol? res) (keyword? res)) + s + res)) + (catch Exception _ + s))) + +(defn write-string [x] + (if (or (symbol? x) (keyword? x)) + (name x) + (str x))) diff --git a/template/bb/lib/ring/util/time.clj b/template/bb/lib/ring/util/time.clj new file mode 100644 index 0000000..c1ca192 --- /dev/null +++ b/template/bb/lib/ring/util/time.clj @@ -0,0 +1,41 @@ +(ns ring.util.time + "Functions for dealing with time and dates in HTTP requests." + {:author "James Reeves" + :contributors "Modified by Carlos da Cunha Fontes to work with Babashka" + :url "https://github.com/ring-clojure/ring" + :license {:name "Distributed under the MIT License, the same as Ring."}} + (:require [clojure.string :as str]) + (:import [java.text ParseException SimpleDateFormat] + [java.util Locale TimeZone])) + +(def ^:no-doc http-date-formats + {:rfc1123 "EEE, dd MMM yyyy HH:mm:ss zzz" + :rfc1036 "EEEE, dd-MMM-yy HH:mm:ss zzz" + :asctime "EEE MMM d HH:mm:ss yyyy"}) + +(defn- formatter ^SimpleDateFormat [format] + (doto (SimpleDateFormat. ^String (http-date-formats format) Locale/US) + (.setTimeZone (TimeZone/getTimeZone "GMT")))) + +(defn- attempt-parse [date format] + (try + (.parse (formatter format) date) + (catch ParseException _ nil))) + +(defn- trim-quotes [s] + (str/replace s #"^'|'$" "")) + +(defn parse-date + "Attempt to parse a HTTP date. Returns nil if unsuccessful." + {:added "1.2"} + [http-date] + (->> (keys http-date-formats) + (map (partial attempt-parse (trim-quotes http-date))) + (remove nil?) + (first))) + +(defn format-date + "Format a date as RFC1123 format." + {:added "1.2"} + [^java.util.Date date] + (.format (formatter :rfc1123) date)) \ No newline at end of file diff --git a/template/bb/lib/ring/util/walk.clj b/template/bb/lib/ring/util/walk.clj new file mode 100644 index 0000000..784171a --- /dev/null +++ b/template/bb/lib/ring/util/walk.clj @@ -0,0 +1,28 @@ +(ns ring.util.walk + {:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} + (:require + [clojure.string :as str :refer [lower-case]] + [clojure.walk :refer [keywordize-keys]] + [ring.util.string :as ring-string])) + +(defn read-val-strings [m] + (into {} + (map (fn [[k v]] [k (ring-string/read-string v)]) m))) + +(defn write-val-strings [m] + (into {} + (map (fn [[k v]] [k (ring-string/write-string v)]) m))) + +(defn lowerify-keys + "Converts all keys in map 'm' to lowercase strings." + [m] (into {} + (map (fn [[k v]] [(lower-case k) v]) m))) + +(defn format-context [m] + (->> m + (map (fn [[k v]] [(ring-string/->kebab-case k) (ring-string/read-string v)])) + (into {}) + (keywordize-keys))) diff --git a/template/bb/tests.clj b/template/bb/tests.clj index 2b5b163..0a13568 100644 --- a/template/bb/tests.clj +++ b/template/bb/tests.clj @@ -1,62 +1,82 @@ -(ns tests ^{:author "Carlos da Cunha Fontes" - :url "https://github.com/ccfontes/faas-bb" - :license {:name "Distributed under the MIT License" - :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} +(ns tests + {:author "Carlos da Cunha Fontes" + :url "https://github.com/ccfontes/faas-bb" + :license {:name "Distributed under the MIT License" + :url "https://github.com/ccfontes/faas-bb/blob/main/LICENSE"}} (:require [clojure.test :refer [run-tests]] [eg :refer [eg]] + [ring.middleware.text :as middleware-text] + [ring.util.walk :as ring-walk] + [ring.util.string :as ring-string] [index])) -(eg index/keywords? - "true" => true - "false" => false - nil => true) +(eg middleware-text/text-request? + {:headers {}} => false + {:headers {"content-type" "text/plain"}} => true + {:headers {"content-type" "application/json"}} => false) + +(eg ring-string/read-string + "0A" => "0A" + "0" => 0 + "-1.1" => -1.1 + "true" => true + "" => nil + "abc" => "abc" + ":def" => ":def") -(eg index/read-string - "0A" => "0A" - "0" => 0 - "abc" => string?) +(eg ring-string/write-string + -4 => "-4" + 5.3 => "5.3" + false => "false" + nil => "" + "asd" => "asd" + :qwer => "qwer") + +(eg index/keywords? + true => true + false => false + nil => true) -(eg index/->kebab-case +(eg ring-string/->kebab-case "" => "" "Boo_baR" => "boo-bar") -(eg index/format-context +(eg ring-walk/format-context {} => {} {"Foo_baR" "false"} => {:foo-bar false}) -(eg index/->context - [{} {}] => {:headers {} :env {}} - [{"Foo_baR" "[]"} {"eggs" "4.3"}] => {:headers {:foo-bar []} - :env {:eggs 4.3}}) +(eg ring-walk/read-val-strings + {} => {} + {"Foo_baR" "false"} => {"Foo_baR" false}) -(defn arity-1-handler [content] - [(keys content) (vals content)]) +(eg ring-walk/write-val-strings + {} => {} + {"Foo_baR" 1} => {"Foo_baR" "1"}) -(defn arity-2-handler [{:keys [bar]} {:keys [headers env]}] - [bar (get headers :content-type) (:my-env env)]) +(eg ring-walk/lowerify-keys + {} => {} + {"Foo-baR" "abc"} => {"foo-bar" "abc"}) -(def handler-arity1 (index/->handler (var arity-1-handler) {})) -(def handler-arity2 (index/->handler (var arity-2-handler) {"my-env" "env-val"})) +(defn user-handler [{:keys [body headers context]}] + [(:bar body) (or (get headers "content-type") (:content-type headers)) (:my-env context)]) -(eg handler-arity1 - {:headers {} :body {}} => {:body [nil nil] :status 200} - {:headers {"content-type" "application/json"}, :body {:bar "foo"}} => {:body ['(:bar) '("foo")] :status 200}) +(def handler (index/->handler user-handler {:my-env "env-val"})) -(eg handler-arity2 - {:headers {} :body {}} => {:body [nil nil "env-val"] :status 200} - {:headers {"content-type" "application/json"}, :body {:bar "foo"}} => {:body ["foo" "application/json" "env-val"] :status 200}) +(eg handler + {:headers {} :body {}} => {:headers {} :body [nil nil "env-val"] :status 200} + {:headers {"content-type" "application/json"}, :body {:bar "foo"}} => {:headers {} :body ["foo" "application/json" "env-val"] :status 200}) -(def app (index/->app (var arity-2-handler) {"MY_ENV" "env-val"})) +(def app (index/->app user-handler {:my-env "env-val"})) (def str->stream #(-> % (.getBytes "UTF-8") (java.io.ByteArrayInputStream.))) -(def resp-fixture {:headers {"Content-Type" "application/json; charset=utf-8"} - :body "[\"spam\",\"application/json\",\"env-val\"]" +(def resp-fixture {:headers {"content-type" "application/json; charset=utf-8"} + :body "[\"spam\",\"application/json; charset=utf-8\",\"env-val\"]" :status 200}) (eg app - {:headers {"content-type" "application/json"}, :body (str->stream "{\"bar\": \"spam\"}")} => resp-fixture) + {:headers {"content-type" "application/json; charset=utf-8"}, :body (str->stream "{\"bar\": \"spam\"}")} => resp-fixture) (let [{:keys [fail error]} (run-tests 'tests)] (when (pos? (+ fail error))