├── .circleci └── config.yml ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── dev-resources └── logback.xml ├── dev └── user.clj ├── example ├── README.md ├── project.clj └── src │ ├── logback.xml │ └── pedestal_api_example │ ├── server.clj │ └── service.clj ├── project.clj ├── src └── pedestal_api │ ├── content_negotiation.clj │ ├── core.clj │ ├── error_handling.clj │ ├── helpers.clj │ ├── request_params.clj │ ├── routes.clj │ └── swagger.clj └── test └── pedestal_api ├── helpers_test.clj ├── integration_test.clj ├── request_params_test.clj ├── sse_client.clj ├── sse_test.clj └── test_fixture.clj /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Clojure CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-clojure/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/clojure:lein-2.9.1 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | LEIN_ROOT: "true" 21 | # Customize the JVM maximum heap limit 22 | JVM_OPTS: -Xmx3200m 23 | 24 | steps: 25 | - checkout 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | keys: 30 | - v1-dependencies-{{ checksum "project.clj" }} 31 | # fallback to using the latest cache if no exact match is found 32 | - v1-dependencies- 33 | 34 | - run: lein deps 35 | 36 | - save_cache: 37 | paths: 38 | - ~/.m2 39 | key: v1-dependencies-{{ checksum "project.clj" }} 40 | 41 | # run tests! 42 | - run: lein test 43 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [oliyh] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | /logs 5 | /db 6 | webhook-url.txt 7 | pom.xml 8 | pom.xml.asc 9 | *.jar 10 | *.class 11 | /.lein-* 12 | /.nrepl-port 13 | /example/logs 14 | /example/target 15 | /example/.nrepl-port -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oliver Hine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java $JVM_OPTS -jar example/target/uberjar/pedestal-api-example-standalone.jar -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pedestal-api 2 | A batteries-included API for Pedestal using Swagger. 3 | 4 | **pedestal-api** is a library for building APIs on the pedestal web server. 5 | It implements the parts of HTTP that are useful for APIs and allows you to document your handlers and middleware 6 | using idiomatic Clojure and generate a compliant Swagger specification. 7 | 8 | [![Clojars Project](https://img.shields.io/clojars/v/pedestal-api.svg)](https://clojars.org/pedestal-api) 9 | 10 | The [example code](https://github.com/oliyh/pedestal-api/tree/master/example) can be seen at https://pedestal-api.oliy.co.uk 11 | 12 | ## Features 13 | 14 | * A [Swagger](http://swagger.io) API with input and output validation and coercion as provided by [route-swagger](https://github.com/frankiesardo/route-swagger). 15 | * Content deserialisation, including: 16 | * `application/json` 17 | * `application/edn` 18 | * `application/transit+json` 19 | * `application/transit+msgpack` 20 | * `application/x-www-form-urlencoded` 21 | * Content negotiation, including: 22 | * `application/json` 23 | * `application/edn` 24 | * `application/transit+json` 25 | * `application/transit+msgpack` 26 | * Human-friendly error messages when schema validation fails 27 | * e.g. `{:error {:body-params {:age "(not (integer? abc))"}}}` 28 | * Convenience functions for annotating routes and interceptors 29 | 30 | ## Flexibility 31 | 32 | pedestal-api is built on top of [route-swagger](https://github.com/frankiesardo/route-swagger) which can still 33 | be used directly if more flexibility is needed. Interceptors are provided but not wired in, allowing you to choose 34 | those which suit you best. 35 | 36 | ## Example 37 | 38 | The [example code](https://github.com/oliyh/pedestal-api/tree/master/example) (reproduced below) 39 | can be seen running on Heroku at https://pedestal-api.oliy.co.uk 40 | 41 | ```clojure 42 | (ns pedestal-api-example.service 43 | (:require [io.pedestal.http :as bootstrap] 44 | [io.pedestal.interceptor.chain :refer [terminate]] 45 | [io.pedestal.interceptor :refer [interceptor]] 46 | [pedestal-api 47 | [core :as api] 48 | [helpers :refer [before defbefore defhandler handler]]] 49 | [schema.core :as s]) 50 | (:import java.util.UUID)) 51 | 52 | (defonce the-pets (atom {})) 53 | 54 | (s/defschema Pet 55 | {:name s/Str 56 | :type s/Str 57 | :age s/Int}) 58 | 59 | (s/defschema PetWithId 60 | (assoc Pet (s/optional-key :id) s/Uuid)) 61 | 62 | (def all-pets 63 | "Example of annotating a generic interceptor" 64 | (api/annotate 65 | {:summary "Get all pets in the store" 66 | :parameters {:query-params {(s/optional-key :sort) (s/enum :asc :desc)}} 67 | :responses {200 {:body {:pets [PetWithId]}}} 68 | :operationId :all-pets} 69 | (interceptor 70 | {:name ::all-pets 71 | :enter (fn [ctx] 72 | (assoc ctx :response 73 | {:status 200 74 | :body {:pets (let [sort (get-in ctx [:request :query-params :sort])] 75 | (cond->> (vals @the-pets) 76 | sort (sort-by :name) 77 | (= :desc sort) reverse))}}))}))) 78 | 79 | (def create-pet 80 | "Example of using the handler helper" 81 | (handler 82 | ::create-pet 83 | {:summary "Create a pet" 84 | :parameters {:body-params Pet} 85 | :responses {201 {:body {:id s/Uuid}}} 86 | :operationId :create-pet} 87 | (fn [request] 88 | (let [id (UUID/randomUUID)] 89 | (swap! the-pets assoc id (assoc (:body-params request) :id id)) 90 | {:status 201 91 | :body {:id id}})))) 92 | 93 | ;; Example using the defbefore helper 94 | (defbefore load-pet 95 | {:summary "Load a pet by id" 96 | :parameters {:path-params {:id s/Uuid}} 97 | :responses {404 {:body s/Str}}} 98 | [{:keys [request] :as context}] 99 | (if-let [pet (get @the-pets (get-in request [:path-params :id]))] 100 | (update context :request assoc :pet pet) 101 | (-> context terminate (assoc :response {:status 404 102 | :body "No pet found with this id"})))) 103 | 104 | ;; Example of using the defhandler helper 105 | (defhandler get-pet 106 | {:summary "Get a pet by id" 107 | :parameters {:path-params {:id s/Uuid}} 108 | :responses {200 {:body PetWithId} 109 | 404 {:body s/Str}} 110 | :operationId :get-pet} 111 | [{:keys [pet] :as request}] 112 | {:status 200 113 | :body pet}) 114 | 115 | (def update-pet 116 | "Example of using the before helper" 117 | (before 118 | ::update-pet 119 | {:summary "Update a pet" 120 | :parameters {:path-params {:id s/Uuid} 121 | :body-params Pet} 122 | :responses {200 {:body s/Str}} 123 | :operationId :update-pet} 124 | (fn [{:keys [request]}] 125 | (swap! the-pets update (get-in request [:path-params :id]) merge (:body-params request)) 126 | {:status 200 127 | :body "Pet updated"}))) 128 | 129 | (def delete-pet 130 | "Example of annotating a generic interceptor" 131 | (api/annotate 132 | {:summary "Delete a pet by id" 133 | :parameters {:path-params {:id s/Uuid}} 134 | :responses {200 {:body s/Str}} 135 | :operationId :delete-pet} 136 | (interceptor 137 | {:name ::delete-pet 138 | :enter (fn [ctx] 139 | (let [pet (get-in ctx [:request :pet])] 140 | (swap! the-pets dissoc (:id pet)) 141 | (assoc ctx :response 142 | {:status 200 143 | :body (str "Deleted " (:name pet))})))}))) 144 | 145 | (s/with-fn-validation 146 | (api/defroutes routes 147 | {:info {:title "Swagger Sample App built using pedestal-api" 148 | :description "Find out more at https://github.com/oliyh/pedestal-api" 149 | :version "2.0"} 150 | :tags [{:name "pets" 151 | :description "Everything about your Pets" 152 | :externalDocs {:description "Find out more" 153 | :url "http://swagger.io"}} 154 | {:name "orders" 155 | :description "Operations about orders"}]} 156 | [[["/" ^:interceptors [api/error-responses 157 | (api/negotiate-response) 158 | (api/body-params) 159 | api/common-body 160 | (api/coerce-request) 161 | (api/validate-response)] 162 | ["/pets" ^:interceptors [(api/doc {:tags ["pets"]})] 163 | ["/" {:get all-pets 164 | :post create-pet}] 165 | ["/:id" ^:interceptors [load-pet] 166 | {:get get-pet 167 | :put update-pet 168 | :delete delete-pet}]] 169 | 170 | ["/swagger.json" {:get api/swagger-json}] 171 | ["/*resource" {:get api/swagger-ui}]]]])) 172 | 173 | (def service 174 | {:env :dev 175 | ::bootstrap/routes #(deref #'routes) 176 | ;; linear-search, and declaring the swagger-ui handler last in the routes, 177 | ;; is important to avoid the splat param for the UI matching API routes 178 | ::bootstrap/router :linear-search 179 | ::bootstrap/resource-path "/public" 180 | ::bootstrap/type :jetty 181 | ::bootstrap/port 8080 182 | ::bootstrap/join? false}) 183 | ``` 184 | 185 | ## Build 186 | [![Circle CI](https://circleci.com/gh/oliyh/pedestal-api.svg?style=svg)](https://circleci.com/gh/oliyh/pedestal-api) 187 | -------------------------------------------------------------------------------- /dev-resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 9 | 10 | 11 | 12 | 13 | logs/slacky-%d{yyyy-MM-dd}.%i.log 14 | 16 | 17 | 64 MB 18 | 19 | 20 | 21 | 22 | true 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | %-5level %logger{36} - %msg%n 31 | 32 | 33 | 34 | INFO 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [clojure.tools.namespace.repl :as repl])) 3 | 4 | (def refresh repl/refresh) 5 | (def clear repl/clear) 6 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example pedestal-api project 2 | 3 | This project contains example usage of [pedestal-api](https://github.com/oliyh/pedestal-api/). 4 | You can see it in action at https://pedestal-api.herokuapp.com/. 5 | 6 | For more information please visit [pedestal-api](https://github.com/oliyh/pedestal-api/). 7 | -------------------------------------------------------------------------------- /example/project.clj: -------------------------------------------------------------------------------- 1 | (defproject pedestal-api-example "0.1.0-SNAPSHOT" 2 | :description "An example project for pedestal-api" 3 | :url "https://github.com/oliyh/pedestal-api" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[org.clojure/clojure "1.10.1"] 7 | [pedestal-api "0.3.6-20210212.231501-2" :exclusions [prismatic/schema]] 8 | [prismatic/schema "1.1.12"] 9 | [io.pedestal/pedestal.service "0.5.8"] 10 | [io.pedestal/pedestal.jetty "0.5.8"] 11 | 12 | [ch.qos.logback/logback-classic "1.2.3" :exclusions [org.slf4j/slf4j-api]] 13 | [org.slf4j/jul-to-slf4j "1.7.30"] 14 | [org.slf4j/jcl-over-slf4j "1.7.30"] 15 | [org.slf4j/log4j-over-slf4j "1.7.30"] 16 | [org.clojure/tools.logging "1.1.0"]] 17 | :main ^:skip-aot pedestal_api_example.server 18 | :target-path "target/%s" 19 | :uberjar-name "pedestal-api-example-standalone.jar" 20 | :profiles {:uberjar {:aot :all} 21 | :dev {:repl-options {:init-ns pedestal-api-example.server}}}) 22 | -------------------------------------------------------------------------------- /example/src/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 9 | 10 | 11 | 12 | 13 | logs/slacky-%d{yyyy-MM-dd}.%i.log 14 | 16 | 17 | 64 MB 18 | 19 | 20 | 21 | 22 | true 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | %-5level %logger{36} - %msg%n 31 | 32 | 33 | 34 | INFO 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /example/src/pedestal_api_example/server.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api-example.server 2 | (:require [pedestal-api-example.service :as service] 3 | [io.pedestal.http :as bootstrap]) 4 | (:gen-class)) 5 | 6 | (defonce service-instance nil) 7 | 8 | (defn create-server [] 9 | (alter-var-root #'service-instance 10 | (constantly (bootstrap/create-server 11 | (-> service/service 12 | (assoc ::bootstrap/port (Integer. (or (System/getenv "PORT") 8080))) 13 | (bootstrap/default-interceptors)))))) 14 | 15 | (defn start [] 16 | (when-not service-instance 17 | (create-server)) 18 | (println "Starting server on port" (::bootstrap/port service-instance)) 19 | (bootstrap/start service-instance)) 20 | 21 | (defn stop [] 22 | (when service-instance 23 | (bootstrap/stop service-instance))) 24 | 25 | (defn -main [& args] 26 | (start)) 27 | -------------------------------------------------------------------------------- /example/src/pedestal_api_example/service.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api-example.service 2 | (:require [io.pedestal.http :as bootstrap] 3 | [io.pedestal.interceptor.chain :refer [terminate]] 4 | [io.pedestal.interceptor :refer [interceptor]] 5 | [pedestal-api 6 | [core :as api] 7 | [helpers :refer [before defbefore defhandler handler]]] 8 | [schema.core :as s])) 9 | 10 | (defonce the-pets (atom {})) 11 | 12 | (s/defschema Pet 13 | {:name s/Str 14 | :type s/Str 15 | :age s/Int}) 16 | 17 | (s/defschema PetWithId 18 | (assoc Pet (s/optional-key :id) s/Int)) 19 | 20 | (def all-pets 21 | "Example of annotating a generic interceptor" 22 | (api/annotate 23 | {:summary "Get all pets in the store" 24 | :parameters {:query-params {(s/optional-key :sort) (s/enum :asc :desc)}} 25 | :responses {200 {:body {:pets [PetWithId]}}}} 26 | (interceptor 27 | {:name ::all-pets 28 | :enter (fn [ctx] 29 | (assoc ctx :response 30 | {:status 200 31 | :body {:pets (let [sort (get-in ctx [:request :query-params :sort])] 32 | (cond->> (vals @the-pets) 33 | sort (sort-by :name) 34 | (= :desc sort) reverse))}}))}))) 35 | 36 | (def create-pet 37 | "Example of using the handler helper" 38 | (handler 39 | ::create-pet 40 | {:summary "Create a pet" 41 | :parameters {:body-params Pet} 42 | :responses {201 {:body {:id s/Int}}}} 43 | (fn [request] 44 | (let [id (inc (count @the-pets))] 45 | (swap! the-pets assoc id (assoc (:body-params request) :id id)) 46 | {:status 201 47 | :body {:id id}})))) 48 | 49 | ;; Example using the defbefore helper 50 | (defbefore load-pet 51 | {:summary "Load a pet by id" 52 | :parameters {:path-params {:id s/Int}} 53 | :responses {404 {:body s/Str}}} 54 | [{:keys [request] :as context}] 55 | (if-let [pet (get @the-pets (get-in request [:path-params :id]))] 56 | (update context :request assoc :pet pet) 57 | (-> context terminate (assoc :response {:status 404 58 | :body "No pet found with this id"})))) 59 | 60 | ;; Example of using the defhandler helper 61 | (defhandler get-pet 62 | {:summary "Get a pet by id" 63 | :parameters {:path-params {:id s/Int}} 64 | :responses {200 {:body PetWithId} 65 | 404 {:body s/Str}}} 66 | [{:keys [pet] :as request}] 67 | {:status 200 68 | :body pet}) 69 | 70 | (def update-pet 71 | "Example of using the before helper" 72 | (before 73 | ::update-pet 74 | {:summary "Update a pet" 75 | :parameters {:path-params {:id s/Int} 76 | :body-params Pet} 77 | :responses {200 {:body s/Str}}} 78 | (fn [{:keys [request]}] 79 | (swap! the-pets update (get-in request [:path-params :id]) merge (:body-params request)) 80 | {:status 200 81 | :body "Pet updated"}))) 82 | 83 | (def delete-pet 84 | "Example of annotating a generic interceptor" 85 | (api/annotate 86 | {:summary "Delete a pet by id" 87 | :parameters {:path-params {:id s/Int}} 88 | :responses {200 {:body s/Str}}} 89 | (interceptor 90 | {:name ::delete-pet 91 | :enter (fn [ctx] 92 | (let [pet (get-in ctx [:request :pet])] 93 | (swap! the-pets dissoc (:id pet)) 94 | (assoc ctx :response 95 | {:status 200 96 | :body (str "Deleted " (:name pet))})))}))) 97 | 98 | (def no-csp 99 | {:name ::no-csp 100 | :leave (fn [ctx] 101 | (assoc-in ctx [:response :headers "Content-Security-Policy"] ""))}) 102 | 103 | (s/with-fn-validation 104 | (api/defroutes routes 105 | {:info {:title "Swagger Sample App built using pedestal-api" 106 | :description "Find out more at https://github.com/oliyh/pedestal-api" 107 | :version "2.0"} 108 | :tags [{:name "pets" 109 | :description "Everything about your Pets" 110 | :externalDocs {:description "Find out more" 111 | :url "http://swagger.io"}} 112 | {:name "orders" 113 | :description "Operations about orders"}]} 114 | [[["/" ^:interceptors [api/error-responses 115 | (api/negotiate-response) 116 | (api/body-params) 117 | api/common-body 118 | (api/coerce-request) 119 | (api/validate-response)] 120 | ["/pets" ^:interceptors [(api/doc {:tags ["pets"]})] 121 | ["/" {:get all-pets 122 | :post create-pet}] 123 | ["/:id" ^:interceptors [load-pet] 124 | {:get get-pet 125 | :put update-pet 126 | :delete delete-pet}]] 127 | 128 | ["/swagger.json" {:get api/swagger-json}] 129 | ["/*resource" ^:interceptors [no-csp] {:get api/swagger-ui}]]]])) 130 | 131 | (def service 132 | {:env :dev 133 | ::bootstrap/routes #(deref #'routes) 134 | ;; linear-search, and declaring the swagger-ui handler last in the routes, 135 | ;; is important to avoid the splat param for the UI matching API routes 136 | ::bootstrap/router :linear-search 137 | ::bootstrap/resource-path "/public" 138 | ::bootstrap/type :jetty 139 | ::bootstrap/allowed-origins {:allowed-origins (constantly true) 140 | :creds true} 141 | ::bootstrap/port 8080 142 | ::bootstrap/host "0.0.0.0" ;; bind to all interfaces 143 | ::bootstrap/join? false}) 144 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject pedestal-api "0.3.6-SNAPSHOT" 2 | :description "A batteries-included API for Pedestal using Swagger" 3 | :url "https://github.com/oliyh/pedestal-api" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[oliyh/route-swagger "0.1.6"]] 7 | :repl-options {:init-ns user} 8 | :profiles {:provided {:dependencies [[org.clojure/clojure "1.10.1"] 9 | [io.pedestal/pedestal.service "0.5.8"]]} 10 | :dev {:source-paths ["dev" "example"] 11 | :resources ["dev-resources"] 12 | :dependencies [[potemkin "0.4.5"] 13 | [org.clojure/tools.namespace "1.1.0"] 14 | [io.pedestal/pedestal.jetty "0.5.8"] 15 | [clj-http "3.11.0"] 16 | 17 | [ch.qos.logback/logback-classic "1.2.3" 18 | :exclusions [org.slf4j/slf4j-api]] 19 | [org.slf4j/jcl-over-slf4j "1.7.30"] 20 | [org.slf4j/jul-to-slf4j "1.7.30"] 21 | [org.slf4j/log4j-over-slf4j "1.7.30"]]}} 22 | 23 | ;; for heroku deployment of example 24 | :min-lein-version "2.0.0" 25 | :plugins [[lein-sub "0.3.0"]] 26 | :sub ["example"] 27 | :aliases {"uberjar" ["sub" "uberjar"]} 28 | :uberjar-name "") 29 | -------------------------------------------------------------------------------- /src/pedestal_api/content_negotiation.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.content-negotiation 2 | (:require [clojure.string :as string] 3 | [io.pedestal.http :as service] 4 | [io.pedestal.interceptor.chain :refer [terminate enqueue*]] 5 | [io.pedestal.interceptor.helpers :as interceptor] 6 | [io.pedestal.http.content-negotiation :as pcn] 7 | [linked.core :as linked] 8 | [route-swagger.doc :as sw.doc] 9 | [ring.util.response :as ring-response])) 10 | 11 | (def edn-body 12 | (interceptor/on-response 13 | ::edn-body 14 | (fn [response] 15 | (let [body (:body response) 16 | content-type (get-in response [:headers "Content-Type"])] 17 | (if (and (coll? body) (not content-type)) 18 | (-> response 19 | (ring-response/content-type "application/edn;charset=UTF-8") 20 | (assoc :body (:body (service/edn-response body)))) 21 | response))))) 22 | 23 | (def default-serialisation-interceptors 24 | (linked/map 25 | "application/json" service/json-body 26 | "application/edn" edn-body 27 | "application/transit+json" service/transit-json-body 28 | "application/transit+msgpack" service/transit-msgpack-body 29 | "application/transit" service/transit-body)) 30 | 31 | (defn- find-interceptor [serialisation-interceptors accept] 32 | (get serialisation-interceptors accept)) 33 | 34 | (defn default-to [content-type] 35 | (fn [ctx] 36 | (assoc-in ctx [:request :accept] content-type))) 37 | 38 | (defn negotiate-response 39 | ([] (negotiate-response default-serialisation-interceptors)) 40 | ([serialisation-interceptors] (negotiate-response serialisation-interceptors service/json-body)) 41 | ([serialisation-interceptors default-serialiser] 42 | (let [delegate (pcn/negotiate-content (keys serialisation-interceptors) {:no-match-fn identity})] 43 | (sw.doc/annotate 44 | {:produces (keys serialisation-interceptors) 45 | ;; :responses {406 {}} see comment below 46 | } 47 | (interceptor/around 48 | ::serialise-response 49 | 50 | (fn [ctx] ((:enter delegate) ctx)) 51 | 52 | (fn [{:keys [request] :as ctx}] 53 | (if-let [i (or (find-interceptor serialisation-interceptors (get-in request [:accept :field])) 54 | default-serialiser)] 55 | ((:leave i) ctx) 56 | ctx))))))) 57 | 58 | ;; turned off until can work out what to do with things like text/plain,text/html,image/jpg etc 59 | #_(fn [{:keys [request] :as ctx}] 60 | (let [accept (get-in request [:headers "accept"])] 61 | (if-not (find-interceptor serialisation-interceptors accept) 62 | (-> ctx 63 | terminate 64 | (assoc :response {:status 406 65 | :headers {} 66 | :body (format "No serialiser could be found to generate '%s'." 67 | accept)})) 68 | ctx))) 69 | -------------------------------------------------------------------------------- /src/pedestal_api/core.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.core 2 | (:require 3 | [pedestal-api 4 | [swagger] 5 | [content-negotiation] 6 | [error-handling] 7 | [request-params] 8 | [routes]] 9 | [potemkin :refer [import-vars]])) 10 | 11 | (import-vars [pedestal-api.swagger 12 | annotate 13 | coerce-request 14 | validate-response 15 | swagger-json 16 | swagger-ui 17 | make-swagger-ui 18 | doc] 19 | 20 | [pedestal-api.content-negotiation 21 | negotiate-response] 22 | 23 | [pedestal-api.error-handling 24 | error-responses] 25 | 26 | [pedestal-api.request-params 27 | common-body 28 | body-params] 29 | 30 | [pedestal-api.routes 31 | defroutes]) 32 | -------------------------------------------------------------------------------- /src/pedestal_api/error_handling.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.error-handling 2 | (:require [route-swagger.interceptor :as sw.int] 3 | [route-swagger.doc :as sw.doc] 4 | [ring.swagger.middleware :refer [stringify-error]] 5 | [io.pedestal.interceptor.error :as error] 6 | [ring.util.http-status :as status] 7 | [io.pedestal.http.body-params :as body-params])) 8 | 9 | (def error-responses 10 | (sw.doc/annotate 11 | {:responses {status/bad-request {} 12 | status/internal-server-error {}}} 13 | (error/error-dispatch [ctx ex] 14 | [{:interceptor ::body-params/body-params}] 15 | (assoc ctx :response {:status status/bad-request :body "Cannot deserialise body" :headers {"Content-Type" "text/plain"}}) 16 | 17 | [{:interceptor ::sw.int/coerce-request}] 18 | (assoc ctx :response {:status status/bad-request :body (stringify-error (:error (ex-data ex))) :headers {"Content-Type" "text/plain"}}) 19 | 20 | [{:interceptor ::sw.int/validate-response}] 21 | (assoc ctx :response {:status status/internal-server-error :body (stringify-error (:error (ex-data ex))) :headers {"Content-Type" "text/plain"}})))) 22 | -------------------------------------------------------------------------------- /src/pedestal_api/helpers.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.helpers 2 | (:require [io.pedestal.interceptor.helpers] 3 | [pedestal-api.swagger :as swagger] 4 | [clojure.string :as string])) 5 | 6 | (defmacro defhelper [helper-name] 7 | (let [helper-fn-name (symbol (string/replace helper-name "def" ""))] 8 | `(do (defmacro ~(symbol helper-name) 9 | [name# swagger# & args#] 10 | `(def ~name# 11 | (swagger/annotate ~swagger# 12 | (@~(ns-resolve 'io.pedestal.interceptor.helpers '~helper-fn-name) 13 | (keyword (name (ns-name *ns*)) (name '~name#)) 14 | (fn ~@args#))))) 15 | 16 | (defn ~helper-fn-name [name# swagger# & args#] 17 | (swagger/annotate 18 | swagger# 19 | (apply @~(ns-resolve 'io.pedestal.interceptor.helpers helper-fn-name) name# args#)))))) 20 | 21 | ;; shadows helper macros in io.pedestal.interceptor.helpers 22 | ;; adding swagger metadata as the second argument, e.g. 23 | ;; (defhandler create-pet 24 | ;; {:summary "Creates a pet"} 25 | ;; [request] 26 | ;; {:status 200 27 | ;; :body "Created pet"}) 28 | ;; 29 | ;; also shadows helper functions in io.pedestal.interceptor.helpers, 30 | ;; again adding swagger metadata as the second argument, e.g. 31 | ;; (handler ::create-pet 32 | ;; {:summary "Creates a pet"} 33 | ;; (fn [request] 34 | ;; {:status 200 35 | ;; :body "Created pet"})) 36 | ;; 37 | ;; Note that pedestal recommends building interceptors directly, 38 | ;; to which you should add swagger metadata, e.g. 39 | ;; (swagger/annotate 40 | ;; {:summary "Creates a pet"} 41 | ;; (i/interceptor {:name ::create-pet 42 | ;; :enter (fn [context] {:status 200 43 | ;; :body "Created pet"})})) 44 | ;; 45 | ;; All these forms create equivalent interceptors. 46 | 47 | (defhelper defbefore) 48 | (defhelper defafter) 49 | (defhelper defaround) 50 | (defhelper defon-request) 51 | (defhelper defon-response) 52 | (defhelper defhandler) 53 | (defhelper defmiddleware) 54 | -------------------------------------------------------------------------------- /src/pedestal_api/request_params.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.request-params 2 | (:require [io.pedestal.http.body-params :as pedestal] 3 | [io.pedestal.interceptor :as i] 4 | [route-swagger.doc :as sw.doc] 5 | [clojure.walk :as walk] 6 | [clojure.set :as set])) 7 | 8 | (defn- safe-merge [a b] 9 | (if (every? map? [a b]) 10 | (merge a b) 11 | a)) 12 | 13 | (defn- merge-empty-params [request] 14 | (merge-with safe-merge 15 | request 16 | {:body-params {} 17 | :form-params {} 18 | :query-params {} 19 | :path-params {} 20 | :headers {}})) 21 | 22 | (defn- normalise-params [request] 23 | (-> request 24 | (set/rename-keys {:edn-params :body-params 25 | :json-params :body-params 26 | :transit-params :body-params 27 | :multipart-params :form-params}) 28 | (update :form-params walk/keywordize-keys) 29 | merge-empty-params)) 30 | 31 | (def common-body 32 | (i/interceptor 33 | {:name ::common-body 34 | :enter (fn [context] 35 | (update context :request normalise-params))})) 36 | 37 | (defn body-params [& args] 38 | (sw.doc/annotate 39 | {:consumes ["application/json" 40 | "application/edn" 41 | "application/x-www-form-urlencoded" 42 | "application/transit+json" 43 | "application/transit+msgpack"]} 44 | (apply pedestal/body-params args))) 45 | -------------------------------------------------------------------------------- /src/pedestal_api/routes.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.routes 2 | (:require [route-swagger.doc :as sw.doc] 3 | [io.pedestal.http.route :as route] 4 | [clojure.string :as string])) 5 | 6 | (defn replace-splat-parameters [route-table] 7 | (map (fn [route] 8 | (update route :path #(string/replace % "*" ":"))) 9 | route-table)) 10 | 11 | (defn update-handler-swagger [route-table f] 12 | (map (fn [route] 13 | (update route :interceptors 14 | (fn [interceptors] 15 | (if (-> interceptors last meta ::sw.doc/doc) 16 | (conj (vec (drop-last interceptors)) 17 | (vary-meta (last interceptors) update ::sw.doc/doc (partial f route))) 18 | interceptors)))) 19 | route-table)) 20 | 21 | (defn default-operation-ids [route handler-swagger] 22 | (merge {:operationId (name (:route-name route))} handler-swagger)) 23 | 24 | (defn default-empty-parameters [route handler-swagger] 25 | (merge {:parameters {}} handler-swagger)) 26 | 27 | (defn comp->> 28 | "Like comp but multiple arguments are passed to all functions like partial, except the last which is threaded like ->>" 29 | [f & fs] 30 | (fn [& args] 31 | (reduce (fn [r f'] 32 | (apply f' (conj (into [] (butlast args)) r))) 33 | (apply f args) 34 | fs))) 35 | 36 | (defmacro defroutes [n doc routes] 37 | `(def ~n (-> ~routes 38 | route/expand-routes 39 | replace-splat-parameters 40 | (update-handler-swagger (comp->> default-operation-ids 41 | default-empty-parameters)) 42 | (sw.doc/with-swagger (merge {:basePath ""} ~doc))))) 43 | -------------------------------------------------------------------------------- /src/pedestal_api/swagger.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.swagger 2 | (:require [route-swagger.interceptor :as sw.int] 3 | [route-swagger.doc :as sw.doc] 4 | [io.pedestal.interceptor :as i] 5 | [potemkin :refer [import-vars]])) 6 | 7 | (import-vars [route-swagger.interceptor coerce-request validate-response] 8 | [route-swagger.doc annotate]) 9 | 10 | (def swagger-json (i/interceptor (sw.int/swagger-json))) 11 | 12 | (defn make-swagger-ui [& opts] 13 | (i/interceptor 14 | (apply sw.int/swagger-ui opts))) 15 | 16 | (def swagger-ui (make-swagger-ui)) 17 | 18 | (defn doc 19 | "Adds metatata m to a swagger route" 20 | [m] 21 | (sw.doc/annotate 22 | m 23 | (i/interceptor 24 | {:name ::doc 25 | :enter identity}))) 26 | -------------------------------------------------------------------------------- /test/pedestal_api/helpers_test.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.helpers-test 2 | (:require [pedestal-api.core :as api] 3 | [pedestal-api.helpers :refer :all] 4 | [schema.core :as s] 5 | [io.pedestal.interceptor.helpers :as i] 6 | [clojure.test :refer :all])) 7 | 8 | (defbefore test-defbefore 9 | {:summary "A test interceptor" 10 | :parameters {:body-params {:age s/Int}} 11 | :responses {200 {:body s/Str}}} 12 | [{:keys [request] :as context}] 13 | (assoc context :response request)) 14 | 15 | (deftest defbefore-test 16 | (is (= ::test-defbefore (:name test-defbefore))) 17 | 18 | (is (= (meta (api/annotate 19 | {:summary "A test interceptor" 20 | :parameters {:body-params {:age s/Int}} 21 | :responses {200 {:body s/Str}}} 22 | (i/before ::test-defbefore 23 | (fn [{:keys [request] :as context}] 24 | (assoc context :response request))))) 25 | (meta test-defbefore))) 26 | 27 | (is (= {:request {:a 1} 28 | :response {:a 1}} 29 | 30 | ((:enter test-defbefore) {:request {:a 1}})))) 31 | 32 | 33 | (def test-before (before ::test-before 34 | {:summary "A test interceptor" 35 | :parameters {:body-params {:age s/Int}} 36 | :responses {200 {:body s/Str}}} 37 | (fn [{:keys [request] :as context}] 38 | (assoc context :response request)))) 39 | 40 | (deftest before-test 41 | (is (= ::test-before (:name test-before))) 42 | 43 | (is (= (meta (api/annotate 44 | {:summary "A test interceptor" 45 | :parameters {:body-params {:age s/Int}} 46 | :responses {200 {:body s/Str}}} 47 | (i/before ::test-before 48 | (fn [{:keys [request] :as context}] 49 | (assoc context :response request))))) 50 | (meta test-before))) 51 | 52 | (is (= {:request {:a 1} 53 | :response {:a 1}} 54 | 55 | ((:enter test-before) {:request {:a 1}})))) 56 | -------------------------------------------------------------------------------- /test/pedestal_api/integration_test.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.integration-test 2 | (:require [pedestal-api 3 | [core :as api] 4 | [helpers :refer [defhandler]] 5 | [test-fixture :as tf]] 6 | [io.pedestal.interceptor :refer [interceptor]] 7 | [schema.core :as s] 8 | [clj-http.client :as http] 9 | [clojure.test :refer :all] 10 | [cheshire.core :as json] 11 | [clojure.edn :as edn] 12 | [route-swagger.doc :as doc])) 13 | 14 | (s/defschema Pet 15 | {:name s/Str 16 | :type s/Str 17 | :age s/Int}) 18 | 19 | (def create-pet 20 | (api/annotate 21 | {:parameters {:body-params Pet} 22 | :responses {201 {:body {:pet Pet}}}} 23 | (interceptor 24 | {:name ::create-pet 25 | :enter (fn [ctx] 26 | (assoc ctx :response 27 | {:status 201 28 | :body {:pet (get-in ctx [:request :body-params])}}))}))) 29 | 30 | (def get-all-pets 31 | (api/annotate 32 | {:parameters {:query-params {(s/optional-key :sort) (s/enum :asc :desc)}} 33 | :responses {200 {:body [Pet]}}} 34 | (interceptor 35 | {:name ::get-all-pets 36 | :enter (fn [ctx] 37 | (assoc ctx :response 38 | {:status 200 39 | :body [{:name "Alfred" 40 | :type "dog" 41 | :age 6}]}))}))) 42 | 43 | (defhandler get-pet-by-name 44 | {:parameters {:path-params {:name s/Str}} 45 | :responses {200 {:body Pet}}} 46 | [{:keys [path-params]}] 47 | {:status 200 48 | :body {:name (:name path-params) 49 | :type "dog" 50 | :age 6}}) 51 | 52 | (defhandler events 53 | {:parameters {:path-params {:topic (s/constrained s/Str #(re-find #"/" %))}} 54 | :responses {200 {:body s/Str}}} 55 | [{:keys [path-params]}] 56 | {:status 200 57 | :body (:topic path-params)}) 58 | 59 | (s/with-fn-validation 60 | (api/defroutes routes 61 | {} 62 | [[["/" ^:interceptors [api/error-responses 63 | (api/negotiate-response) 64 | (api/body-params) 65 | api/common-body 66 | (api/coerce-request) 67 | (api/validate-response)] 68 | ["/pets" 69 | {:get get-all-pets 70 | :post create-pet} 71 | ["/:name" {:get get-pet-by-name}]] 72 | ["/events/*topic" {:get events}] 73 | ["/swagger.json" {:get api/swagger-json}]]]])) 74 | 75 | (use-fixtures :once (tf/with-server #(deref #'routes))) 76 | 77 | (def ^:private url-for (partial tf/url-for routes)) 78 | 79 | (deftest can-get-content-test 80 | 81 | ;;json 82 | (let [response (http/get (url-for ::get-all-pets))] 83 | (is (= 200 (:status response))) 84 | (is (= "application/json;charset=utf-8" (get-in response [:headers "Content-Type"]))) 85 | (is (= [{:name "Alfred" 86 | :type "dog" 87 | :age 6}] 88 | (json/decode (:body response) keyword)))) 89 | 90 | ;; edn 91 | (let [response (http/get (url-for ::get-all-pets) {:headers {"Accept" "application/edn"}})] 92 | (is (= 200 (:status response))) 93 | (is (= "application/edn;charset=UTF-8" (get-in response [:headers "Content-Type"]))) 94 | (is (= [{:name "Alfred" 95 | :type "dog" 96 | :age 6}] 97 | (edn/read-string (:body response))))) 98 | 99 | ;; transit+json 100 | (let [response (http/get (url-for ::get-all-pets) {:headers {"Accept" "application/transit+json"}})] 101 | (is (= 200 (:status response))) 102 | (is (= "application/transit+json;charset=UTF-8" (get-in response [:headers "Content-Type"]))) 103 | (is (= [{:name "Alfred" 104 | :type "dog" 105 | :age 6}] 106 | (tf/transit-read-bytes (.getBytes (:body response)) :json)))) 107 | 108 | (let [response (http/get (url-for ::get-all-pets) {:headers {"Accept" "application/transit+msgpack"} 109 | :as :byte-array})] 110 | (is (= 200 (:status response))) 111 | (is (= "application/transit+msgpack;charset=UTF-8" (get-in response [:headers "Content-Type"]))) 112 | (is (= [{:name "Alfred" 113 | :type "dog" 114 | :age 6}] 115 | (tf/transit-read-bytes (:body response) :msgpack))))) 116 | 117 | (deftest can-submit-content-test 118 | 119 | (let [pet {:name "Alfred" 120 | :type "dog" 121 | :age 6}] 122 | ;;json 123 | (let [response (http/post (url-for ::create-pet) {:body (json/encode pet) 124 | :headers {"Content-Type" "application/json" 125 | "Accept" "application/json"}})] 126 | (is (= 201 (:status response))) 127 | (is (= {:pet pet} (json/decode (:body response) keyword)))) 128 | 129 | ;; edn 130 | (let [response (http/post (url-for ::create-pet) {:body (pr-str pet) 131 | :headers {"Content-Type" "application/edn" 132 | "Accept" "application/edn"}})] 133 | (is (= 201 (:status response))) 134 | (is (= {:pet pet} (edn/read-string (:body response))))) 135 | 136 | ;; transit+json 137 | (let [response (http/post (url-for ::create-pet) {:body (tf/transit-write-bytes pet :json) 138 | :headers {"Content-Type" "application/transit+json" 139 | "Accept" "application/transit+json"}})] 140 | (is (= 201 (:status response))) 141 | (is (= {:pet pet} (tf/transit-read-bytes (.getBytes (:body response)) :json)))) 142 | 143 | ;; transit+msgpack 144 | (let [response (http/post (url-for ::create-pet) {:body (tf/transit-write-bytes pet :msgpack) 145 | :headers {"Content-Type" "application/transit+msgpack" 146 | "Accept" "application/transit+msgpack"} 147 | :as :byte-array})] 148 | (is (= 201 (:status response))) 149 | (is (= {:pet pet} (tf/transit-read-bytes (:body response) :msgpack)))))) 150 | 151 | (deftest helpers-test 152 | (let [response (http/get (url-for ::get-pet-by-name :path-params {:name "Keiran"}))] 153 | (is (= 200 (:status response))) 154 | (is (= {:name "Keiran" 155 | :type "dog" 156 | :age 6} 157 | (json/decode (:body response) keyword))))) 158 | 159 | (deftest optional-parameters-test 160 | (let [response (http/get (url-for ::get-all-pets) {:headers {"Content-Type" "application/json"}})] 161 | (is (= 200 (:status response)))) 162 | 163 | (let [response (http/get (url-for ::get-all-pets :query-params {:sort "asc"}) 164 | {:headers {"Content-Type" "application/json"}})] 165 | (is (= 200 (:status response))))) 166 | 167 | (deftest schema-errors-test 168 | (let [response (http/post (url-for ::create-pet) {:body (json/encode {:name "Bob" :type "dog" :age "abc"}) 169 | :headers {"Content-Type" "application/json"} 170 | :throw-exceptions false})] 171 | (is (= 400 (:status response))) 172 | (is (= "{:error {:body-params {:age \"(not (integer? abc))\"}}}" 173 | (:body response))))) 174 | 175 | (deftest bad-request-test 176 | (let [response (http/post (url-for ::create-pet) {:body "{\"" 177 | :headers {"Content-Type" "application/json"} 178 | :throw-exceptions false})] 179 | (is (= 400 (:status response))) 180 | (is (= "Cannot deserialise body" 181 | (:body response))))) 182 | 183 | (deftest splat-params-test 184 | (let [response (http/get (url-for ::events :path-params {:topic "foo/bar"}))] 185 | (is (= 200 (:status response))) 186 | (is (= "foo/bar" (:body response))))) 187 | 188 | (deftest swagger-schema-test 189 | (let [response (http/get (url-for ::doc/swagger-json) {})] 190 | (is (= 200 (:status response))) 191 | (is (:body response)) 192 | (let [body (json/parse-string (:body response) keyword)] 193 | (is (get-in body [:paths (keyword "/events/{topic}")])) 194 | (is (= "events" (get-in body [:paths (keyword "/events/{topic}") :get :operationId])))))) 195 | -------------------------------------------------------------------------------- /test/pedestal_api/request_params_test.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.request-params-test 2 | (:require [clojure.test :refer [deftest testing is]] 3 | [pedestal-api.request-params :refer [body-params common-body]] 4 | [pedestal-api.error-handling :refer [error-responses]] 5 | [io.pedestal.interceptor.chain :refer [execute]] 6 | [clojure.java.io :as io] 7 | [cognitect.transit :as transit]) 8 | (:import [java.io ByteArrayOutputStream])) 9 | 10 | (defn transit-encode [body type] 11 | (let [out (ByteArrayOutputStream. 4096) 12 | writer (transit/writer out type)] 13 | (transit/write writer body) 14 | (io/input-stream (.toByteArray out)))) 15 | 16 | (defn bad-body [] 17 | (io/input-stream (.getBytes "{\"foo\"\"}"))) 18 | 19 | (def bad-request-response 20 | {:status 400 21 | :headers {"Content-Type" "text/plain"} 22 | :body "Cannot deserialise body"}) 23 | 24 | (deftest body-parsing-test 25 | (let [enter (fn [ctx] 26 | (execute ctx [error-responses (body-params) common-body]))] 27 | 28 | (testing "json" 29 | (testing "can parse" 30 | (is (= {:foo "bar"} 31 | (get-in (enter {:request {:body (io/input-stream (.getBytes "{\"foo\": \"bar\"}")) 32 | :content-type "application/json"}}) 33 | [:request :body-params])))) 34 | 35 | (testing "and reject" 36 | (is (= bad-request-response 37 | (:response (enter {:request {:body (bad-body) 38 | :content-type "application/json"}})))))) 39 | 40 | (testing "edn" 41 | (testing "can parse" 42 | (is (= {:foo "bar"} 43 | (get-in (enter {:request {:body (io/input-stream (.getBytes (pr-str {:foo "bar"}))) 44 | :content-type "application/edn"}}) 45 | [:request :body-params])))) 46 | 47 | (testing "and reject" 48 | (is (= bad-request-response 49 | (:response (enter {:request {:body (bad-body) 50 | :content-type "application/edn"}})))))) 51 | 52 | (testing "transit+json" 53 | (testing "can parse" 54 | (is (= {:foo "bar"} 55 | (get-in (enter {:request {:body (transit-encode {:foo "bar"} :json) 56 | :content-type "application/transit+json"}}) 57 | [:request :body-params])))) 58 | 59 | (testing "and reject" 60 | (is (= bad-request-response 61 | (:response (enter {:request {:body (bad-body) 62 | :content-type "application/transit+json"}})))))) 63 | 64 | (testing "transit+msgpack" 65 | (testing "can parse" 66 | (is (= {:foo "bar"} 67 | (get-in (enter {:request {:body (transit-encode {:foo "bar"} :msgpack) 68 | :content-type "application/transit+msgpack"}}) 69 | [:request :body-params]))))) 70 | 71 | ;; cannot make a transit+json string that cannot be deserialised! 72 | )) 73 | -------------------------------------------------------------------------------- /test/pedestal_api/sse_client.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.sse-client 2 | (:require [clojure.core.async :as a] 3 | [clojure.java.io :as io] 4 | [clojure.string :as string] 5 | [clj-http.client :as http]) 6 | (:import [java.io InputStream])) 7 | 8 | ;; from https://gist.github.com/oliyh/2b9b9107e7e7e12d4a60e79a19d056ee 9 | 10 | (def event-mask (re-pattern (str "(?s).+?\r\n\r\n"))) 11 | 12 | (defn- parse-event [raw-event] 13 | (->> (re-seq #"(.*): (.*)\n?" raw-event) 14 | (map #(drop 1 %)) 15 | (group-by first) 16 | (reduce (fn [acc [k v]] 17 | (assoc acc (keyword k) (string/join (map second v)))) {}))) 18 | 19 | (defn connect [url & [params]] 20 | (let [event-stream ^InputStream (:body (http/get url (merge params {:as :stream}))) 21 | events (a/chan (a/sliding-buffer 10) (map parse-event))] 22 | (a/thread 23 | (loop [data nil] 24 | (let [byte-array (make-array Byte/TYPE (max 1 (.available event-stream))) 25 | bytes-read (.read event-stream byte-array)] 26 | 27 | (if (neg? bytes-read) 28 | 29 | (do (println "Input stream closed, exiting read-loop") 30 | (.close event-stream)) 31 | 32 | (let [data (str data (slurp (io/input-stream byte-array)))] 33 | 34 | (if-let [es (not-empty (re-seq event-mask data))] 35 | (if (every? true? (map #(a/>!! events %) es)) 36 | (recur (string/replace data event-mask "")) 37 | (do (println "Output stream closed, exiting read-loop") 38 | (.close event-stream))) 39 | 40 | (recur data))))))) 41 | events)) 42 | -------------------------------------------------------------------------------- /test/pedestal_api/sse_test.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.sse-test 2 | (:require [clojure.test :refer :all] 3 | [pedestal-api 4 | [core :as api] 5 | [test-fixture :as tf] 6 | [sse-client :as sse-client] 7 | [content-negotiation :as cn]] 8 | [schema.core :as s] 9 | [io.pedestal.http.sse :as sse] 10 | [io.pedestal.interceptor :refer [interceptor]] 11 | [clojure.core.async :as a] 12 | [clj-http.client :as http] 13 | [cheshire.core :as json]) 14 | (:import [java.io InputStream])) 15 | 16 | (defn- initialise-stream [event-channel context] 17 | (a/go-loop [i 0] 18 | (a/>! event-channel {:data {:content i}}) 19 | (a/ ctx 31 | (assoc-in [:response :headers "Content-Type"] "json/event-stream; charset=UTF-8") 32 | (update-in [:response-channel] #(a/map (fn [event] (println "got an event!" event) event) [%]))))}) 33 | 34 | 35 | ;; more intrusive method but gives full control 36 | 37 | (defn start-event-stream 38 | ([stream-ready-fn] 39 | (start-event-stream stream-ready-fn 10 10)) 40 | ([stream-ready-fn heartbeat-delay] 41 | (start-event-stream stream-ready-fn heartbeat-delay 10)) 42 | ([stream-ready-fn heartbeat-delay bufferfn-or-n] 43 | (start-event-stream stream-ready-fn heartbeat-delay bufferfn-or-n {})) 44 | ([stream-ready-fn heartbeat-delay bufferfn-or-n opts] 45 | (interceptor 46 | {:name (keyword (str (gensym "pedestal-api.sse/start-event-stream"))) 47 | :enter (fn [{:keys [request] :as ctx}] 48 | 49 | ;; todo should have already had content negotiated by this point, should be able to use that 50 | ;; should be able to set produces and consumes on this interceptor 51 | 52 | (sse/start-stream 53 | (fn [out-ch context] 54 | (let [event-ch (a/chan 1 (map (fn [event] 55 | (let [e (if (map? event) event {:data event})] 56 | (update e :data json/encode)))))] 57 | (a/pipe event-ch out-ch) 58 | (stream-ready-fn event-ch context))) 59 | ctx heartbeat-delay bufferfn-or-n opts)) 60 | :leave (fn [ctx] 61 | (assoc-in ctx [:response :headers "Content-Type"] "json/event-stream; charset=UTF-8"))}))) 62 | 63 | (def plain-events 64 | (api/annotate 65 | {:summary "Plain-text event stream" 66 | :description "Broadcasts random numbers" 67 | :parameters {}} 68 | (assoc (sse/start-event-stream initialise-stream) 69 | :name ::plain-events))) 70 | 71 | (def json-events 72 | (api/annotate 73 | {:summary "JSON event stream" 74 | :description "Broadcasts random numbers" 75 | :parameters {}} 76 | (assoc (start-event-stream initialise-stream) 77 | :name ::json-events))) 78 | 79 | (s/with-fn-validation 80 | (api/defroutes routes 81 | {} 82 | [[["/" ^:interceptors [api/error-responses 83 | (api/negotiate-response) 84 | (api/body-params) 85 | api/common-body 86 | (api/coerce-request) 87 | (api/validate-response)] 88 | ["/plain-events" {:get plain-events}] 89 | ["/json-events" {:get json-events}] 90 | ["/swagger.json" {:get api/swagger-json}]]]])) 91 | 92 | (use-fixtures :once (tf/with-server routes)) 93 | 94 | (defn- n-vec [n ch] 95 | (first (a/alts!! [(a/into [] (a/take n ch)) (a/timeout 1000)]))) 96 | 97 | (deftest plain-sse-test 98 | (testing "plain events are just strings" 99 | (is (= "text/event-stream; charset=UTF-8" 100 | (get-in (http/get (tf/url-for routes ::plain-events) {:as :stream}) 101 | [:headers "Content-Type"]))) 102 | 103 | (let [events (a/map :data [(sse-client/connect (tf/url-for routes ::plain-events))])] 104 | (is (= ["{:content 0}" "{:content 1}" "{:content 2}" "{:content 3}" "{:content 4}"] 105 | (n-vec 5 events)))))) 106 | 107 | (deftest json-sse-test 108 | (testing "json events keep numbers as numbers" 109 | (is (= "json/event-stream; charset=UTF-8" 110 | (get-in (http/get (tf/url-for routes ::json-events) {:headers {"Accept" "json/event-stream"} 111 | :as :stream}) 112 | [:headers "Content-Type"]))) 113 | 114 | (let [events (a/map :data [(sse-client/connect (tf/url-for routes ::json-events))])] 115 | (is (= [{:content 0} {:content 1} {:content 2} {:content 3} {:content 4}] 116 | (map #(json/decode % keyword) (n-vec 5 events))))))) 117 | -------------------------------------------------------------------------------- /test/pedestal_api/test_fixture.clj: -------------------------------------------------------------------------------- 1 | (ns pedestal-api.test-fixture 2 | (:require [io.pedestal.http :as bootstrap] 3 | [io.pedestal.http.route :refer [url-for-routes]] 4 | [cognitect.transit :as transit]) 5 | (:import [java.io ByteArrayInputStream ByteArrayOutputStream])) 6 | 7 | (def port 8082) 8 | 9 | (defn service [routes] 10 | {:env :dev 11 | ::bootstrap/routes routes 12 | ::bootstrap/router :linear-search 13 | ::bootstrap/resource-path "/public" 14 | ::bootstrap/type :jetty 15 | ::bootstrap/join? false}) 16 | 17 | (defn url-for [routes handler & args] 18 | (apply (url-for-routes routes 19 | :absolute? true 20 | :request {:scheme "http" 21 | :server-name "localhost" 22 | :server-port port}) 23 | handler 24 | args)) 25 | 26 | (defn with-server [routes] 27 | (fn [f] 28 | (let [server (bootstrap/start (bootstrap/create-server 29 | (-> (service routes) 30 | (merge {::bootstrap/port port}) 31 | (bootstrap/default-interceptors))))] 32 | (try 33 | (f) 34 | (finally (bootstrap/stop server)))))) 35 | 36 | (defn transit-read-bytes [bytes type] 37 | (transit/read (transit/reader (ByteArrayInputStream. bytes) type))) 38 | 39 | (defn transit-write-bytes [body type] 40 | (let [out (ByteArrayOutputStream. 4096) 41 | writer (transit/writer out type)] 42 | (transit/write writer body) 43 | (.toByteArray out))) 44 | --------------------------------------------------------------------------------