├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------