summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore12
-rw-r--r--.travis.yml16
-rw-r--r--CHANGELOG.md50
-rw-r--r--CONTRIBUTING.md9
-rw-r--r--LICENSE201
-rw-r--r--README.md291
-rw-r--r--dev-resources/config/jetty/ssl/certs/ca.pem30
-rw-r--r--dev-resources/config/jetty/ssl/certs/localhost.pem33
-rw-r--r--dev-resources/config/jetty/ssl/private_keys/localhost.pem51
-rw-r--r--dev-resources/logback-test.xml11
-rw-r--r--dev-resources/ssl/cert.pem31
-rwxr-xr-xext/travisci/test.sh3
-rwxr-xr-xjenkins/deploy.sh15
-rw-r--r--project.clj44
-rw-r--r--src/puppetlabs/ring_middleware/common.clj53
-rw-r--r--src/puppetlabs/ring_middleware/core.clj214
-rw-r--r--src/puppetlabs/ring_middleware/utils.clj93
-rw-r--r--test/puppetlabs/ring_middleware/core_test.clj635
-rw-r--r--test/puppetlabs/ring_middleware/testutils/common.clj23
-rw-r--r--test/puppetlabs/ring_middleware/utils_test.clj25
20 files changed, 1840 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e0d8dff
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.nrepl-port
+pom.xml
+pom.xml.asc
+*jar
+/lib/
+/classes/
+/target/
+/checkouts/
+.lein-deps-sum
+.lein-repl-history
+.lein-plugins/
+.lein-failures
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..1bf409f
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,16 @@
+language: clojure
+lein: lein2
+jdk:
+- oraclejdk7
+- openjdk7
+script: ./ext/travisci/test.sh
+notifications:
+email: false
+
+# workaround for buffer overflow issue, ref https://github.com/travis-ci/travis-ci/issues/522e
+before_install:
+ - cat /etc/hosts # optionally check the content *before*
+ - sudo hostname "$(hostname | cut -c1-63)"
+ - sed -e "s/^\\(127\\.0\\.0\\.1.*\\)/\\1 $(hostname | cut -c1-63)/" /etc/hosts | sudo tee /etc/hosts
+ - cat /etc/hosts # optionally check the content *after*
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d95bb00
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,50 @@
+# 1.0.0
+#### Breaking Changes
+* Moves from `{:type ... :message ...}` to `{:kind ... :msg ...}` for
+ exceptions and error responses.
+* Moves schemas and helpers previously defined in `core` namespace into new `utils` namespace.
+
+# 0.3.1
+* This is a bug-fix release for a regression in wrap-proxy.
+* All middleware now have the `:always-validate` metadata
+ set for schema validation.
+
+# 0.3.0
+* This version adds many middleware that are used in other
+ puppetlabs projects. These middleware are mostly for logging
+ and error handling, and they are all documented in the
+ [README](./README.md):
+ * `wrap-request-logging`
+ * `wrap-response-logging`
+ * `wrap-service-unavailable`
+ * `wrap-bad-request`
+ * `wrap-data-errors`
+ * `wrap-schema-errors`
+ * `wrap-uncaught-errors`
+* Additionally, this version fixes
+ [an issue](https://tickets.puppetlabs.com/browse/TK-228) with the
+ behavior of `wrap-proxy` and its handling of redirects.
+
+# 0.2.1
+* Add wrap-with-certificate-cn middleware that adds a `:ssl-client-cn` key
+ to the request map if a `:ssl-client-cert` is present.
+* Add wrap-with-x-frame-options-deny middleware that adds `X-Frame-Options: DENY`
+
+# 0.2.0
+* Modify behavior of regex support in the wrap-proxy function.
+ Now, when a regex is given for the `proxied-path` argument,
+ the entirety of the request uri's path will be appended onto
+ the path of `remote-uri-base`.
+* Add a new utility middleware, `wrap-add-cache-headers`,
+ that adds `cache-control` headers to `GET` and `PUT`
+ requests.
+
+# 0.1.3
+* Log proxied requests
+* Allow `proxied-path` argument in `wrap-proxy` function to
+ be a regular expression
+* Bump http-client to v0.2.8
+
+# 0.1.2
+* Add support for redirect following on proxy requests
+* Fix issue where Gzipped proxy responses were being truncated
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8890fe7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+# How to contribute
+
+Third-party patches are essential for keeping puppet open-source projects
+great. We want to keep it as easy as possible to contribute changes that
+allow you to get the most out of our projects. There are a few guidelines
+that we need contributors to follow so that we can have a chance of keeping on
+top of things. For more info, see our canonical guide to contributing:
+
+[https://github.com/puppetlabs/puppet/blob/master/CONTRIBUTING.md](https://github.com/puppetlabs/puppet/blob/master/CONTRIBUTING.md)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5c304d1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..04c42c7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,291 @@
+# ring-middleware
+
+[![Build Status](https://travis-ci.org/puppetlabs/ring-middleware.png?branch=master)](https://travis-ci.org/puppetlabs/ring-middleware)
+
+This project was originally adapted from tailrecursion's
+[ring-proxy](https://github.com/tailrecursion/ring-proxy) middleware, and is
+meant for use with the [Trapperkeeper Jetty9 Webservice](https://github.com/puppetlabs/trapperkeeper-webserver-jetty9). It also contains common ring middleware between Puppet projects and helpers to be used with the middleware.
+
+## Usage
+
+
+To use `ring-middleware`, add this project as a dependency in your leiningen project file:
+
+[![Clojars Project](http://clojars.org/puppetlabs/ring-middleware/latest-version.svg)](https://clojars.org/puppetlabs/ring-middleware)
+
+## Schemas
+
+ * `ResponseType` -- one of the two supported response types (`:json`, `:plain`) returned by many middleware.
+ * `RingRequest` -- a map containing at least a `:uri`, optionally a valid certificate, and any number of keyword-Any pairs.
+ * `RingResponse` -- a map with at least `:status`, `:headers`, and `:body` keys.
+
+
+## Non-Middleware Helpers
+### json-response
+```clj
+(json-response status body)
+```
+Creates a basic ring response with `:status` of `status` and a `:body` of `body` serialized to json.
+
+### plain-response
+```clj
+(plain-response status body)
+```
+Creates a basic ring response with `:status` of `status` and a `:body` of `body` set to UTF-8 plain text.
+
+
+### throw-bad-request!
+```clj
+(throw-bad-request! "Error Message")
+```
+Throws a :bad-request type slingshot error with the supplied message.
+See `wrap-bad-request` for middleware designed to compliment this function,
+also `bad-request?` for a function to help implement your own error handling.
+
+### throw-service-unavailable!
+```clj
+(throw-service-unavailable! "Error Message")
+```
+Throws a :service-unavailable type slingshot error with the supplied message.
+See `wrap-service-unavailable` for middleware designed to compliment this function,
+also `service-unavailable?` for a function to help implement your own error handling.
+
+### throw-data-invalid!
+```clj
+(throw-data-invalid! "Error Message")
+```
+Throws a :data-invalid type slingshot error with the supplied message.
+See `wrap-data-errors` for middleware designed to compliment this function,
+also `data-invalid?` for a function to help implement your own error handling.
+
+
+### bad-request?
+```clj
+(try+ (handler request)
+ (catch bad-request? e
+ (...handle a bad request...)))
+```
+Determines if the supplied slingshot error map is for a bad request.
+
+### service-unavailable?
+```clj
+(try+ (handler request)
+ (catch service-unavailable? e
+ (...handle service unavailability...)))
+```
+Determines if the supplied slingshot error map is for the service being unavailable.
+
+### data-invalid?
+```clj
+(try+ (handler request)
+ (catch data-invalid? e
+ (...handle invalid data...)))
+```
+Determines if the supplied slingshot error map is for invalid data.
+
+### schema-error?
+```clj
+(try+ (handler request)
+ (catch schema-error? e
+ (...handle schema error...)))
+```
+Determines if the supplied slingshot error map is for a schema error.
+
+
+
+## Middleware
+### wrap-request-logging
+```clj
+(wrap-request-logging handler)
+```
+Logs the `:request-method` and `:uri` at debug level, the full request at trace. At the trace level, attempts to remove sensitive auth information and replace client certificate with the client's common name.
+
+### wrap-response-logging
+```clj
+(wrap-response-logging handler)
+```
+Logs the response at the trace log level.
+
+### wrap-proxy
+```clj
+(wrap-proxy handler proxied-path remote-uri-base & [http-opts])
+```
+
+This function returns a ring handler that, when given a URL with a certain prefix, proxies the request
+to a remote URL specified by the `remote-uri-base` argument.
+
+The arguments are as follows:
+
+* `handler`: A ring-handler that will be used if the provided url does not begin with the proxied-path prefix
+* `proxied-path`: The URL prefix of all requests that are to be proxied. This can be either a string or a
+ regular expression pattern. Note that, when this is a regular expression, the entire request URI
+ will be appended to `remote-uri-base` when the URI is being rewritten, whereas if this argument
+ is a string, the `proxied-path` will not be included.
+* `remote-uri-base`: The base URL that you want to proxy requests with the `proxied-path` prefix to
+* `http-opts`: An optional list of options for an http client. This is used by the handler returned by
+ `wrap-proxy` when it makes a proxied request to a remote URI. For a list of available options, please
+ see the options defined for [clj-http-client](https://github.com/puppetlabs/clj-http-client).
+
+For example, the following:
+
+```clj
+(wrap-proxy handler "/hello-world" "http://localhost:9000/hello")
+```
+would return a ring handler that proxies all requests with URL prefix "/hello-world" to
+`http://localhost:9000/hello`.
+
+The following:
+
+```clj
+(wrap-proxy handler #"^/hello-world" "http://localhost:9000/hello")
+```
+would return a ring handler that proxies all requests with a URL path matching the regex
+`#^/hello-world"` to `http://localhost:9000/hello/[url-path]`.
+
+#### Proxy Redirect Support
+
+By default, all proxy requests using `wrap-proxy` will follow any redirects, including on POST and PUT
+requests. To allow redirects but restrict their use on POST and PUT requests, set the `:force-redirects`
+option to `false` in the `http-opts` map. To disable redirect following on proxy requests, set the
+`:follow-redirects` option to `false` in the `http-opts` map. Please not that if proxy redirect following
+is disabled, you may have to disable it on the client making the proxy request as well if the location returned
+by the redirect is relative.
+
+#### SSL Support
+
+`wrap-proxy` supports SSL. To add SSL support, you can set SSL options in the `http-opts` map as you would in
+a request made with [clj-http-client](https://github.com/puppetlabs/clj-http-client). Simply set the
+`:ssl-cert`, `:ssl-key`, and `:ssl-ca-cert` options in the `http-opts` map to be paths to your .pem files.
+
+### wrap-with-certificate-cn
+
+This middleware adds a `:ssl-client-cn` key to the request map if a
+`:ssl-client-cert` is present. If no client certificate is present,
+ the key's value is set to nil. This makes for easier certificate
+whitelisting (using the cert whitelisting function from pl/kitchensink)
+
+### wrap-add-cache-headers
+
+A utility middleware with the following signature:
+
+```clj
+(wrap-add-cache-headers handler)
+```
+
+This middleware adds `cache-control` headers ("private, max-age=0, no-cache") to `GET` and `PUT` requests if they are handled by the handler.
+
+### wrap-add-x-frame-options-deny
+
+A utility middleware with the following signature:
+
+```clj
+(wrap-add-x-frame-options-deny handler)
+```
+
+This middleware adds `X-Frame-Options: DENY` headers to requests if they are handled by the handler.
+
+### wrap-data-errors
+```clj
+(wrap-data-errors handler)
+```
+Always returns a status code of 400 to the client and logs the error message at the "error" log level.
+Catches and processes any exceptions thrown via `slingshot/throw+` with a `:type` of one of:
+ * `:request-data-invalid`
+ * `:user-data-invalid`
+ * `:data-invalid`
+ * `:service-status-version-not-found`
+
+Returns a basic ring response map with the `:body` set to the JSON serialized representation of the exception thrown wrapped in a map and accessible by the "error" key.
+
+Example return body:
+```json
+{
+ "error": {
+ "type": "user-data-invalid",
+ "message": "Error Message From Thrower"
+ }
+}
+```
+
+Returns valid [`ResponseType`](#schemas)s, eg:
+```clj
+(wrap-data-errors handler :plain)
+```
+
+### wrap-bad-request
+```clj
+(wrap-bad-request handler)
+```
+Always returns a status code of 400 to the client and logs the error message at the "error" log level.
+Catches and processes any exceptions thrown via `slingshot/throw+` with a `:type` of one of:
+ * `:bad-request`
+
+Returns a basic ring response map with the `:body` set to the JSON serialized representation of the exception thrown wrapped in a map and accessible by the "error" key.
+
+Example return body:
+```json
+{
+ "error": {
+ "type": "bad-request",
+ "message": "Error Message From Thrower"
+ }
+}
+```
+
+Returns valid [`ResponseType`](#schemas)s, eg:
+```clj
+(wrap-bad-request handler :plain)
+```
+
+### wrap-schema-errors
+```clj
+(wrap-schema-errors handler)
+```
+Always returns a status code of 500 to the client and logs an message containing the schema error, expected value, and exception type at the "error" log level.
+
+Returns a basic ring response map with the `:body` as the JSON serialized representation of helpful exception information wrapped in a map and accessible by the "error" key. Always returns an error type of "application-error".
+
+Example return body:
+```json
+{
+ "error": {
+ "type": "application-error",
+ "message": "Something unexpected happened: {:error ... :value ... :type :schema.core/error}"
+ }
+}
+```
+
+Returns valid [`ResponseType`](#schemas)s, eg:
+```clj
+(wrap-schema-errors handler :plain)
+```
+
+### wrap-uncaught-errors
+```clj
+(wrap-uncaught-errors handler)
+```
+Always returns a status code of 500 to the client and logs a message with the serialized Exception at the "error" log level.
+
+Returns a basic ring response map with the `:body` set as the JSON serialized representation of helpful exception information wrapped in a map and accessible by the "error" key. Always returns an error type of "application-error".
+
+Example return body:
+```json
+{
+ "error": {
+ "type": "application-error",
+ "message": "Internal Server Error: <serialized Exception>"
+ }
+}
+```
+
+Returns valid [`ResponseType`](#schemas)s, eg:
+```clj
+(wrap-uncaught-errors handler :plain)
+```
+
+## Support
+
+Please log tickets and issues at our [Trapperkeeper Issue Tracker](https://tickets.puppetlabs.com/browse/TK).
+In addition there is a #trapperkeeper channel on Freenode.
+
+Maintainers: Justin Stoller <justin@puppet.com>, Kevin Corcoran <kevin.corcoran@puppet.com>, Nathaniel Smith <nathaniel@puppet.com>
diff --git a/dev-resources/config/jetty/ssl/certs/ca.pem b/dev-resources/config/jetty/ssl/certs/ca.pem
new file mode 100644
index 0000000..e11d220
--- /dev/null
+++ b/dev-resources/config/jetty/ssl/certs/ca.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQsFADAfMR0wGwYDVQQDDBRQdXBw
+ZXQgQ0E6IGxvY2FsaG9zdDAeFw0xNDAyMTQxODA5MDdaFw0xOTAyMTQxODA5MDda
+MB8xHTAbBgNVBAMMFFB1cHBldCBDQTogbG9jYWxob3N0MIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEA5vYnoJ85k6qcUFzWFOr9MN2ZWFlgA6nB0Adfm8Yg
+ovg963NrauwTcoPqbfGhi53A8RWc5GT5x1OSjxQW/PAGGfGg3aHZuQMClYWsauod
+CYG6YgG49WGZODCHZ+TbHMN1pNV+6S+tnZbUtfVKsN347XyHCyrymmb/OQNAxPyO
+/76dDR4dB7kWKP4KMMOgDAnA+WDpD1d9/UfBPVZtw/sTyjeJGEOZqSXQW93PKumJ
+9/DS4azsUMR1JwJA67yWffoHb6sL7QSAQEfp17gDs9asIzITZPPyHNslmY55zaSW
+EslzFGqPvB7ugUbqH0rjp2TjQ/9Nw7ZKBxukFB9cBUr7v21D2Of2SHQorzRe9lXO
+wO3BYEt/viypktDnH42qAeoLHDK1ay9ugByTm4ARj14CjslpkEKflk9t9XwUR3ku
+Uaj3w+Y5ItZXFBxns1OQpDt3bMhJFemj7MxkZXt8tvWlUMKVmCUqbnG1eDdTF4Vd
+OovdacMDpvKg438I8MCjaHQf90qIp8MaOjdfSoKBCQi6AP7cpjB+x9xhEuetPYcb
+/bNjV1OFo4h77XJ+lIH58HBpEG0zkqhtS7JqICIzp3GK7ZG3bhfKKJz0Qr4ISxI0
+YsYWdmsG38SyMwcomMy4WoLiuQF2QusBSHwBS+p5qQyNkJmXEy8KSQmrrIB/bkiS
+mgkCAwEAAaN5MHcwNQYJYIZIAYb4QgENBChQdXBwZXQgUnVieS9PcGVuU1NMIElu
+dGVybmFsIENlcnRpZmljYXRlMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD
+AQH/MB0GA1UdDgQWBBRZy3D0vQGiBu3mnggq8jiEZERoXzANBgkqhkiG9w0BAQsF
+AAOCAgEAZHcyah146g2vmuQZsGJtljCkWjM1WWjcPoLewdd4FG1YKkAgbGGPVnSS
+yIS04tj4/Z3eePUlBNe3ZHs7yTQkq3SwWIhRSMJRA8xAjtL0rL7mvJ6PMV+Sujb7
++ENTML76+oq1d7XTqAh8mnSJOIc6eMUQqBBYl/5BYa4IzkvkrS4ai4vg0ihKr6pf
+C8XVINTjxChqK08dEr690sPD/3DwPPVGqG+qIyIv6u5buVLniLWiaq4HOG54i/yH
+k21W6A5UizRxb5GwVxWfjsHcLr2pvPq/ipRP/sCNBgKdziaGQm+IqeJiI6bQegx3
+ZF5UX2CaIkZsEM0v2yQ7/t9rSCfpd/eEYMdcWcnSfEf/OOA/ti7jnpapENXturLo
+6/TNJarHYQlkpEsaRfiiSmomAn3TNOOuqBhWtj0fdy21/fETxVVzPp/CL7EvkUnU
+26SguZdBkGf22yZViKRq22eDnM/frsoSMTkIbkayDQ8Hx9JVpfPmdJWQyL86etsd
+uCR5OXJtd7vxRZT15m2cFvdpRW3VZbqFyIwe0NgJUs3FRrjFZdqQiBsvWwQjK8jN
+A3+IAAz4pk0A4xpQvNwvUzo4wyQB8/PTcmZoPBzeDaJScqSto2r3kvOSqvm1PSc6
+gneGkbPvQpQRH9HVxzaEEcCrcZYFZXpCkF0ACuB+smgfMVY2Sno=
+-----END CERTIFICATE-----
diff --git a/dev-resources/config/jetty/ssl/certs/localhost.pem b/dev-resources/config/jetty/ssl/certs/localhost.pem
new file mode 100644
index 0000000..faa309c
--- /dev/null
+++ b/dev-resources/config/jetty/ssl/certs/localhost.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFnjCCA4agAwIBAgIBAjANBgkqhkiG9w0BAQsFADAfMR0wGwYDVQQDDBRQdXBw
+ZXQgQ0E6IGxvY2FsaG9zdDAeFw0xNDAyMTQxODA5MDdaFw0xOTAyMTQxODA5MDda
+MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
+AgoCggIBALzn4xcbGhSX65fgSIo+NB4EOYX+zJxbHamv+hbQkhAxqHxUSszDD86+
+s9ZsN8cvpDIRex1/SfkNRKhgVUHGiUYbol+9RLpgpdHYLJCY0S0kOyBUgWqEcQrA
+tVDYpwaNC5qMneiZkcNvQ74pnuFOa0/bzbpLyOjYKEQq3BXK32yGLLHseb/PIX1o
+bqcuYiR6yI7S3wK9hJzSo6BRPSz0cNZNpnXbW/yLRtHbthBtzTifM2MW2jtWuFvg
+OW1ly9vxwTHCrJv5KxMubVDrqAx+RmmOsn327APO3r6NUr2CzV1vG8CMqLApse6m
+dKpCZ5UNLm4dwCUe2zWAQ0Q2Eba88O480bH8k/t8NUGXlWt25B8BUE8rklY0jwSw
+v8Dyjda0moeoxgMMZb+ogPjTY/Ds/h2hgFzy/DUuGrv6kyVxzH0/pOg29HeDoUaK
+IaqjAsN8h/oYTJ6CiOKAqWfbi8JZRmiEVTekskS5eySsPqPCegfAkfpIlz4EU4FN
+0A2vORI7epW4mNiJcCLb09v5jogK+DhfeSscDsrYgIF8eAPLw6r9h1am+vXoD6vt
+tIwZBu15pnCWXdDcKWBNjx+zYU648iZ9V/qFG31uTJaw0z18eFPyTcJN8StTnoGZ
+zjKI2AyjG5F6ov/S7mnU3I/wv0B5Vq6fPjSvMrsBlbBdI3Pbr3w/AgMBAAGjge8w
+gewwNQYJYIZIAYb4QgENBChQdXBwZXQgUnVieS9PcGVuU1NMIEludGVybmFsIENl
+cnRpZmljYXRlMFQGA1UdEQRNMEuCG2Rqcm9vbWJhLnZwbi5wdXBwZXRsYWJzLm5l
+dIIJbG9jYWxob3N0ggZwdXBwZXSCGXB1cHBldC52cG4ucHVwcGV0bGFicy5uZXQw
+DgYDVR0PAQH/BAQDAgWgMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBRZy3D0vQGiBu3mnggq8jiEZERoXzAN
+BgkqhkiG9w0BAQsFAAOCAgEAJwDAK7UKZvLtSJkdprO/Z0qALdUSliO0+I6lsSr/
+A8SyilfMQuxOoq6uWA6j7uMU4SAHwT14QD9c6BJJiBhWLo6HoynZfYK8Smn1q2Xy
+vNMnUEUEjcWIgIqEDP98RTUZldgR4aWQkQVHB9XY8g6F0qWzBq64qLWjfrsIUijF
+Z3Ex1OMU7ZMjHhoKk2J3oBcMau47mqU49C9MoWkBH+0fLLr+lxoa40DPcFr+KzhI
+BHdTKuKAEJEkiE5QNVWl3+M7psFZzdTd+YFz9Vn/9L3aQr8KCb4oMietp84KM0yR
+6GwodvIjh/3owsnvNvl28HeZGWQMCNIlG0aWx3JIfCOHSYlXfQ55FCtLRqMflJty
+M4MqRkPHKykZMZmNoiTtXRSz3vMW8JnIyZPsNtfIGltnpjHd4Y4EEVxdZhlL2wBv
+YycI6PN1oyhv/9ZcIbSaVY4jEpxx6mrsG8iuaT1YHlO2HOwuqvXJ48WkT1Q6go9k
+A775N00y4jLJXqWchiuP9Hp7AGYWzoKXFDmQbUyl0jpZsrfoTskb90xp6R7+IO7K
+6opzTahhO9QeURqc7lkdvwjiYMw7uIiTT6IAKtYeK4f52siiJ/LBWucte5FLCjvq
+KkdWF7aQOKZq+L7Cs2nJ/qQQzlDYTQLpAbBWJbVAscoYYGPVPdKrwI4UEbKGIsWs
+wMA=
+-----END CERTIFICATE-----
diff --git a/dev-resources/config/jetty/ssl/private_keys/localhost.pem b/dev-resources/config/jetty/ssl/private_keys/localhost.pem
new file mode 100644
index 0000000..231da6a
--- /dev/null
+++ b/dev-resources/config/jetty/ssl/private_keys/localhost.pem
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+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
+-----END RSA PRIVATE KEY-----
diff --git a/dev-resources/logback-test.xml b/dev-resources/logback-test.xml
new file mode 100644
index 0000000..b13ebc2
--- /dev/null
+++ b/dev-resources/logback-test.xml
@@ -0,0 +1,11 @@
+<configuration scan="true">
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d %-5p [%c{2}] %m%n</pattern>
+ </encoder>
+ </appender>
+
+ <root level="warn">
+ <appender-ref ref="STDOUT" />
+ </root>
+</configuration>
diff --git a/dev-resources/ssl/cert.pem b/dev-resources/ssl/cert.pem
new file mode 100644
index 0000000..4d9b583
--- /dev/null
+++ b/dev-resources/ssl/cert.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFSjCCAzKgAwIBAgIBAzANBgkqhkiG9w0BAQsFADAfMR0wGwYDVQQDDBRQdXBw
+ZXQgQ0E6IGV4cGxvc2l2bzAeFw0xMzAxMDEyMTQ0MjhaFw0xODAxMDEyMTQ0Mjha
+MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
+AgoCggIBANCEYBvEIdTXFOMwz18wahs6tg26C+LT2XOwQspb/Aj5WT2EBwnG2leD
+CzfOAKyhHDL6jRqrYU32jqoqBzmzkeVsHzqOMNFosyvcBLU8zyLZU+IP1rjJCyE8
+xx9HsdhPKJj93f/gSNR5NQlRcZfqahhOwh/nYdY3pFiNgjUoRwhV2Q01n+ku8WJw
+kLVT1TREW9TiSWk7cHWF/ZltPOMMxvJ9q0kXh8sVYK4Gtt3pphTUW0qgXQ2NnNWT
+W+7vciRjnHxeoY3q6ZG7vZ8HewYKR4W8D6FA32xCsWELSsWlAABt1lBjKGas/fiY
+SeDqSfIxknFn/CIM9AIp2PLS3wh5e0o98qey9AN2WRyG7Qs0ijhwKx9bsMxbM0LR
+5jXuXjBnjGQ69fwCjwlUsOSpNPWLibM+GmxvhghJgAlH5dD4+GqN77WLncQTWYXX
+GnOw5efVivS4bgU3t8l8mHLH6quLolR1KLfCv+HuqkvRposAqqLwKH+dhlbq1Y+i
+4siPxfYV5NZ092Z9R0F4BPEmLhKngkK+/eQXxLY2zfaR6Ns83yRJfMXRyElECX/+
+RBT1LyIRZg+MbsRg7DsKWI0plzxso/4CgSmYSfPku5nkekrMN34YhUtcxsdHSmY1
+5/p2olvKpTJj3e5fa2KVswcv77FsC17gIfMXqvN3tITP+q1LLJHNAgMBAAGjgZsw
+gZgwNwYJYIZIAYb4QgENBCoWKFB1cHBldCBSdWJ5L09wZW5TU0wgSW50ZXJuYWwg
+Q2VydGlmaWNhdGUwDgYDVR0PAQH/BAQDAgWgMCAGA1UdJQEB/wQWMBQGCCsGAQUF
+BwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBT3awXrPXSWNszZ
+tlLKoXQ8LRjJ3DANBgkqhkiG9w0BAQsFAAOCAgEAYTFx++uptZxgptFmkPfT1f2W
+6djOOVULmlLGPC6Ovbe5v0ksA2hbLW3eSmfL28Ku0WC8gRl0/PhyiyW77M1jp9dV
+ztsFkXjMiIIcY0B7Hgqh1kpK1CFvSbsD3piXcDLlZ1CwSAXuohp+J2fUblHRfAUD
+Th9qrm3g4uNFp0wXxO1+GgXeDrGRqYosb0wAhB7/BhW2WbOFtVYdFoyyXJFJYx/3
+Gj7ZTE3rvGGxOEEuww0pFmuGCflZYxEu15Rynej7soGaE80+wRk+gMS26WKwRQ/0
+TGovOHSLo/fpOjjHIoqbQLH3S08jUfAYjjP7Rd01SiztUjZMILC2WCnpdYN+2O9O
+rGTj2Zl3oRtE67NwxgKlo2GIFghSF366XOF8O4z1e9id6u5XEdoz8uGFkHyMu79N
+cdYcUtmAqLvJ0Ubewg+TfNDcfk1akNtHtIJDNqFwHlZ9R1GIupHQs10R4YxJv3I2
+LojJbtcgWcDg9StwCHRA0SuLrWnHPnm+glzXM5HTNJZ6vcrM0SrcnZ59p3o3ZULL
+JTJikA+pcs+WAWz1yTNg/ywxYPnFrs8A4MEC43XFe2dS96a7VcNqpMMya+DAWWCS
++51lDlMLuz+q5WHDGh3ouTNYMdcSLo8bHzKInDYnK/DzUpdJ5OKYwqfxv5n0bJs6
+fRA2buaQo0zJeid7G2Q=
+-----END CERTIFICATE-----
diff --git a/ext/travisci/test.sh b/ext/travisci/test.sh
new file mode 100755
index 0000000..db011da
--- /dev/null
+++ b/ext/travisci/test.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+lein2 test
diff --git a/jenkins/deploy.sh b/jenkins/deploy.sh
new file mode 100755
index 0000000..3bc667f
--- /dev/null
+++ b/jenkins/deploy.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -e
+set -x
+
+git fetch --tags
+
+lein test
+echo "Tests passed!"
+
+lein release
+echo "Release plugin successful, pushing changes to git"
+
+git push origin --tags HEAD:$RING_MIDDLEWARE_BRANCH
+echo "git push successful."
diff --git a/project.clj b/project.clj
new file mode 100644
index 0000000..ad8f7b2
--- /dev/null
+++ b/project.clj
@@ -0,0 +1,44 @@
+(def clj-version "1.7.0")
+(def ks-version "1.3.0")
+(def tk-version "1.3.1")
+
+(defproject puppetlabs/ring-middleware "1.0.0"
+ :dependencies [[org.clojure/clojure ~clj-version]
+
+ ;; begin version conflict resolution dependencies
+ [clj-time "0.11.0"]
+ ;; end version conflict resolution dependencies
+
+ [cheshire "5.6.1"]
+ [org.clojure/tools.logging "0.3.1"]
+ [prismatic/schema "1.1.0"]
+
+ [puppetlabs/http-client "0.5.0"]
+ [puppetlabs/kitchensink ~ks-version]
+ [puppetlabs/ssl-utils "0.8.1"]
+ [ring "1.4.0"]
+ [slingshot "0.12.2"]]
+
+ ;; Abort when version ranges or version conflicts are detected in
+ ;; dependencies. Also supports :warn to simply emit warnings.
+ ;; requires lein 2.2.0+.
+ :pedantic? :abort
+
+ :plugins [[lein-release "1.0.5"]]
+
+ :deploy-repositories [["releases" {:url "https://clojars.org/repo"
+ :username :env/clojars_jenkins_username
+ :password :env/clojars_jenkins_password
+ :sign-releases false}]
+ ["snapshots" "http://nexus.delivery.puppetlabs.net/content/repositories/snapshots/"]]
+
+ :profiles {:dev {:dependencies [
+ ;; begin version conflict resolution dependencies
+ [org.clojure/tools.reader "1.0.0-beta1"]
+ [org.clojure/tools.macro "0.1.5"]
+ ;; begin version conflict resolution dependencies
+
+ [puppetlabs/trapperkeeper-webserver-jetty9 "1.5.5"]
+ [puppetlabs/kitchensink ~ks-version :classifier "test" :scope "test"]
+ [puppetlabs/trapperkeeper ~tk-version :classifier "test" :scope "test"]
+ [compojure "1.5.0"]]}})
diff --git a/src/puppetlabs/ring_middleware/common.clj b/src/puppetlabs/ring_middleware/common.clj
new file mode 100644
index 0000000..d34cf3e
--- /dev/null
+++ b/src/puppetlabs/ring_middleware/common.clj
@@ -0,0 +1,53 @@
+(ns puppetlabs.ring-middleware.common
+ (:require [clojure.tools.logging :as log]
+ [clojure.string :refer [join split replace-first]]
+ [puppetlabs.http.client.sync :refer [request]])
+ (:import (java.net URI)))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;; Private utility functions
+
+(defn prepare-cookies
+ "Removes the :domain and :secure keys and converts the :expires key (a Date)
+ to a string in the ring response map resp. Returns resp with cookies properly
+ munged."
+ [resp]
+ (let [prepare #(-> (update-in % [1 :expires] str)
+ (update-in [1] dissoc :domain :secure))]
+ (assoc resp :cookies (into {} (map prepare (:cookies resp))))))
+
+(defn strip-trailing-slash
+ [url]
+ (if (.endsWith url "/")
+ (.substring url 0 (- (count url) 1))
+ url))
+
+(defn proxy-request
+ [req proxied-path remote-uri-base & [http-opts]]
+ ; Remove :decompress-body from the options map, as if this is
+ ; ever set to true, the response returned to the client making the
+ ; proxy request will be truncated
+ (let [http-opts (dissoc http-opts :decompress-body)
+ uri (URI. (strip-trailing-slash remote-uri-base))
+ remote-uri (URI. (.getScheme uri)
+ (.getAuthority uri)
+ (str (.getPath uri)
+ (if (instance? java.util.regex.Pattern proxied-path)
+ (:uri req)
+ (replace-first (:uri req) proxied-path "")))
+ nil
+ nil)
+ response (-> (merge {:method (:request-method req)
+ :url (str remote-uri "?" (:query-string req))
+ :headers (dissoc (:headers req) "host" "content-length")
+ :body (not-empty (slurp (:body req)))
+ :as :stream
+ :force-redirects false
+ :follow-redirects false
+ :decompress-body false}
+ http-opts)
+ request
+ prepare-cookies)]
+ (log/debug "Proxying request to" (:uri req) "to remote url" (str remote-uri)
+ ". Remote server responded with status" (:status response))
+ response))
diff --git a/src/puppetlabs/ring_middleware/core.clj b/src/puppetlabs/ring_middleware/core.clj
new file mode 100644
index 0000000..f125a25
--- /dev/null
+++ b/src/puppetlabs/ring_middleware/core.clj
@@ -0,0 +1,214 @@
+(ns puppetlabs.ring-middleware.core
+ (:require [cheshire.core :as json]
+ [clojure.tools.logging :as log]
+ [puppetlabs.kitchensink.core :as ks]
+ [puppetlabs.ring-middleware.common :as common]
+ [puppetlabs.ring-middleware.utils :as utils]
+ [puppetlabs.ssl-utils.core :as ssl-utils]
+ [ring.middleware.cookies :as cookies]
+ [ring.util.response :as rr]
+ [schema.core :as schema]
+ [slingshot.slingshot :as sling])
+ (:import (clojure.lang IFn ExceptionInfo)
+ (java.lang Exception)
+ (java.util.regex Pattern)
+ (java.security.cert X509Certificate)))
+
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Private
+
+(defn sanitize-client-cert
+ "Given a ring request, return a map which replaces the :ssl-client-cert with
+ just the certificate's Common Name at :ssl-client-cert-cn. Also, remove the
+ copy of the certificate put on the request by TK-auth."
+ [req]
+ (-> (if-let [client-cert (:ssl-client-cert req)]
+ (-> req
+ (dissoc :ssl-client-cert)
+ (assoc :ssl-client-cert-cn (ssl-utils/get-cn-from-x509-certificate client-cert)))
+ req)
+ (ks/dissoc-in [:authorization :certificate])))
+
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Middleware
+
+(schema/defn ^:always-validate wrap-request-logging :- IFn
+ "A ring middleware that logs the request."
+ [handler :- IFn]
+ (fn [{:keys [request-method uri] :as req}]
+ (log/debug "Processing" request-method uri)
+ (log/trace (str "Full request:\n" (ks/pprint-to-string (sanitize-client-cert req))))
+ (handler req)))
+
+(schema/defn ^:always-validate wrap-response-logging :- IFn
+ "A ring middleware that logs the response."
+ [handler :- IFn]
+ (fn [req]
+ (let [resp (handler req)]
+ (log/trace "Computed response:" resp)
+ resp)))
+
+(schema/defn ^:always-validate wrap-proxy :- IFn
+ "Proxies requests to proxied-path, a local URI, to the remote URI at
+ remote-uri-base, also a string."
+ [handler :- IFn
+ proxied-path :- (schema/either Pattern schema/Str)
+ remote-uri-base :- schema/Str
+ & [http-opts]]
+ (let [proxied-path (if (instance? Pattern proxied-path)
+ (re-pattern (str "^" (.pattern proxied-path)))
+ proxied-path)]
+ (cookies/wrap-cookies
+ (fn [req]
+ (if (or (and (string? proxied-path) (.startsWith ^String (:uri req) (str proxied-path "/")))
+ (and (instance? Pattern proxied-path) (re-find proxied-path (:uri req))))
+ (common/proxy-request req proxied-path remote-uri-base http-opts)
+ (handler req))))))
+
+(schema/defn ^:always-validate wrap-add-cache-headers :- IFn
+ "Adds cache control invalidation headers to GET and PUT requests if they are handled by the handler"
+ [handler :- IFn]
+ (fn [request]
+ (let [request-method (:request-method request)
+ response (handler request)]
+ (when-not (nil? response)
+ (if (or
+ (= request-method :get)
+ (= request-method :put))
+ (assoc-in response [:headers "cache-control"] "private, max-age=0, no-cache")
+ response)))))
+
+(schema/defn ^:always-validate wrap-add-x-frame-options-deny :- IFn
+ "Adds 'X-Frame-Options: DENY' headers to requests if they are handled by the handler"
+ [handler :- IFn]
+ (fn [request]
+ (let [response (handler request)]
+ (when response
+ (assoc-in response [:headers "X-Frame-Options"] "DENY")))))
+
+(schema/defn ^:always-validate wrap-with-certificate-cn :- IFn
+ "Ring middleware that will annotate the request with an
+ :ssl-client-cn key representing the CN contained in the client
+ certificate of the request. If no client certificate is present,
+ the key's value is set to nil."
+ [handler :- IFn]
+ (fn [{:keys [ssl-client-cert] :as req}]
+ (let [cn (some-> ssl-client-cert
+ ssl-utils/get-cn-from-x509-certificate)
+ req (assoc req :ssl-client-cn cn)]
+ (handler req))))
+
+(schema/defn ^:always-validate wrap-data-errors :- IFn
+ "A ring middleware that catches a slingshot error thrown by
+ throw-data-invalid! or a :kind of slingshot error of one of:
+ :request-data-invalid
+ :user-data-invalid
+ :data-invalid
+ :service-status-version-not-found
+ logs the error and returns a 400 ring response."
+ ([handler :- IFn]
+ (wrap-data-errors handler :json))
+ ([handler :- IFn
+ type :- utils/ResponseType]
+ (let [code 400
+ response (fn [e]
+ (log/error "Submitted data is invalid:" (:msg e))
+ (case type
+ :json (utils/json-response code e)
+ :plain (utils/plain-response code (:msg e))))]
+ (fn [request]
+ (sling/try+ (handler request)
+ (catch
+ #(contains? #{:request-data-invalid
+ :user-data-invalid
+ :data-invalid
+ :service-status-version-not-found}
+ (:kind %))
+ e
+ (response e)))))))
+
+(schema/defn ^:always-validate wrap-service-unavailable :- IFn
+ "A ring middleware that catches slingshot errors thrown by
+ utils/throw-service-unavailabe!, logs the error and returns a 503 ring
+ response."
+ ([handler :- IFn]
+ (wrap-service-unavailable handler :json))
+ ([handler :- IFn
+ type :- utils/ResponseType]
+ (let [code 503
+ response (fn [e]
+ (log/error "Service Unavailable:" (:msg e))
+ (case type
+ :json (utils/json-response code e)
+ :plain (utils/plain-response code (:msg e))))]
+ (fn [request]
+ (sling/try+ (handler request)
+ (catch utils/service-unavailable? e
+ (response e)))))))
+
+(schema/defn ^:always-validate wrap-bad-request :- IFn
+ "A ring middleware that catches slingshot errors thrown by
+ utils/throw-bad-request!, logs the error and returns a 503 ring
+ response."
+ ([handler :- IFn]
+ (wrap-bad-request handler :json))
+ ([handler :- IFn
+ type :- utils/ResponseType]
+ (let [code 400
+ response (fn [e]
+ (log/error "Bad Request:" (:msg e))
+ (case type
+ :json (utils/json-response code e)
+ :plain (utils/plain-response code (:msg e))))]
+ (fn [request]
+ (sling/try+ (handler request)
+ (catch utils/bad-request? e
+ (response e)))))))
+
+(schema/defn ^:always-validate wrap-schema-errors :- IFn
+ "A ring middleware that catches schema errors and returns a 500
+ response with the details"
+ ([handler :- IFn]
+ (wrap-schema-errors handler :json))
+ ([handler :- IFn
+ type :- utils/ResponseType]
+ (let [code 500
+ response (fn [e]
+ (let [msg (str "Something unexpected happened: "
+ (select-keys e [:error :value :type]))]
+ (log/error msg)
+ (case type
+ :json (utils/json-response code
+ {:kind :application-error
+ :msg msg})
+ :plain (utils/plain-response code msg))))]
+ (fn [request]
+ (sling/try+ (handler request)
+ (catch utils/schema-error? e
+ (response e)))))))
+
+(schema/defn ^:always-validate wrap-uncaught-errors :- IFn
+ "A ring middleware that catches all otherwise uncaught errors and
+ returns a 500 response with the error message"
+ ([handler :- IFn]
+ (wrap-uncaught-errors handler :json))
+ ([handler :- IFn
+ type :- utils/ResponseType]
+ (let [code 500
+ response (fn [e]
+ (let [msg (str "Internal Server Error: " e)]
+ (log/error msg)
+ (case type
+ :json (utils/json-response code
+ {:kind :application-error
+ :msg msg})
+ :plain (utils/plain-response code msg))))]
+ (fn [request]
+ (sling/try+ (handler request)
+ (catch Exception e
+ (response e)))))))
+
diff --git a/src/puppetlabs/ring_middleware/utils.clj b/src/puppetlabs/ring_middleware/utils.clj
new file mode 100644
index 0000000..93937ca
--- /dev/null
+++ b/src/puppetlabs/ring_middleware/utils.clj
@@ -0,0 +1,93 @@
+(ns puppetlabs.ring-middleware.utils
+ (:require [schema.core :as schema]
+ [ring.util.response :as rr]
+ [slingshot.slingshot :as sling]
+ [cheshire.core :as json])
+ (:import (java.security.cert X509Certificate)))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Schemas
+
+(def ResponseType
+ (schema/enum :json :plain))
+
+(def RingRequest
+ {:uri schema/Str
+ (schema/optional-key :ssl-client-cert) (schema/maybe X509Certificate)
+ schema/Keyword schema/Any})
+
+(def RingResponse
+ {:status schema/Int
+ :headers {schema/Str schema/Any}
+ :body schema/Any
+ schema/Keyword schema/Any})
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Helpers
+
+(schema/defn ^:always-validate json-response
+ :- RingResponse
+ [status :- schema/Int
+ body :- schema/Any]
+ (-> body
+ json/encode
+ rr/response
+ (rr/status status)
+ (rr/content-type "application/json; charset=utf-8")))
+
+(schema/defn ^:always-validate plain-response
+ :- RingResponse
+ [status :- schema/Int
+ body :- schema/Str]
+ (-> body
+ rr/response
+ (rr/status status)
+ (rr/content-type "text/plain; charset=utf-8")))
+
+(defn throw-bad-request!
+ "Throw a :bad-request type slingshot error with the supplied message"
+ [message]
+ (sling/throw+ {:kind :bad-request
+ :msg message}))
+
+(defn bad-request?
+ [e]
+ "Determine if the supplied slingshot error is for a bad request"
+ (when (map? e)
+ (= (:kind e)
+ :bad-request)))
+
+(defn throw-service-unavailable!
+ "Throw a :service-unavailable type slingshot error with the supplied message"
+ [message]
+ (sling/throw+ {:kind :service-unavailable
+ :msg message}))
+
+(defn service-unavailable?
+ [e]
+ "Determine if the supplied slingshot error is for an unavailable service"
+ (when (map? e)
+ (= (:kind e)
+ :service-unavailable)))
+
+(defn throw-data-invalid!
+ "Throw a :data-invalid type slingshot error with the supplied message"
+ [message]
+ (sling/throw+ {:kind :data-invalid
+ :msg message}))
+
+(defn data-invalid?
+ [e]
+ "Determine if the supplied slingshot error is for invalid data"
+ (when (map? e)
+ (= (:kind e)
+ :data-invalid)))
+
+(defn schema-error?
+ [e]
+ "Determine if the supplied slingshot error is for a schema mismatch"
+ (when (map? e)
+ (= (:type e)
+ :schema.core/error)))
+
diff --git a/test/puppetlabs/ring_middleware/core_test.clj b/test/puppetlabs/ring_middleware/core_test.clj
new file mode 100644
index 0000000..a1d00a1
--- /dev/null
+++ b/test/puppetlabs/ring_middleware/core_test.clj
@@ -0,0 +1,635 @@
+(ns puppetlabs.ring-middleware.core-test
+ (:require [cheshire.core :as json]
+ [clojure.test :refer :all]
+ [compojure.core :refer :all]
+ [compojure.handler :as handler]
+ [compojure.route :as route]
+ [puppetlabs.ring-middleware.core :as core]
+ [puppetlabs.ring-middleware.utils :as utils]
+ [puppetlabs.ring-middleware.testutils.common :refer :all]
+ [puppetlabs.ssl-utils.core :refer [pem->cert]]
+ [puppetlabs.ssl-utils.simple :as ssl-simple]
+ [puppetlabs.trapperkeeper.app :refer [get-service]]
+ [puppetlabs.trapperkeeper.services.webserver.jetty9-service :refer :all]
+ [puppetlabs.trapperkeeper.testutils.bootstrap :refer [with-app-with-config]]
+ [puppetlabs.trapperkeeper.testutils.logging :as logutils]
+ [ring.util.response :as rr]
+ [schema.core :as schema]
+ [slingshot.slingshot :as slingshot]))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Testing Helpers
+
+
+(def WackSchema
+ [schema/Str])
+
+(schema/defn ^:always-validate cause-schema-error
+ [request :- WackSchema]
+ (throw (IllegalStateException. "The test should have never gotten here...")))
+
+(defn throwing-handler
+ [kind msg]
+ (fn [_] (slingshot/throw+ {:kind kind :msg msg})))
+
+(defn basic-request
+ ([] (basic-request "foo-agent" :get "https://example.com"))
+ ([subject method uri]
+ {:request-method method
+ :uri uri
+ :ssl-client-cert (:cert (ssl-simple/gen-self-signed-cert subject 1))
+ :authorization {:certificate "foo"}}))
+
+(defn post-target-handler
+ [req]
+ (if (= (:request-method req) :post)
+ {:status 200 :body (slurp (:body req))}
+ {:status 404 :body "Z'oh"}))
+
+(defn proxy-target-handler
+ [req]
+ (condp = (:uri req)
+ "/hello" {:status 302 :headers {"Location" "/hello/world"}}
+ "/hello/" {:status 302 :headers {"Location" "/hello/world"}}
+ "/hello/world" {:status 200 :body "Hello, World!"}
+ "/hello/wrong-host" {:status 302 :headers {"Location" "http://localhost:4/fake"}}
+ "/hello/fully-qualified" {:status 302 :headers {"Location" "http://localhost:9000/hello/world"}}
+ "/hello/different-path" {:status 302 :headers {"Location" "http://localhost:9000/different/"}}
+ {:status 404 :body "D'oh"}))
+
+(defn non-proxy-target
+ [_]
+ {:status 200 :body "Non-proxied path"})
+
+(def gzip-body
+ (apply str (repeat 1000 "f")))
+
+(defn proxy-gzip-response
+ [_]
+ (-> gzip-body
+ (rr/response)
+ (rr/status 200)
+ (rr/content-type "text/plain")
+ (rr/charset "UTF-8")))
+
+(defn proxy-error-handler
+ [_]
+ {:status 404 :body "N'oh"})
+
+(defn proxy-regex-response
+ [req]
+ {:status 200 :body (str "Proxied to " (:uri req))})
+
+(defroutes fallthrough-routes
+ (GET "/hello/world" [] "Hello, World! (fallthrough)")
+ (GET "/goodbye/world" [] "Goodbye, World! (fallthrough)")
+ (route/not-found "Not Found (fallthrough)"))
+
+(def proxy-regex-fallthrough
+ (handler/site fallthrough-routes))
+
+(def proxy-wrapped-app
+ (-> proxy-error-handler
+ (core/wrap-proxy "/hello-proxy" "http://localhost:9000/hello")))
+
+(def proxy-wrapped-app-ssl
+ (-> proxy-error-handler
+ (core/wrap-proxy "/hello-proxy" "https://localhost:9001/hello"
+ {:ssl-cert "./dev-resources/config/jetty/ssl/certs/localhost.pem"
+ :ssl-key "./dev-resources/config/jetty/ssl/private_keys/localhost.pem"
+ :ssl-ca-cert "./dev-resources/config/jetty/ssl/certs/ca.pem"})))
+
+(def proxy-wrapped-app-redirects
+ (-> proxy-error-handler
+ (core/wrap-proxy "/hello-proxy" "http://localhost:9000/hello"
+ {:force-redirects true
+ :follow-redirects true})))
+
+(def proxy-wrapped-app-regex
+ (-> proxy-regex-fallthrough
+ (core/wrap-proxy #"^/([^/]+/certificate.*)$" "http://localhost:9000/hello")))
+
+(def proxy-wrapped-app-regex-alt
+ (-> proxy-regex-fallthrough
+ (core/wrap-proxy #"/hello-proxy" "http://localhost:9000/hello")))
+
+(def proxy-wrapped-app-regex-no-prepend
+ (-> proxy-regex-fallthrough
+ (core/wrap-proxy #"^/([^/]+/certificate.*)$" "http://localhost:9000")))
+
+(def proxy-wrapped-app-regex-trailing-slash
+ (-> proxy-regex-fallthrough
+ (core/wrap-proxy #"^/([^/]+/certificate.*)$" "http://localhost:9000/")))
+
+(defmacro with-target-and-proxy-servers
+ [{:keys [target proxy proxy-handler ring-handler endpoint target-endpoint]} & body]
+ `(with-app-with-config proxy-target-app#
+ [jetty9-service]
+ {:webserver ~target}
+ (let [target-webserver# (get-service proxy-target-app# :WebserverService)]
+ (add-ring-handler
+ target-webserver#
+ ~ring-handler
+ ~target-endpoint)
+ (add-ring-handler
+ target-webserver#
+ non-proxy-target
+ "/different")
+ (add-ring-handler
+ target-webserver#
+ post-target-handler
+ "/hello/post/"))
+ (with-app-with-config proxy-app#
+ [jetty9-service]
+ {:webserver ~proxy}
+ (let [proxy-webserver# (get-service proxy-app# :WebserverService)]
+ (add-ring-handler proxy-webserver# ~proxy-handler ~endpoint))
+ ~@body)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Core Helpers
+
+(deftest sanitize-client-cert-test
+ (testing "sanitize-client-cert"
+ (let [subject "foo-client"
+ cert (:cert (ssl-simple/gen-self-signed-cert subject 1))
+ request {:ssl-client-cert cert :authorization {:certificate "stuff"}}
+ response (core/sanitize-client-cert request)]
+ (testing "adds the CN at :ssl-client-cert-cn"
+ (is (= subject (response :ssl-client-cert-cn))))
+ (testing "removes :ssl-client-cert key from response"
+ (is (nil? (response :ssl-client-cert))))
+ (testing "remove tk-auth cert info"
+ (is (nil? (get-in response [:authorization :certificate])))))))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Core Middleware
+
+(deftest test-proxy
+ (let [common-ssl-config {:ssl-cert "./dev-resources/config/jetty/ssl/certs/localhost.pem"
+ :ssl-key "./dev-resources/config/jetty/ssl/private_keys/localhost.pem"
+ :ssl-ca-cert "./dev-resources/config/jetty/ssl/certs/ca.pem"}]
+
+ (testing "basic proxy support"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:9000/hello/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "http://localhost:10000/hello-proxy/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "http://localhost:10000/hello-proxy/world" {:as :stream})]
+ (is (= (slurp (:body response)) "Hello, World!")))
+ (let [response (http-post "http://localhost:10000/hello-proxy/post/" {:as :stream :body "I'm posted!"})]
+ (is (= (:status response) 200))
+ (is (= (slurp (:body response)) "I'm posted!")))))
+
+ (testing "basic https proxy support"
+ (with-target-and-proxy-servers
+ {:target (merge common-ssl-config
+ {:ssl-host "0.0.0.0"
+ :ssl-port 9001})
+ :proxy (merge common-ssl-config
+ {:ssl-host "0.0.0.0"
+ :ssl-port 10001})
+ :proxy-handler proxy-wrapped-app-ssl
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "https://localhost:9001/hello/world" default-options-for-https-client)]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "https://localhost:10001/hello-proxy/world" default-options-for-https-client)]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))))
+
+ (testing "basic http->https proxy support"
+ (with-target-and-proxy-servers
+ {:target (merge common-ssl-config
+ {:ssl-host "0.0.0.0"
+ :ssl-port 9001})
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-ssl
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "https://localhost:9001/hello/world" default-options-for-https-client)]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "http://localhost:10000/hello-proxy/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))))
+
+ (testing "basic https->http proxy support"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy (merge common-ssl-config
+ {:ssl-host "0.0.0.0"
+ :ssl-port 10001})
+ :proxy-handler proxy-wrapped-app
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:9000/hello/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "https://localhost:10001/hello-proxy/world" default-options-for-https-client)]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))))
+ (testing "redirect test with proxy"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:9000/hello")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "http://localhost:9000/hello/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-get "http://localhost:10000/hello-proxy/"
+ {:follow-redirects false
+ :as :text})]
+ (is (= (:status response) 302))
+ (is (= "/hello/world" (get-in response [:headers "location"]))))
+ (let [response (http-post "http://localhost:10000/hello-proxy/"
+ {:follow-redirects false
+ :as :text})]
+ (is (= (:status response) 302))
+ (is (= "/hello/world" (get-in response [:headers "location"]))))
+ (let [response (http-get "http://localhost:10000/hello-proxy/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))))
+
+ (testing "proxy redirect succeeds on POST if :force-redirects set true"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-redirects
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:10000/hello-proxy/"
+ {:follow-redirects false
+ :as :text})]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))
+ (let [response (http-post "http://localhost:10000/hello-proxy/"
+ {:follow-redirects false})]
+ (is (= (:status response) 200)))))
+
+ (testing "redirect test with fully qualified url, correct host, and proxied path"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-redirects
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:10000/hello-proxy/fully-qualified"
+ {:follow-redirects false
+ :as :text})]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World!")))))
+
+ (testing "redirect test with correct host on non-proxied path"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-redirects
+ :ring-handler proxy-target-handler
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:9000/different")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Non-proxied path")))
+ (let [response (http-get "http://localhost:10000/different")]
+ (is (= (:status response) 404)))
+ (let [response (http-get "http://localhost:10000/hello-proxy/different-path"
+ {:follow-redirects false
+ :as :text})]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Non-proxied path")))))
+
+ (testing "gzipped responses not truncated"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app
+ :ring-handler proxy-gzip-response
+ :endpoint "/hello-proxy"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:9000/hello")]
+ (is (= gzip-body (:body response)))
+ (is (= "gzip" (:orig-content-encoding response))))
+ (let [response (http-get "http://localhost:10000/hello-proxy/")]
+ (is (= gzip-body (:body response)))
+ (is (= "gzip" (:orig-content-encoding response))))))
+
+ (testing "proxy works with regex"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-regex
+ :ring-handler proxy-regex-response
+ :endpoint "/"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:10000/production/certificate/foo")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Proxied to /hello/production/certificate/foo")))
+ (let [response (http-get "http://localhost:10000/hello/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Hello, World! (fallthrough)")))
+ (let [response (http-get "http://localhost:10000/goodbye/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Goodbye, World! (fallthrough)")))
+ (let [response (http-get "http://localhost:10000/production/cert/foo")]
+ (is (= (:status response) 404))
+ (is (= (:body response) "Not Found (fallthrough)")))))
+
+ (testing "proxy regex matches beginning of string"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-regex-alt
+ :ring-handler proxy-regex-response
+ :endpoint "/"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:10000/hello-proxy")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Proxied to /hello/hello-proxy")))
+ (let [response (http-get "http://localhost:10000/production/hello-proxy")]
+ (is (= (:status response) 404))
+ (is (= (:body response) "Not Found (fallthrough)")))))
+
+ (testing "proxy regex does not need to match entire request uri"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-regex-alt
+ :ring-handler proxy-regex-response
+ :endpoint "/"
+ :target-endpoint "/hello"}
+ (let [response (http-get "http://localhost:10000/hello-proxy/world")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Proxied to /hello/hello-proxy/world")))))
+
+ (testing "proxy works with regex and no prepended path"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-regex-no-prepend
+ :ring-handler proxy-regex-response
+ :endpoint "/"
+ :target-endpoint "/"}
+ (let [response (http-get "http://localhost:10000/production/certificate/foo")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Proxied to /production/certificate/foo")))))
+
+ (testing "no repeat slashes exist in rewritten uri"
+ (with-target-and-proxy-servers
+ {:target {:host "0.0.0.0"
+ :port 9000}
+ :proxy {:host "0.0.0.0"
+ :port 10000}
+ :proxy-handler proxy-wrapped-app-regex-trailing-slash
+ :ring-handler proxy-regex-response
+ :endpoint "/"
+ :target-endpoint "/"}
+ (let [response (http-get "http://localhost:10000/production/certificate/foo")]
+ (is (= (:status response) 200))
+ (is (= (:body response) "Proxied to /production/certificate/foo")))))))
+
+(deftest test-wrap-add-cache-headers
+ (let [put-request {:request-method :put}
+ get-request {:request-method :get}
+ post-request {:request-method :post}
+ delete-request {:request-method :delete}
+ no-cache-header "private, max-age=0, no-cache"]
+ (testing "wrap-add-cache-headers ignores nil response"
+ (let [handler (constantly nil)
+ wrapped-handler (core/wrap-add-cache-headers handler)]
+ (is (nil? (wrapped-handler put-request)))
+ (is (nil? (wrapped-handler get-request)))
+ (is (nil? (wrapped-handler post-request)))
+ (is (nil? (wrapped-handler delete-request)))))
+ (testing "wrap-add-cache-headers observes handled response"
+ (let [handler (constantly {})
+ wrapped-handler (core/wrap-add-cache-headers handler)
+ handled-response {:headers {"cache-control" no-cache-header}}
+ not-handled-response {}]
+ (is (= handled-response (wrapped-handler get-request)))
+ (is (= handled-response (wrapped-handler put-request)))
+ (is (= not-handled-response (wrapped-handler post-request)))
+ (is (= not-handled-response (wrapped-handler delete-request)))))
+ (testing "wrap-add-cache-headers doesn't stomp on existing headers"
+ (let [fake-response {:headers {:something "Hi mom"}}
+ handler (constantly fake-response)
+ wrapped-handler (core/wrap-add-cache-headers handler)
+ handled-response {:headers {:something "Hi mom"
+ "cache-control" no-cache-header}}
+ not-handled-response fake-response]
+ (is (= handled-response (wrapped-handler get-request)))
+ (is (= handled-response (wrapped-handler put-request)))
+ (is (= not-handled-response (wrapped-handler post-request)))
+ (is (= not-handled-response (wrapped-handler delete-request)))))))
+
+(deftest test-wrap-with-cn
+ (testing "When extracting a CN from a cert"
+ (testing "and there is no cert"
+ (let [mw-fn (core/wrap-with-certificate-cn identity)
+ post-req (mw-fn {})]
+ (testing "ssl-client-cn is set to nil"
+ (is (= post-req {:ssl-client-cn nil})))))
+
+ (testing "and there is a cert"
+ (let [mw-fn (core/wrap-with-certificate-cn identity)
+ post-req (mw-fn {:ssl-client-cert (pem->cert "dev-resources/ssl/cert.pem")})]
+ (testing "ssl-client-cn is set properly"
+ (is (= (:ssl-client-cn post-req) "localhost")))))))
+
+(deftest test-wrap-add-x-frame-options-deny
+ (let [get-request {:request-method :get}
+ put-request {:request-method :put}
+ post-request {:request-method :post}
+ delete-request {:request-method :delete}
+ x-frame-header "DENY"]
+ (testing "wrap-add-x-frame-options-deny ignores nil response"
+ (let [handler (constantly nil)
+ wrapped-handler (core/wrap-add-x-frame-options-deny handler)]
+ (is (nil? (wrapped-handler get-request)))
+ (is (nil? (wrapped-handler put-request)))
+ (is (nil? (wrapped-handler post-request)))
+ (is (nil? (wrapped-handler delete-request)))))
+ (testing "wrap-add-x-frame-options-deny observes handled response"
+ (let [handler (constantly {})
+ wrapped-handler (core/wrap-add-x-frame-options-deny handler)
+ handled-response {:headers {"X-Frame-Options" x-frame-header}}
+ not-handled-response {}]
+ (is (= handled-response (wrapped-handler get-request)))
+ (is (= handled-response (wrapped-handler put-request)))
+ (is (= handled-response (wrapped-handler post-request)))
+ (is (= handled-response (wrapped-handler delete-request)))))
+ (testing "wrap-add-x-frame-options-deny doesn't stomp on existing headers"
+ (let [fake-response {:headers {:something "Hi mom"}}
+ handler (constantly fake-response)
+ wrapped-handler (core/wrap-add-x-frame-options-deny handler)
+ handled-response {:headers {:something "Hi mom"
+ "X-Frame-Options" x-frame-header}}]
+ (is (= handled-response (wrapped-handler get-request)))
+ (is (= handled-response (wrapped-handler put-request)))
+ (is (= handled-response (wrapped-handler post-request)))
+ (is (= handled-response (wrapped-handler delete-request)))))))
+
+(deftest wrap-response-logging-test
+ (testing "wrap-response-logging"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-response-logging identity)
+ response (stack (basic-request))]
+ (is (logged? #"Computed response.*" :trace))))))
+
+(deftest wrap-request-logging-test
+ (testing "wrap-request-logging"
+ (logutils/with-test-logging
+ (let [subject "foo-agent"
+ method :get
+ uri "https://example.com"
+ stack (core/wrap-request-logging identity)
+ request (basic-request subject method uri)
+ response (stack request)]
+ (is (logged? (format "Processing %s %s" method uri) :debug))
+ (is (logged? #"Full request" :trace))))))
+
+(deftest wrap-data-errors-test
+ (testing "wrap-data-errors"
+ (testing "default behavior"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-data-errors (throwing-handler :user-data-invalid "Error Message"))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 400 (response :status)))
+ (is (= "Error Message" (get json-body "msg")))
+ (is (logged? #"Error Message" :error))
+ (is (logged? #"Submitted data is invalid" :error)))))
+ (doseq [error [:request-data-invalid :user-data-invalid :service-status-version-not-found]]
+ (testing (str "handles errors of " error)
+ (logutils/with-test-logging
+ (let [stack (core/wrap-data-errors (throwing-handler error "Error Message"))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 400 (response :status)))
+ (is (= (name error) (get json-body "kind")))))))
+ (testing "handles errors thrown by `throw-data-invalid!`"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-data-errors (fn [_] (utils/throw-data-invalid! "Error Message")))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 400 (response :status)))
+ (is (= (name "data-invalid") (get json-body "kind"))))))
+ (testing "can be plain text"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-data-errors
+ (throwing-handler :user-data-invalid "Error Message") :plain)
+ response (stack (basic-request))]
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"]))))))))
+
+(deftest wrap-bad-request-test
+ (testing "wrap-bad-request"
+ (testing "default behavior"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-bad-request (fn [_] (utils/throw-bad-request! "Error Message")))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 400 (response :status)))
+ (is (logged? #".*Bad Request.*" :error))
+ (is (re-matches #"Error Message.*" (get json-body "msg" "")))
+ (is (= "bad-request" (get json-body "kind"))))))
+ (testing "can be plain text"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-bad-request (fn [_] (utils/throw-bad-request! "Error Message")) :plain)
+ response (stack (basic-request))]
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"]))))))))
+
+(deftest wrap-schema-errors-test
+ (testing "wrap-schema-errors"
+ (testing "default behavior"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-schema-errors cause-schema-error)
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 500 (response :status)))
+ (is (logged? #".*Something unexpected.*" :error))
+ (is (re-matches #"Something unexpected.*" (get json-body "msg" "")))
+ (is (= "application-error" (get json-body "kind"))))))
+ (testing "can be plain text"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-schema-errors cause-schema-error :plain)
+ response (stack (basic-request))]
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"]))))))))
+
+(deftest wrap-service-unavailable-test
+ (testing "wrap-service-unavailable"
+ (testing "default behavior"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-service-unavailable (fn [_] (utils/throw-service-unavailable! "Test Service is DOWN!")))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 503 (response :status)))
+ (is (logged? #".*Service Unavailable.*" :error))
+ (is (= "Test Service is DOWN!" (get json-body "msg")))
+ (is (= "service-unavailable" (get json-body "kind"))))))
+ (testing "can be plain text"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-service-unavailable (fn [_] (utils/throw-service-unavailable! "Test Service is DOWN!")) :plain)
+ response (stack (basic-request))]
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"]))))))))
+
+(deftest wrap-uncaught-errors-test
+ (testing "wrap-uncaught-errors"
+ (testing "default behavior"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-uncaught-errors (fn [_] (throw (IllegalStateException. "Woah..."))))
+ response (stack (basic-request))
+ json-body (json/parse-string (response :body))]
+ (is (= 500 (response :status)))
+ (is (logged? #".*Internal Server Error.*" :warn))
+ (is (re-matches #"Internal Server Error.*" (get json-body "msg" ""))))))
+ (testing "can be plain text"
+ (logutils/with-test-logging
+ (let [stack (core/wrap-uncaught-errors (fn [_] (throw (IllegalStateException. "Woah..."))) :plain)
+ response (stack (basic-request))]
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"]))))))))
diff --git a/test/puppetlabs/ring_middleware/testutils/common.clj b/test/puppetlabs/ring_middleware/testutils/common.clj
new file mode 100644
index 0000000..f9420c0
--- /dev/null
+++ b/test/puppetlabs/ring_middleware/testutils/common.clj
@@ -0,0 +1,23 @@
+(ns puppetlabs.ring-middleware.testutils.common
+ (:require [puppetlabs.http.client.sync :as http-client]))
+
+(def default-options-for-https-client
+ {:ssl-cert "./dev-resources/config/jetty/ssl/certs/localhost.pem"
+ :ssl-key "./dev-resources/config/jetty/ssl/private_keys/localhost.pem"
+ :ssl-ca-cert "./dev-resources/config/jetty/ssl/certs/ca.pem"
+ :as :text})
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;; Testing utility functions
+
+(defn http-get
+ ([url]
+ (http-get url {:as :text}))
+ ([url options]
+ (http-client/get url options)))
+
+(defn http-post
+ ([url]
+ (http-post url {:as :text}))
+ ([url options]
+ (http-client/post url options))) \ No newline at end of file
diff --git a/test/puppetlabs/ring_middleware/utils_test.clj b/test/puppetlabs/ring_middleware/utils_test.clj
new file mode 100644
index 0000000..faa21ce
--- /dev/null
+++ b/test/puppetlabs/ring_middleware/utils_test.clj
@@ -0,0 +1,25 @@
+(ns puppetlabs.ring-middleware.utils-test
+ (:require [cheshire.core :as json]
+ [clojure.test :refer :all]
+ [puppetlabs.ring-middleware.utils :as utils]))
+
+(deftest json-response-test
+ (testing "json response"
+ (let [source {:key 1}
+ response (utils/json-response 200 source)]
+ (testing "has 200 status code"
+ (is (= 200 (:status response))))
+ (testing "has json content-type"
+ (is (re-matches #"application/json.*" (get-in response [:headers "Content-Type"]))))
+ (testing "is properly converted to a json string"
+ (is (= 1 ((json/parse-string (:body response)) "key")))))))
+
+(deftest plain-response-test
+ (testing "json response"
+ (let [message "Response message"
+ response (utils/plain-response 200 message)]
+ (testing "has 200 status code"
+ (is (= 200 (:status response))))
+ (testing "has plain content-type"
+ (is (re-matches #"text/plain.*" (get-in response [:headers "Content-Type"])))))))
+