diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd565b5e..4665c66a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,7 @@ jobs: with: lein: latest - name: Run tests - run: lein do clean, all midje, all check + run: lein do clean, all test, all check deploy: concurrency: deploy needs: test diff --git a/CHANGELOG-1.1.x.md b/CHANGELOG-1.1.x.md new file mode 100644 index 00000000..84d63788 --- /dev/null +++ b/CHANGELOG-1.1.x.md @@ -0,0 +1,1358 @@ +## 1.1.13 (2019-11-02) + +* **BREAKING**: Drop support for Java 6, Java 7. +* Support Java 13. +* Update dependencies. + +## 1.1.12 (27.2.2018) + +Maintenance release, adding several patches from 2.0 branch. + +- Backport: Fix context child resolution with compojure-bindings [#370](https://github.com/metosin/compojure-api/issues/370) +- Backport: merge-vector [#311](https://github.com/metosin/compojure-api/issues/311) +- Backport: Fix metadata position on defmacro to activate CIDER indent style [#261](https://github.com/metosin/compojure-api/issues/261) + +## 1.1.11 (25.7.2017) + + * **BREAKING**: in `compojure.api.swagger`, the `swagger-ui` and `swagger-docs` now take options map with `path` key instead of separate optional path & vararg opts. + - normally you would use swagger api-options or `swagger-routes` and thus be unaffected of this. + +* updated dependencies: + +```clj +[prismatic/plumbing "0.5.4"] is available but we use "0.5.3" +[compojure "1.6.0"] is available but we use "1.5.2" +[prismatic/schema "1.1.6"] is available but we use "1.1.3" +[ring-middleware-format "0.7.2"] is available but we use "0.7.0" +[metosin/ring-http-response "0.9.0"] is available but we use "0.8.1" +[metosin/ring-swagger "0.24.1"] is available but we use "0.22.14" +``` + +## 1.1.10 (11.1.2017) + +* Updated dependencies to [avoid a path traversal vulnerability](https://groups.google.com/forum/#!topic/clojure/YDrKBV26rnA) in Ring. + +```clj +[compojure "1.5.2"] is available but we use "1.5.1" +[metosin/ring-http-response "0.8.1"] is available but we use "0.8.0" +[metosin/ring-swagger "0.22.14"] is available but we use "0.22.11" +[metosin/ring-swagger-ui "2.2.8"] is available but we use "2.2.5-0" +``` + +## 1.1.9 (23.10.2016) + +* Fix `:header-params` with resources, [#254](https://github.com/metosin/compojure-api/issues/254) +* updated dependencies: + +```clj +[frankiesardo/linked "1.2.9"] is available but we use "1.2.7" +[metosin/ring-swagger "0.22.11"] is available but we use "0.22.10" +[metosin/ring-swagger-ui "2.2.5-0"] is available but we use "2.2.2-0" +``` + +## 1.1.8 (29.8.2016) + +* Lot's of new swagger-bindings from Ring-swagger: + * `schema.core.defrecord` + * `org.joda.time.LocalTime` + * primitive arrays, fixes [#177](https://github.com/metosin/compojure-api/issues/177) + * `s/Any` in body generates empty object instead of nil +* Bundled with latest swagger-ui `2.2.2-0` + +* Updated deps: + +```clj +[metosin/ring-swagger "0.22.10"] is available but we use "0.22.9" +[metosin/ring-swagger-ui "2.2.2-0"] is available but we use "2.2.1-0" +``` + +## 1.1.7 (24.8.2016) + +* Bundled with the latest Swagger-ui (2.2.1-0) + +* Updated deps: + +```clj +[metosin/ring-swagger-ui "2.2.1-0"] is available but we use "2.1.4-0" +``` + +## 1.1.6 (1.8.2016) + +* `:content-type` of user-defined formats are pushed into Swagger `:produces` and `:consumes`, thanks to [Waldemar](https://github.com/Velrok). + +```clj +(def custom-json-format + (ring.middleware.format-response/make-encoder cheshire.core/generate-string "application/vnd.vendor.v1+json")) + +(api + {:format {:formats [custom-json-format :json :edn]}} + ...) +``` + +## 1.1.5 (27.7.2016) + +* New api-options `[:api :disable-api-middleware?]` to disable the api-middleware completely. With this set, `api` only produces the (reverse) route-tree + set's swagger stuff and sets schema coercions for the api. + * Thanks to [Alan Malloy](https://github.com/amalloy) for contributing! + +```clj +(api + {:api {:disable-api-middleware? true} + ;; Still available + :swagger {:ui "/api-docs" + :spec "/swagger.json" + :data {:info {:title "api"}}}} + ...) +``` + +* `:data` in `swagger-routes` can be overridden even if run outside of `api`: + +```clj +(def app + (routes + (swagger-routes + {:ui "/api-docs" + :spec "/swagger.json" + :data {:info {:title "Kikka"} + :paths {"/ping" {:get {:summary "ping get"}}}}}) + (GET "/ping" [] "pong")))) +``` + +* unsetting `:format` option in `api-middleware` causes all format-middlewares not to mount +* unsetting `:exceptions` option in `api-middleware` causes the exception handling to be disabled +* unsetting `:coercion` translates to same as setting it to `(constantly nil)` + +```clj +(api + {:exceptions nil ;; disable exception handling + :format nil ;; disable ring-middleware-format + :coercion nil} ;; disable all schema-coercion + ;; this will be really thrown + (GET "/throw" [] + (throw (new RuntimeException)))) +``` + +* updated dependencies: + +```clj +[prismatic/schema "1.1.3"] is available but we use "1.1.2" +[frankiesardo/linked "1.2.7"] is available but we use "1.2.6" +``` + +## 1.1.4 (9.7.2016) + +* fix reflection warning with logging, thanks to [Matt K](https://github.com/mtkp). +* Empty contexts (`/`) don't accumulate to the path, see https://github.com/weavejester/compojure/issues/125 + +* **NOTE**: update of `ring-http-response` had a [breaking change](https://github.com/metosin/ring-http-response/blob/master/CHANGELOG.md#080-2862016): + - first argument for `created` is `url`, not `body`. Has 2-arity version which takes both `url` & `body` in align to the [spec](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) & [ring](https://github.com/ring-clojure/ring/blob/master/ring-core/src/ring/util/response.clj#L37) + - fixes [#12](https://github.com/metosin/ring-http-response/issues/12). + + +* updated dependencies: + +```clj +[compojure "1.5.1"] is available but we use "1.5.0" +[metosin/ring-http-response "0.8.0"] is available but we use "0.7.0" +[cheshire "5.6.3"] is available but we use "5.6.1" +``` + +## 1.1.3 (14.6.2016) + +* updated dependencies: + +```clj +[prismatic/schema "1.1.2"] is available but we use "1.1.1" +[metosin/ring-http-response "0.7.0"] is available but we use "0.6.5" +[metosin/ring-swagger "0.22.9"] is available but we use "0.22.8" +[reloaded.repl "0.2.2"] is available but we use "0.2.1" +[peridot "0.4.4"] is available but we use "0.4.3" +[reloaded.repl "0.2.2"] is available but we use "0.2.1" +``` + +## 1.1.2 (21.5.2016) + +* Response headers are mapped correctly, fixes [#232](https://github.com/metosin/compojure-api/issues/232) + +* updated dependencies: + +```clj +[metosin/ring-swagger "0.22.8"] is available but we use "0.22.7" +``` + +## 1.1.1 (18.5.2016) + +* Allow usage of run-time parameters with `:swagger`. + +```clj +(let [runtime-data {:x-name :boolean + :operationId "echoBoolean" + :description "Ehcoes a boolean" + :parameters {:query {:q s/Bool}}}] + (api + (GET "/route" [] + :swagger runtime-data + (ok {:it "works"})))) +``` + +* Copy & coerce compojure `:route-params` into `:path-params` with resources + * Fixes [#231](https://github.com/metosin/compojure-api/issues/231). + +```clj +(resource + {:parameters {:path-params {:id s/Int}} + :responses {200 {:schema s/Int}} + :handler (fnk [[:path-params id]] + (ok (inc id)))}) +``` + +* updated dependencies: + +```clj +[prismatic/schema "1.1.1"] is available but we use "1.1.0" +``` + +## 1.1.0 (25.4.2016) + +* **BREAKING**: Move `compojure.api.swgger/validate` to `compojure.api.validator/validate`. +* **BREAKING**: If a `resource` doesn't define a handler for a given `request-method` or for top-level, nil is returned (instead of throwing exeption) +* **BREAKING** Resource-routing is done by `context`. Trying to return a `compojure.api.routing/Route` from an endpoint like `ANY` will throw descriptive (runtime-)exception. + +```clj +(context "/hello" [] + (resource + {:description "hello-resource" + :responses {200 {:schema {:message s/Str}}} + :post {:summary "post-hello" + :parameters {:body-params {:name s/Str}} + :handler (fnk [[:body-params name]] + (ok {:message (format "hello, %s!" name)}))} + :get {:summary "get-hello" + :parameters {:query-params {:name s/Str}} + :handler (fnk [[:query-params name]] + (ok {:message (format "hello, %s!" name)}))}})) +``` + +* api-level swagger-options default to `{:ui nil, :spec nil}`. Setting up just the spec or ui, doesn't automatically setup the other (like previously) +* Strip nils from `:middleware`, fixes [#228](https://github.com/metosin/compojure-api/issues/228) +* `describe` works with anonymous body-schemas (via ring-swagger `0.22.7`), Fixes [#168](https://github.com/metosin/compojure-api/issues/168) +* Support compojure-api apps in [Google App Engine](https://cloud.google.com/appengine) by allowing [scjsv](https://github.com/metosin/scjsv) to be excluded (uses [json-schema-validator](https://github.com/fge/json-schema-validator), which uses rogue threads): + +```clj +[metosin/compojure-api "1.1.0" :exclusions [[metosin/scjsv]]] +``` + +* updated dependencies: + +```clj +[metosin/ring-swagger "0.22.7"] is available but we use "0.22.6" +[prismatic/plumbing "0.5.3"] is available but we use "0.5.2" +[cheshire "5.6.1"] is available but we use "5.5.0" +``` + +## 1.0.2 (27.3.2016) + +* Parameter order is unreversed for fnk-style destructurings for small number of paramerers, fixes [#224](https://github.com/metosin/compojure-api/issues/224) +* Moved internal coercion helpers from `compojure.api.meta` to `compojure.api.coerce`. +* New `compojure.api.resource/resource` (also in `compojure.api.sweet`) for building resource-oriented services + * Yields (presumably) better support for [Liberator](http://clojure-liberator.github.io/liberator/), fixes [#185](https://github.com/metosin/compojure-api/issues/185) + +```clj +(defn resource + "Creates a nested compojure-api Route from enchanced ring-swagger operations map and options. + By default, applies both request- and response-coercion based on those definitions. + + Options: + + - **:coercion** A function from request->type->coercion-matcher, used + in resource coercion for :body, :string and :response. + Setting value to `(constantly nil)` disables both request- & + response coercion. See tests and wiki for details. + + Enchancements to ring-swagger operations map: + + 1) :parameters use ring request keys (query-params, path-params, ...) instead of + swagger-params (query, path, ...). This keeps things simple as ring keys are used in + the handler when destructuring the request. + + 2) at resource root, one can add any ring-swagger operation definitions, which will be + available for all operations, using the following rules: + + 2.1) :parameters are deep-merged into operation :parameters + 2.2) :responses are merged into operation :responses (operation can fully override them) + 2.3) all others (:produces, :consumes, :summary,...) are deep-merged by compojure-api + + 3) special key `:handler` either under operations or at top-level. Value should be a + ring-handler function, responsible for the actual request processing. Handler lookup + order is the following: operations-level, top-level, exception. + + 4) request-coercion is applied once, using deep-merged parameters for a given + operation or resource-level if only resource-level handler is defined. + + 5) response-coercion is applied once, using merged responses for a given + operation or resource-level if only resource-level handler is defined. + + Note: Swagger operations are generated only from declared operations (:get, :post, ..), + despite the top-level handler could process more operations. + + Example: + + (resource + {:parameters {:query-params {:x Long}} + :responses {500 {:schema {:reason s/Str}}} + :get {:parameters {:query-params {:y Long}} + :responses {200 {:schema {:total Long}}} + :handler (fn [request] + (ok {:total (+ (-> request :query-params :x) + (-> request :query-params :y))}))} + :post {} + :handler (constantly + (internal-server-error {:reason \"not implemented\"}))})" + ([info] + (resource info {})) + ([info options] + (let [info (merge-parameters-and-responses info) + root-info (swaggerize (root-info info)) + childs (create-childs info) + handler (create-handler info options)] + (routes/create nil nil root-info childs handler)))) +``` + +* updated dependencies: + +```clj +[compojure "1.5.0"] is available but we use "1.4.0" +[prismatic/schema "1.1.0"] is available but we use "1.0.5" +[metosin/ring-swagger "0.22.6"] is available but we use "0.22.4" +``` + +## 1.0.1 (28.2.2016) + +* For response coercion, the original response is available in `ex-data` under `:response`. +This can be used in logging, "what did the route try to return". Thanks to [Tim Gilbert](https://github.com/timgilbert). +* Response coercion uses the `:default` code if available and response code doesn't match + +```clj +(GET "/" [] + :responses {200 {:schema {:ping s/Str}} + :default {:schema {:error s/int}}} + ...) +``` + +## 1.0.0 (17.2.2016) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.5...1.0.0)** + +**[compare to RC2](https://github.com/metosin/compojure-api/compare/1.0.0-RC2...1.0.0)** + +* updated dependencies: + +```clj +[prismatic/schema "1.0.5"] is available but we use "1.0.4" +``` + +### 1.0.0-RC2 (11.2.2016) + +**[compare to RC1](https://github.com/metosin/compojure-api/compare/1.0.0-RC1...1.0.0-RC2)** + +* Swagger-routes mounted via api-options are mounted before other routes, fixes [#218](https://github.com/metosin/compojure-api/issues/218) +* Routes are now resolved also from from Vars, fixes [#219](https://github.com/metosin/compojure-api/issues/219) +* Better handling of `:basePath` with `swagger-routes`, thanks to [Hoxu](https://github.com/hoxu). +* Updated dependencies: + +```clj +[metosin/ring-swagger "0.22.4"] is available but we use "0.22.3" +``` + +### 1.0.0-RC1 (2.2.2016) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.5...1.0.0-RC1)** + +* Move from compile-time to runtime route resolution. + * Most of the internal macro magic has been vaporized + * Uses internally (invokable) Records & Protocols, allowing easier integration to 3rd party libs like [Liberator](http://clojure-liberator.github.io/liberator/) + * even for large apps (100+ routes), route compilation takes now millis, instead of seconds + * sub-routes can be created with normal functions (or values), making it easier to: + * pass in app-level dependencies from libs like [Component](https://github.com/stuartsierra/component) + * reuse shared request-handling time parameters like path-parameters and authorization info + +```clj +(defn more-routes [db version] + (routes + (GET "/version" [] + (ok {:version version})) + (POST "/thingie" [] + (ok (thingie/create db))))) + +(defn app [db] + (api + (context "/api/:version" [] + :path-params [version :- s/Str] + (more-routes db version) + (GET "/kikka" [] + (ok "kukka"))))) +``` + +### Breaking changes + +* **BREAKING** Vanilla Compojure routes will not produce any swagger-docs (as they do not satisfy the +`Routing` protocol. They can still be used for handling request, just without docs. + * a new api-level option `[:api :invalid-routes-fn]` to declare how to handle routes not satisfying + the `Routing` protocol. Default implementation logs invalid routes as WARNINGs. + +* **BREAKING** compojure.core imports are removed from `compojure.api.sweet`: + * `let-request`, `routing`, `wrap-routes` + +* **BREAKING** Asterix (`*`) is removed from route macro & function names, as there is no reason to mix compojure-api & compojure route macros. + * `GET*` => `GET` + * `ANY*` => `ANY` + * `HEAD*` => `HEAD` + * `PATCH*` => `PATCH` + * `DELETE*` => `DELETE` + * `OPTIONS*` => `OPTIONS` + * `POST*` => `PUT` + * `context*` => `context` + * `defroutes*` => `defroutes` + +* **BREAKING** `swagger-docs` and `swagger-ui` are no longer in `compojure.api.sweet` + * Syntax was hairy and when configuring the spec-url it needed to be set to both in order to work + * In future, there are multiple ways of setting the swagger stuff: + * via api-options `:swagger` (has no defaults) + * via `swagger-routes` function, mounting both the `swagger-ui` and `swagger-docs` and wiring them together + * by default, mounts the swagger-ui to `/` and the swagger-spec to `/swagger.json` + * via the old `swagger-ui` & `swagger-docs` (need to be separately imported from `compojure.api.swagger`). + * see https://github.com/metosin/compojure-api/wiki/Swagger-integration for details + +```clj +(defapi app + (swagger-routes) + (GET "/ping" [] + (ok {:message "pong"}))) + +(defapi app + {:swagger {:ui "/", :spec "/swagger.json"}} + (GET "/ping" [] + (ok {:message "pong"}))) +``` + +* **BREAKING**: api-level coercion option is now a function of `request => type => matcher` as it is documented. +Previously required a `type => matcher` map. Options are checked against `type => matcher` coercion input, and a +descriptive error is thrown when api is created with the old options format. + +* **BREAKING**: Renamed `middlewares` to `middleware` and `:middlewares` key (restructuring) to `:middleware` + * will break at macro-expansion time with helpful exception + +* **BREAKING**: Middleware must be defined as data: both middleware macro and :middleware restructuring +take a vector of middleware containing either + * a) fully configured middleware (function), or + * b) a middleware templates in form of `[function args]` + * You can also use anonymous or lambda functions to create middleware with correct parameters, + these are all identical: + * `[[wrap-foo {:opts :bar}]]` + * `[#(wrap-foo % {:opts :bar})]` + * `[(fn [handler] (wrap-foo handler {:opts :bar}))]` + * Similar to [duct](https://github.com/weavejester/duct/wiki/Components#handlers) + +* **BREAKING**: (Custom restructuring handlers only) `:parameters` key used by `restructure-param` +has been renamed to `:swagger`. + * will break at macro-expansion time with helpful exception + +* **BREAKING** `public-resource-routes` & `public-resources` are removed from `compojure.api.middleware`. + +* **BREAKING**: `compojure.api.legacy` namespace has been removed. + +### Migration guide + +https://github.com/metosin/compojure-api/wiki/Migration-Guide-to-1.0.0 + +### Other stuff + +* Additional route functions/macros in `compojure.api.core`: + * `routes` & `letroutes`, just like in the Compojure, but supporting `Routing` + * `undocumented` - works just like `routes` but without any route definitions. Can be used to wrap legacy routes which setting the api option to fail on missing docs. + +* top-level `api` is now just function, not a macro. It takes an optional options maps and a top-level route function. + +* Coercer cache is now at api-level with 10000 entries. + +* Code generated from restructured route macros is much cleaner now + +* Coercion is on by default for standalone (apiless) endpoints. + +```clj +(fact "coercion is on for apiless routes" + (let [route (GET "/x" [] + :query-params [x :- Long] + (ok))] + (route {:request-method :get :uri "/x" :query-params {}}) => throws)) +``` + +* Removed deps: + +```clojure +[backtick "0.3.3"] +``` + +## 0.24.5 (17.1.2016) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.4...0.24.5)** + +* Fixed path parameter handling in cases where path parameter is followed by an extension +([#196](https://github.com/metosin/compojure-api/issues/196), [metosin/ring-swagger#82](https://github.com/metosin/ring-swagger/issues/82)) +* [Updated ring-swagger](https://github.com/metosin/ring-swagger/blob/master/CHANGELOG.md#0223-1712016) +* Added `compojure.api.exception/with-logging` helper to add logging to exception handlers. + * Check extended wiki guide on [exception handling](https://github.com/metosin/compojure-api/wiki/Exception-handling#logging) + +* Updated deps: + +```clojure +[metosin/ring-swagger "0.22.3"] is available +``` + +## 0.24.4 (13.1.2016) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.3...0.24.4)** + +- [Updated ring-swagger](https://github.com/metosin/ring-swagger/blob/master/CHANGELOG.md#0222-1312016) + +* Updated deps: + +```clojure +[metosin/ring-swagger "0.22.2"] is available +[metosin/ring-swagger-ui "2.1.4-0"] is available +[potemkin "0.4.3"] is available +``` + +## 0.24.3 (14.12.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.2...0.24.3)** + +* coercer-cache is now per Route instead beeing global and based on a +FIFO size 100 cache. Avoids potential memory leaks when using anonymous coercion matchers (which never hit the cache). + +* Updated deps: + +```clj +[prismatic/schema "1.0.4"] is available but we use "1.0.3" +``` + +## 0.24.2 (8.12.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.1...0.24.2)** + +* Memoize coercers (for `schema` & `matcher` -input) for better performance. + * [Tests](https://github.com/metosin/compojure-api/blob/master/test/compojure/api/perf_test.clj) show 0-40% lower latency, +depending on input & output schema complexity. + * Tested by sending json-strings to `api` and reading json-string out. + * Measured a 80% lower latency with a real world large Schema. +* Updated deps: + +```clj +[potemkin "0.4.2"] is available but we use "0.4.1" +``` + +## 0.24.1 (29.11.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.24.0...0.24.1)** + +* uses [`[Ring-Swagger "0.22.1"]`](https://github.com/metosin/ring-swagger/blob/master/CHANGELOG.md#0221-29112015) +* `clojure.tools.logging` is used with default uncaugt exception handling if it's found +on the classpath. Fixes [#172](https://github.com/metosin/compojure-api/issues/172). +* Both `api` and `defapi` produce identical swagger-docs. Fixes [#159](https://github.com/metosin/compojure-api/issues/159) +* allow any swagger data to be overriden at runtime either via swagger-docs or via middlewares. Fixes [#170](https://github.com/metosin/compojure-api/issues/170). + +```clojure +[metosin/ring-swagger "0.22.1"] is available but we use "0.22.0" +[metosin/ring-swagger-ui "2.1.3-4"] is available but we use "2.1.3-2" +[prismatic/plumbing "0.5.2] is available but we use "0.5.1" +``` + +## 0.24.0 (8.11.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.23.1...0.24.0)** + +- **BREAKING**: Dropped support for Clojure 1.6 +- **BREAKING**: Supports and depends on Schema 1.0. +- **BREAKING**: `ring-middleware-format` accepts transit options in a new format: + +```clj +;; pre 0.24.0: + +(api + {:format {:response-opts {:transit-json {:handlers transit/writers}} + :params-opts {:transit-json {:options {:handlers transit/readers}}}}} + ...) + +;; 0.24.0 + + +(api + {:format {:response-opts {:transit-json {:handlers transit/writers}} + :params-opts {:transit-json {:handlers transit/readers}}}} + ...) +``` + +- Uses upstream [ring-middleware-format](https://github.com/ngrunwald/ring-middleware-format) +instead of Metosin fork. +- Uses now [linked](https://github.com/frankiesardo/linked) instead of +[ordered](https://github.com/amalloy/ordered) for maps where order matters. +- `swagger-ui` now supports passing arbitrary options to `SwaggerUI` +([metosin/ring-swagger#67](https://github.com/metosin/ring-swagger/issues/67)). +* Updated deps: + +```clojure +[prismatic/schema "1.0.3"] is available but we use "0.4.4" +[prismatic/plumbing "0.5.1] is available but we use "0.4.4" +[metosin/schema-tools "0.7.0"] is available but we use "0.5.2" +[metosin/ring-swagger "0.22.0"] is available but we use "0.21.0" +[metosin/ring-swagger-ui "2.1.3-2"] is available but we use "2.1.2" +``` + +## 0.23.1 (3.9.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.23.0...0.23.1)** + +* Routes are kept in order for swagger docs, Fixes [#138](https://github.com/metosin/compojure-api/issues/138). + +## 0.23.0 (1.9.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.22.2...0.23.0)** + +* Ring-swagger 0.21.0 + * **BREAKING**: new signature for dispatching custom JSON Schema transformations, old signature will break (nicely at compile-time), see [Readme](https://github.com/metosin/ring-swagger/blob/master/README.md) for details. + * Support for collections in query parameters. E.g. `:query-params [x :- [Long]]` & url `?x=1&x=2&x=3` should result in `x` being `[1 2 3]`. +* **BREAKING**: `:validation-errors :error-handler`, `:validation-errors :catch-core-errors?` + and `:exceptions :exception-handler` options have been removed. + * These have been replaced with general `:exceptions :handlers` options. + * Fails nicely at compile-time + * **BREAKING**: New handler use different arity than old handler functions. + * new arguments: Exception, ex-info and request. +* Move `context` from `compojure.api.sweet` to `compojure.api.legacy`. Use `context*` instead. +* Updated deps: + +```clojure +[metosin/ring-swagger "0.21.0-SNAPSHOT"] is available but we use "0.20.4" +[compojure "1.4.0"] is available but we use "1.3.4" +[prismatic/schema "0.4.4"] is available but we use "0.4.3" +[metosin/ring-http-response "0.6.5"] is available but we use "0.6.3" +[metosin/schema-tools "0.5.2"] is available but we use "0.5.1" +[metosin/ring-swagger-ui "2.1.2"] is available but we use "2.1.5-M2" +[peridot "0.4.1"] is available but we use "0.4.0" +``` + +## 0.22.2 (12.8.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.22.1...0.22.2)** + +* fixes [150](https://github.com/metosin/compojure-api/issues/150) + +## 0.22.1 (12.7.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.22.0...0.22.1)** + +* fixes [137](https://github.com/metosin/compojure-api/issues/137) & [134](https://github.com/metosin/compojure-api/issues/134), thanks to @thomaswhitcomb! +* updated deps: + +```clojure +[metosin/ring-http-response "0.6.3"] is available but we use "0.6.2" +[midje "1.7.0"] is available but we use "1.7.0-SNAPSHOT" +``` + +## 0.22.0 (30.6.2015) + +**[compare](https://github.com/metosin/compojure-api/compare/0.21.0...0.22.0)** + +* Optional integration with [Component](https://github.com/stuartsierra/component). + Use either `:components`-option of `api-middleware` or `wrap-components`-middleware + to associate the components with your API. Then you can use `:components`-restructuring + to destructure your components using letk syntax. +* fix for [#123](https://github.com/metosin/compojure-api/issues/123) +* support for pluggable coercion, at both api-level & endpoint-level with option `:coercion`. See the[the tests](./test/compojure/api/coercion_test.clj). + * coercion is a function of type - `ring-request->coercion-type->coercion-matcher` allowing protocol-based coercion in the future + ** BREAKING**: if you have created custom restructurings using `src-coerce`, they will break (nicely at compile-time) + +* new restucturing `:swagger` just for swagger-docs. Does not do any coercion. + +```clojure +(GET* "/documented" [] + :swagger {:responses {200 {:schema User} + 404 {:schema Error + :description "Not Found"} } + :paramerers {:query {:q s/Str} + :body NewUser}}} + ...) +``` + +```clojure +[cheshire "5.5.0"] is available but we use "5.4.0" +[backtick "0.3.3"] is available but we use "0.3.2" +[lein-ring "0.9.6"] is available but we use "0.9.4" +``` + +## 0.21.0 (25.5.2015) + +* `:multipart-params` now sets `:consumes ["multipart/form-data"]` and `:form-params` sets +`:consumes ["application/x-www-form-urlencoded"]` +* **experimental**: File upload support using `compojure.api.upload` namespace. + +```clojure +(POST* "/upload" [] + :multipart-params [file :- TempFileUpload] + :middlewares [wrap-multipart-params] + (ok (dissoc file :tempfile)))) +``` + +* **breaking**: use plain Ring-Swagger 2.0 models with `:responses`. A helpful `IllegalArgumentException` will be thrown at compile-time with old models. +* new way: + +```clojure +:responses {400 {:schema ErrorSchema}} +:responses {400 {:schema ErrorSchema, :description "Eror"}} +``` + +* allow configuring of Ring-Swagger via `api-middleware` options with key `:ring-swagger`: + +```clojure +(defapi app + {:ring-swagger {:ignore-missing-mappings? true}}) + (swagger-docs) + (swagger-ui) + ...) +``` + +* Bidirectinal routing, inspired by [bidi](https://github.com/juxt/bidi) - named routes & `path-for`: + +```clojure +(fact "bidirectional routing" + (let [app (api + (GET* "/api/pong" [] + :name :pong + (ok {:pong "pong"})) + (GET* "/api/ping" [] + (moved-permanently (path-for :pong))))] + (fact "path-for resolution" + (let [[status body] (get* app "/api/ping" {})] + status => 200 + body => {:pong "pong"})))) +``` + +* a validator for the api + +```clojure +(require '[compojure.api.sweet :refer :all]) +(require '[compojure.api.swagger :refer [validate]) + +(defrecord NonSwaggerRecord [data]) + +(def app + (validate + (api + (swagger-docs) + (GET* "/ping" [] + :return NonSwaggerRecord + (ok (->NonSwaggerRecord "ping")))))) + +; clojure.lang.Compiler$CompilerException: java.lang.IllegalArgumentException: +; don't know how to create json-type of: class compojure.api.integration_test.NonSwaggerRecord +``` + +* updated dependencies: + +```clojure +[metosin/ring-swagger "0.20.4"] is available but we use "0.20.3" +[metosin/ring-http-response "0.6.2"] is available but we use "0.6.1" +[metosin/ring-swagger-ui "2.1.5-M2"] +[prismatic/plumbing "0.4.4"] is available but we use "0.4.3" +[prismatic/schema "0.4.3"] is available but we use "0.4.2" +``` + +## 0.20.4 (20.5.2015) + +* response descriptions can be given also with run-time meta-data (`with-meta`), fixes [#96](https://github.com/metosin/compojure-api/issues/96) + * in next MINOR version, we'll switch to (Ring-)Swagger 2.0 format. + +```clojure +(context* "/responses" [] + :tags ["responses"] + (GET* "/" [] + :query-params [return :- (s/enum :200 :403 :404)] + :responses {403 ^{:message "spiders?"} {:code s/Str} ; old + 404 (with-meta {:reason s/Str} {:message "lost?"})} ; new + :return Total + :summary "multiple returns models" + (case return + :200 (ok {:total 42}) + :403 (forbidden {:code "forest"}) + :404 (not-found {:reason "lost"})))) +``` + +## 0.20.3 (17.5.2015) + +* welcome `compojure.api.core/api`, the work-horse behind `compojure.api.core/defapi`. +* lexically bound route-trees, generated by `api`, pushed to request via ring-swagger middlewares. + * no more `+compojure-api-routes+` littering the handler namespaces. +* fixes [#101](https://github.com/metosin/compojure-api/issues/101) +* fixes [#102](https://github.com/metosin/compojure-api/issues/102) +* update dependencies: + +```clojure +[metosin/ring-swagger "0.20.3"] is available but we use "0.20.2" +[prismatic/plumbing "0.4.3"] is available but we use "0.4.2" +[peridot "0.4.0"] is available but we use "0.3.1" +[compojure "1.3.4"] is available but we use "1.3.3" +[lein-ring "0.9.4"] is available but we use "0.9.3" +``` + +## 0.20.1 (2.5.2015) + +* use ring-swagger middleware swagger-data injection instead of own custom mechanism. +* fixed [#98](https://github.com/metosin/compojure-api/issues/98): 2.0 UI works when running with context on (Servlet-based) app-servers. +* Preserve response-schema names, fixes [#93](https://github.com/metosin/compojure-api/issues/93). +* updated dependencies: + +```clojure +[metosin/ring-swagger "0.20.2"] is available but we use "0.20.0" +[prismatic/schema "0.4.2"] is available but we use "0.4.1" +``` + +## 0.20.0 (24.4.2015) + +* New restructuring for `:no-doc` (a boolean) - endpoints with this don't get api documentation. +* Fixed [#42](https://github.com/metosin/compojure-api/issues/42) - `defroutes*` now does namespace resolution for the source +used for route peeling and source linking (the macro magic) +* Fixed [#91](https://github.com/metosin/compojure-api/issues/91) - `defroutes*` are now automatically accessed over a Var for better development flow. +* Fixed [#89](https://github.com/metosin/compojure-api/issues/89). +* Fixed [#82](https://github.com/metosin/compojure-api/issues/82). +* Fixed [#71](https://github.com/metosin/compojure-api/issues/71), [ring-swagger-ui](https://github.com/metosin/ring-swagger-ui) +is now a dependency. + +* **breaking** `ring.swagger.json-schema/describe` is now imported into `compojure.api.sweet` for easy use. If your code +refers to it directly, you need remove the direct reference. + +### Swagger 2.0 -support + +#### [Migration Guide](https://github.com/metosin/compojure-api/wiki/Migration-from-Swagger-1.2-to-2.0) + +* Routes are collected always from the root (`defapi` or `compojure.api.routes/api-root` within that) +* `compojure.api.routes/with-routes` is now `compojure.api.routes/api-root` +* **breaking** requires the latest swagger-ui to work + * `[metosin/ring-swagger-ui "2.1.1-M2"]` to get things pre-configured + * or package `2.1.1-M2` yourself from the [source](https://github.com/swagger-api/swagger-ui). +* **breaking**: api ordering is not implemented. +* **breaking**: restructuring `:nickname` is now `:operationId` +* **breaking**: restructuring `:notes` is now `:description` +* `swagger-docs` now takes any valid Swagger Spec data in. Using old format gives a warning is to STDOUT. + +```clojure +(swagger-docs + {:info {:version "1.0.0" + :title "Sausages" + :description "Sausage description" + :termsOfService "http://helloreverb.com/terms/" + :contact {:name "My API Team" + :email "foo@example.com" + :url "http://www.metosin.fi"} + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"}} + :tags [{:name "kikka", :description "kukka"}]}) +``` + +* Swagger-documentation default uri is changed from `/api/api-docs` to `/swagger.json`. +* `compojure.api.swagger/swaggered` is deprecated - not relevant with 2.0. Works, but prints out a warning to STDOUT +** in 2.0, apis are categorized by Tags, one can set them either to endpoints or to paths: + +```clojure +(GET* "/api/pets/" [] + :tags ["pet"] + (ok ...)) +``` + +```clojure +(context* "/api/pets" [] + :tags ["pet"] + (GET* "/" [] + :summary "get all pets" + (ok ...))) +``` + +- updated deps: + +```clojure +[metosin/ring-swagger "0.20.0"] is available but we use "0.19.4" +[prismatic/schema "0.4.1"] is available but we use "0.4.0" +``` + +## 0.19.3 (9.4.2015) +- Fixed [#79](https://github.com/metosin/compojure-api/issues/79) by [Jon Eisen](https://github.com/yanatan16) + +- updated deps: + +```clojure +[prismatic/plumbing "0.4.2"] is available but we use "0.4.1" +[prismatic/schema "0.4.1"] is available but we use "0.4.0" +[potemkin "0.3.13"] is available but we use "0.3.12" +[compojure "1.3.3"] is available but we use "1.3.2" +[metosin/ring-swagger "0.19.4"] is available but we use "0.19.3" +``` + +## 0.19.2 (31.3.2015) + +- Compatibility with swagger-ui `2.1.0-M2` - `[metosin/ring-swagger-ui "2.1.0-M2-2]` +- updated deps: +```clojure +[metosin/ring-swagger "0.19.3"] is available but we use "0.19.2" +``` + +## 0.19.1 (31.3.2015) +- avoid reflection fixes by [Michael Blume](https://github.com/MichaelBlume) +- one can now wrap body & response-models in predicates and get the swagger docs out: + +```clojure + :return (s/maybe User) + :responses {200 (s/maybe User) + 400 (s/either Cat Dog)} +``` + +- updated deps: + +```clojure +[metosin/ring-swagger "0.19.2"] is available but we use "0.19.1" +``` + +## 0.19.0 (28.3.2015) + +- added destructuring for `:headers`, thanks to [tchagnon](https://github.com/tchagnon)! +- `:path-param` allows any keywords, needed for the partial parameter matching with `context*` +- **BREAKING**: parameters are collected in (Ring-)Swagger 2.0 format, might break client-side `compojure.api.meta/restructure-param` dispatch functions - for the swagger documentation part. See https://github.com/metosin/ring-swagger/blob/master/test/ring/swagger/swagger2_test.clj & https://github.com/metosin/compojure-api/blob/master/src/compojure/api/meta.clj for examples of the new schemas. +- `context*` to allow setting meta-data to mid-routes. Mid-route meta-data are deep-merged into endpoint swagger-definitions at compile-time. At runtime, code is executed in place. + +```clojure +(context* "/api/:kikka" [] + :summary "summary inherited from context" + :path-params [kikka :- s/Str] ; enforced here at runtime + :query-params [kukka :- s/Str] ; enforced here at runtime + (GET* "/:kakka" [] + :path-params [kakka :- s/Str] ; enforced here at runtime + (ok {:kikka kikka + :kukka kukka + :kakka kakka}))) +``` + +- updated deps: + +```clojure +[prismatic/plumbing "0.4.1"] is available but we use "0.3.7" +[potemkin "0.3.12"] is available but we use "0.3.11" +[prismatic/schema "0.4.0"] is available but we use "0.3.7" +[metosin/ring-http-response "0.6.1"] is available but we use "0.6.0" +[metosin/ring-swagger "0.19.0"] is available but we use "0.18.1" +[lein-ring "0.9.3"] is available but we use "0.9.2" +``` + +## 0.18.0 (2.3.2015) + +- Support passing options to specific format middlewares (merged into defaults): +```clj +(defapi app + {:format {:formats [:json-kw :yaml-kw :edn :transit-json :transit-msgpack] + :params-opts {} + :response-opts {}} + :validation-errors {:error-handler nil + :catch-core-errors? nil} + :exceptions {:exception-handler default-exception-handler}} + ...) +``` +- import `compojure.core/wrap-routes` into `compojure.api.sweet` +- **BREAKING**: in `compojure.api.middleware`, `ex-info-support` is now parameterizable `wrap-exception` + - fixes [#68](https://github.com/metosin/compojure-api/issues/68) +- Update dependencies +``` +[prismatic/plumbing "0.3.7"] is available but we use "0.3.5" +[compojure "1.3.2"] is available but we use "1.3.1" +[prismatic/schema "0.3.7"] is available but we use "0.3.3" +[metosin/ring-swagger "0.18.0"] is available but we use "0.15.0" +[metosin/ring-http-response "0.6.0"] is available but we use "0.5.2" +[metosin/ring-middleware-format "0.6.0"] is available but we use "0.5.0" +``` + +## 0.17.0 (10.1.2015) + +- Depend on forked version of [`ring-middleware-format`](https://github.com/metosin/ring-middleware-format) + - Transit support should now work + - If you are depending on ring-middleware-format directly, you'll want to either + update your dependency or exclude one from Compojure-api +- Update dependencies: +```clojure +[cheshire "5.4.0"] is available but we use "5.3.1" +[metosin/ring-swagger-ui "2.0.24"] is available but we use "2.0.17" +[lein-ring "0.9.0"] is available but we use "0.8.13" +``` + +## 0.16.6 (8.12.2014) + +- fix #53 +- update deps: +```clojure +[compojure "1.3.1"] is available but we use "1.2.1" +[metosin/ring-swagger "0.15.0"] is available but we use "0.14.1" +[peridot "0.3.1"] is available but we use "0.3.0" +``` + +## 0.16.5 (21.11.2014) + +- fix anonymous Body & Return model naming issue [56](https://github.com/metosin/compojure-api/issues/56) by [Michael Blume](https://github.com/MichaelBlume) +- update deps: + +```clojure +[prismatic/schema "0.3.3"] is available but we use "0.3.2" +[metosin/ring-http-response "0.5.2"] is available but we use "0.5.1" +``` + +## 0.16.4 (10.11.2014) + +- use `[org.tobereplaced/lettercase "1.0.0"]` for camel-casing (see https://github.com/metosin/compojure-api-examples/issues/1#issuecomment-62580504) +- updated deps: +```clojure +[metosin/ring-swagger "0.14.1"] is available but we use "0.14.0" +``` + +## 0.16.3 (9.11.2014) + +- support for `:form-parameters`, thanks to [Thomas Whitcomb](https://github.com/thomaswhitcomb) +- update deps: + +```clojure +[prismatic/plumbing "0.3.5"] is available but we use "0.3.3" +[potemkin "0.3.11"] is available but we use "0.3.8" +[compojure "1.2.1"] is available but we use "1.1.9" +[prismatic/schema "0.3.2"] is available but we use "0.2.6" +[metosin/ring-http-response "0.5.1"] is available but we use "0.5.0" +[metosin/ring-swagger "0.14.0"] is available but we use "0.13.0" +[lein-ring "0.8.13"] is available but we use "0.8.11" +``` + +## 0.16.2 (11.9.2014) + +- Fixed #47: `:middlewares` broke route parameters + +## 0.16.1 (11.9.2014) + +- Compiled without AOT +- Removed `:yaml-in-html` and `:clojure` from default response formats + +## 0.16.0 (10.9.2014) + +- Some cleaning + - Requires now Clojure 1.6.0 for `clojure.walk` +- Support other formats in addition to JSON + - Uses the [ring-middleware-format](https://github.com/ngrunwald/ring-middleware-format) to parse requests and encode responses +- Fixes #43: Middlewares added to route with :middlewares shouldn't leak to other routes in same context anymore + +## 0.15.2 (4.9.2014) + +- Update to latest `ring-swagger` + +## 0.15.1 (19.8.2014) + +- Update to latest `ring-swagger` + - Fixes #16: If Schema has many properties, they are now shown in correct order on Swagger-UI + - `hash-map` loses the order if it has enough properties + - ~~Use [flatland.ordered.map/ordered-map](https://github.com/flatland/ordered) when Schema has many properties and you want to keep the order intact~~ + - ~~`(s/defschema Thingie (ordered-map :a String ...))`~~ + +## 0.15.0 (10.8.2014) + +- Use latest `ring-swagger` +- `:body` and others no langer take description as third param, instead use `:body [body (describe Schema "The description")]` + - `describe` works also for Java classes `:query-params [x :- (describe Long "first-param")]` + - And inside defschema `(s/defschema Schema {:sub (describe [{:x Long :y String}] "Array of stuff")})` + +## 0.14.0 (9.7.2014) + +- return model coercion returns `500` instead of `400`, thanks to @phadej! +- added support for returning primitives, thanks to @phadej! + +```clojure +(GET* "/plus" [] + :return Long + :query-params [x :- Long {y :- Long 1}] + :summary "x+y with query-parameters. y defaults to 1." + (ok (+ x y))) +``` + +- `:responses` restructuring to (error) return codes and models, thanks to @phadej! + +```clojure +(POST* "/number" [] + :return Total + :query-params [x :- Long y :- Long] + :responses {403 ^{:message "Underflow"} ErrorEnvelope} + :summary "x-y with body-parameters." + (let [total (- x y)] + (if (>= total 0) + (ok {:total (- x y)}) + (forbidden {:message "difference is negative"})))) +``` + +## 0.13.3 (28.6.2014) + +- support for `s/Uuid` via latest `ring-swagger`. +- fail-fast (with client-typos): remove default implementation from `compojure.api.meta/restructure-param` + +## 0.13.2 (28.6.2014) + +- restructure `:header-params` (fixes #31) +- remove vanilla compojure-examples, internal cleanup + +## 0.13.1 (22.6.2014) + +- allow primitives as return types (with help of `[metosin/ring-swagger 0.10.2]`) + - all primitives are supported when wrapped into sequences and sets + - directly, only `String` is supported as [Ring](https://github.com/ring-clojure/ring/blob/master/SPEC) doesn't support others + - in future, there could be a special return value coercer forcing all other primitives as Strings + +## 0.13.0 (21.6.2014) + +- first take on removing the global route state => instead of global `swagger` atom, there is one defined `+routes+` var per namespace + - requires a `compojure.api.core/with-routes` on api root to generate and hold the `+routes+` (automatically bundled with `defapi`) +- update ring-swagger to `0.10.1` to get support for `s/Keyword` as a nested schema key. + +## 0.12.0 (17.6.2014) + +- **possibly breaking change**: `middlewares` macro and `:middlewares` restructuring now use thread-first to apply middlewares +- update ring-swagger to `0.9.1` with support for vanilla `schema.core/defschema` schemas + - big internal cleanup, removing model var-resolutions, lot's of internal fns removed +- added `defroutes` to `compojure.api.legacy` +- removed defns from `compojure.api.common`: `->Long`, `fn->`, `fn->>` +- cleaner output from `compojure.api.meta/restructure` (doesn't generate empty `lets` & `letks`) + +## 0.11.6 (5.6.2014) + +- added `compojure.api.legacy` ns to have the old Compojure HTTP-method macros (`GET`, `POST`,...) + +## 0.11.5 (1.6.2014) + +- Update dependencies: + +``` +[prismatic/plumbing "0.3.1"] is available but we use "0.2.2" +[compojure "1.1.8"] is available but we use "1.1.7" +[prismatic/schema "0.2.3"] is available but we use "0.2.2" +[metosin/ring-swagger "0.8.8"] is available but we use "0.8.7" +[peridot "0.3.0"] is available but we use "0.2.2" +``` + +## 0.11.4 (19.5.2014) + +- Really updated ring-swagger dependency as I forgot that last with previous release + +## 0.11.3 (12.5.2014) + +- remove non-first trailing spaces from Compojure-routes for swagger-docs. +- updated dependencies: + - `[metosin/ring-swagger "0.8.7"]` + - `[metosin/ring-swagger-ui "2.6.16-2"]` +- Moved swagger-ui handler to ring-swagger + +## 0.11.2 (7.5.2014) + +- updated dependencies: + - `[compojure "1.1.7"]` + - `[prismatic/schema "0.2.2"]` + - `[metosin/ring-swagger "0.8.5"]` + +- `consumes` and `produces` are now feed to `ring-swagger` based on the installed middlewares. + +## 0.11.1 (4.5.2014) + +- fix for https://github.com/metosin/compojure-api/issues/19 + +## 0.11.0 (29.4.2014) + +- change signature of `restructure-param` to receive key, value and the accumulator. Remove the key from accumulator parameters by default. No more alpha. +- separate restructuring into own namespace `meta` +- **new**: `:middlewares` restructuring to support adding middlewares to routes: + +```clojure + (DELETE* "/user/:id" [] + :middlewares [audit-support (for-roles :admin)] + (ok {:name "Pertti"}))) +``` + +- **breaking change**: `with-middleware` is renamed to `middlewares` & it applies middlewares in reverse order +- more docs on creating own metadata DSLs +- use `clojure.walk16` internally + +## 0.10.4 (16.4.2014) + +- fixed https://github.com/metosin/compojure-api/issues/12 +- added http-kit example + +## 0.10.3 (15.4.2014) + +- renamed `clojure.walk` to `clojure.walk16` +- writing routes to `swagger` atom happens now at runtime, not compile-time. Works with AOT. + +## 0.10.2 (14.4.2014) + +- All `compojure.api.core` restructuring are now using `restructure-param` multimethod to allow external extensions. ALPHA. + +## 0.10.1 (13.4.2014) + +- FIXED https://github.com/metosin/compojure-api/issues/9 + - `swaggered` resources are now collected in order + +## 0.10.0 (10.4.2014) + +- fixed bug with missing `+compojure-api-request+` when having both Compojure destructuring & Compojure-api destructuring in place +- added support for `:body-params` (with strict schema): + +```clojure +(POST* "/minus" [] + :body-params [x :- Long y :- Long] + :summary "x-y with body-parameters" + (ok {:total (- x y)})) +``` + +## 0.9.1 (9.4.2014) + +- update `ring-swagger` to `0.8.4` to get better basepath-resolution (with reverse-proxies) + +## 0.9.0 (6.4.2014) + +- support for Schema-aware `:path-parameters` and `query-parameters`: + +```clojure +(GET* "/sum" [] + :query-params [x :- Long y :- Long] + :summary "sums x & y query-parameters" + (ok {:total (+ x y)})) + +(GET* "/times/:x/:y" [] + :path-params [x :- Long y :- Long] + :summary "multiplies x & y path-parameters" + (ok {:total (* x y)})) +``` + +## 0.8.7 (30.3.2014) + +- `swagger-ui` index-redirect work also under a context when running in an legacy app-server. Thanks to [Juha Syrjälä](https://github.com/jsyrjala) for the PR. + +## 0.8.6 (29.3.2014) + +- use `instanceof?` to match records instead of `=` with class. Apps can now be uberwarred with `lein ring uberwar`. + +## 0.8.5 (25.3.2014) + +- update `ring-swagger` to `0.8.3`, generate path-parameters on client side + +## 0.8.4 (25.3.2014) + +- update `ring-swagger` to `0.8.1`, all JSON-schema generation now done there. + +## 0.8.3 (15.3.2014) + +- coerce return values with smart destructuring, thanks to [Arttu Kaipiainen](https://github.com/arttuka). +- update `ring-http-response` to `0.4.0` +- handle json-parse-errors by returning JSON +- rewrite `compojure.api.core-integration-test` using `peridot.core` + +## 0.8.2 (10.3.2014) + +- Swagger path resolution works now with Compojure [regular expression matching in URL parameters](https://github.com/weavejester/compojure/wiki/Routes-In-Detail). Thanks to [Arttu Kaipiainen](https://github.com/arttuka). + +```clojure +(context "/api" [] + (GET* ["/item/:name" :name #"[a-z/]+"] [name] identity)) +``` + +- Sets really work now with smart destructuring of `GET*` and `POST*`. Addeds tests to verify. + +## 0.8.1 (6.3.2104) + +- update `ring-swagger` to `0.7.3` +- initial support for smart query parameter destructuring (arrays and nested params don't get swagger-ui love yet - but work otherwise ok) + +```clojure + (GET* "/echo" [] + :return Thingie + :query [thingie Thingie] + (ok thingie)) ;; here be coerced thingie +``` + +## 0.8.0 (5.3.2014) + +- Breaking change: `compojure.api.routes/defroutes` is now `compojure.api.core/defroutes*` to avoid namespace clashes & promote it's different. +- FIXED https://github.com/metosin/compojure-api/issues/4 + - reverted "Compojures args-vector is now optional with `compojure.api.core` web methods" + +## 0.7.3 (4.3.2014) + +- removed the Compojure Var pimp. Extended meta-data syntax no longer works with vanilla Compojure but requires the extended macros from `compojure.api.core`. +- update to `Ring-Swagger` to `0.7.2` + +## 0.7.2 (3.3.2014) + +- date-format can be overridden in the `json-response-support`, thanks to Dmitry Balakhonskiy +- Update `Ring-Swagger` to `0.7.1` giving support for nested Maps: + +```clojure + (defmodel Customer {:id String + :address {:street String + :zip Long + :country {:code Long + :name String}}}) +``` + +- schema-aware body destructuring with `compojure.api.core` web methods does now automatic coercion for the body +- Compojures args-vector is now optional with `compojure.api.core` web methods + +```clojure + (POST* "/customer" + :return Customer + :body [customer Customer] + (ok customer))) ;; we have a coerced customer here +``` + +## 0.7.1 (1.3.2014) + +- update `ring-swagger` to `0.7.0` + - support for `schema/maybe` and `schema/both` + - consume `Date` & `DateTime` both with and without milliseconds: `"2014-02-18T18:25:37.456Z"` & `"2014-02-18T18:25:37Z"` +- name-parameter of `swaggered` is stripped out of spaces. + +## 0.7.0 (19.2.2014) + +- update `ring-swagger` to `0.6.0` + - support for [LocalDate](https://github.com/metosin/ring-swagger/blob/master/CHANGELOG.md). +- updated example to cover all the dates. +- `swaggered` doesn't have to contain container-element (`context` etc.) within, endpoints are ok: + +```clojure + (swaggered "ping" + :description "Ping api" + (GET* "/ping" [] (ok {:ping "pong"}))) +``` + +- body parameter in `POST*` and `PUT*` now allows model sequences: + +```clojure + (POST* "/pizzas" [] + :body [pizzas [NewPizza] {:description "new pizzas"}] + (ok (add! pizzas))) +``` + +## 0.6.0 (18.2.2014) + +- update `ring-swagger` to `0.5.0` to get support for [Data & DateTime](https://github.com/metosin/ring-swagger/blob/master/CHANGELOG.md). + +## 0.5.0 (17.2.2014) + +- `swaggered` can now follow symbols pointing to a `compojure.api.routes/defroutes` route definition to allow better route composition. +- `compojure.api.sweet` now uses `compojure.api.routes/defroutes` instead of `compojure.core/defroutes` + +## 0.4.1 (16.2.2014) + +- Fixed JSON Array -> Clojure Set coercing with Strings + +## 0.4.0 (13.2.2014) + +- Initial public version diff --git a/CHANGELOG.md b/CHANGELOG.md index 4672867b..0defee60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,501 @@ -## NEXT - -## NEXT -* drop support for Clojure 1.8 -* upgrade cheshire 5.13.0 -* Backport: use muuntaja in compojure.api.validator - -## 1.1.14 (2024-04-30) +See also: [compojure-api 1.1.x changelog](./CHANGELOG-1.1.x.md) + +## Next +* Lazily load spec and schema coercion +* bump spec-tools to 0.10.6 + * notable changes: swagger `:name` defaults to `"body"` instead of `""` ([diff](https://github.com/metosin/spec-tools/compare/0.10.2...0.10.3)) + +## 2.0.0-alpha34-SNAPSHOT +* **BREAKING CHANGE**: `:formatter :muuntaja` sometimes required for `api{-middleware}` options + * to prepare for 1.x compatibility, :muuntaja must be explicitly configured + * Migration instructions: run your program and fix the error messages, which will provide specific instructions. + * to circumvent this change, set `-Dcompojure.api.middleware.global-default-formatter=:muuntaja` + * stable 2.x will default `:formatter` to `:ring-middleware-format` +* [#472](https://github.com/metosin/compojure-api/issues/472): Fix potential memory leak involving schemas being cached in the multimethod default cache + +## 2.0.0-alpha33 (2024-04-30) +* Throw an error on malformed `:{body,query,headers}`, in particular if anything other than 2 elements was provided + * Disable check with `-Dcompojure.api.meta.allow-bad-{body,query,headers}=true` +* 50% reduction in the number of times `:{return,body,query,responses,headers,coercion,{body,form,header,query,path}-params}` schemas/arguments are evaluated/expanded + * saves 1 evaluation per schema for static contexts + * saves 1 evaluation per schema, per request, for dynamic contexts +* Fix: Merge `:{form,multipart}-params` `:info :public :parameters :formData` field at runtime +* Add `:outer-lets` field to `restructure-param` result which wraps entire resulting form +* Remove `static-context` macro and replace with equivalent expansion without relying on compojure internals. +* Upgrade to ring-swagger 1.0.0 to fix memory leaks + +## 2.0.0-alpha32 (2024-04-20) + +* Fix empty spec response coercion. [#413](https://github.com/metosin/compojure-api/issues/413) +* Add back `defapi` (and deprecate it) * Remove potemkin [#445](https://github.com/metosin/compojure-api/issues/445) -* backport `route-middleware` -* deprecate `middleware` -* upgrade to ring-swagger 1.0.0 to fix memory leaks +* Add back `compojure.api.routes/create` +* Add back `middleware` (and deprecate it) +* Make `context` :dynamic by default +* Add `:static true` option to `context` +* Add static context optimization coach + * `-Dcompojure.api.meta.static-context-coach=print` to print hints + * `-Dcompojure.api.meta.static-context-coach=assert` to assert hints +* port unit tests from midje to clojure.test -## 1.1.13 (2019-11-02) +## 2.0.0-alpha31 (2019-12-20) -* **BREAKING**: Drop support for Java 6, Java 7. -* Support Java 13. * Update dependencies. -## 1.1.12 (27.2.2018) +## 2.0.0-alpha30 (2019-04-01) + +* Correct names for `:json` & `:string` coercion type transformers +* Update dependencies: + +```clj +[metosin/spec-tools "0.9.1"] is available but we use "0.9.0" +[metosin/muuntaja "0.6.4"] is available but we use "0.6.3" +[metosin/ring-swagger "0.26.2"] is available but we use "0.26.1" +``` + +## 2.0.0-alpha29 (2019-02-26) + +* Spec Coercion enchancements + * See [spec-tools 0.9.0 changelog](https://github.com/metosin/spec-tools/blob/master/CHANGELOG.md) + * Coercion flows values through both `st/coerce` and `st/conform`, by [Miguel Ping](https://github.com/mping) + +* Updated dependencies: + +```clj +[prismatic/schema "1.1.10"] is available but we use "1.1.9" +[metosin/muuntaja "0.6.3"] is available but we use "0.6.1" +[com.fasterxml.jackson.datatype/jackson-datatype-joda "2.9.8"] is available but we use "2.9.7" +[metosin/spec-tools "0.9.0"] is available but we use "0.8.2" +``` + +## 2.0.0-alpha28 (2018-11-19) + +* Expose full `clojure.spec` problems for exception handlers, to use pretty-printers like [expound](https://github.com/bhb/expound): + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[ring.util.http-response :refer :all]) +(require '[compojure.api.exception :as ex]) +(require '[expound.alpha :as expound]) + +(def printer + (expound/custom-printer + {:theme :figwheel-theme, :print-specs? false})) + +(def app + (api + {:coercion :spec + :exceptions + {:handlers + {::ex/request-validation + (fn [e data request] + (printer (:problems data)) + (ex/request-validation-handler e data request)) + ::ex/response-validation + (fn [e data request] + (printer (:problems data)) + (ex/response-validation-handler e data request))}}} + + (GET "/math" [] + :query-params [x :- int?, y :- int?] + :return {:total pos-int?} + (ok {:total (+ x y)})))) +``` + +* updated deps: + +```clj +[ring/ring-core "1.7.1"] is available but we use "1.7.0" +[metosin/spec-tools "0.8.2"] is available but we use "0.8.0" +[metosin/ring-http-response "0.9.1"] is available but we use "0.9.0" +``` + +## 2.0.0-alpha27 (2018-10-22) + +* **BREAKING**: dropped support for Clojure 1.8.0 +* Latest features from spec-tools + * Swagger enchancements + * Better spec coercion via st/coerce using spec walking & inference: many simple specs (core predicates, `spec-tools.core/spec`, `s/and`, `s/or`, `s/coll-of`, `s/keys`, `s/map-of`, `s/nillable` and `s/every`) can be transformed without needing spec to be wrapped. Fallbacks to old conformed based approach. + +* new deps: + +```clj +[metosin/spec-tools "0.8.0"] +``` + +## 2.0.0-alpha26 (2018-09-22) + +* update deps: + +```clj +[com.fasterxml.jackson.datatype/jackson-datatype-joda "2.9.7"] is available but we use "2.9.6" +[ring/ring-core "1.7.0"] is available but we use "1.6.3" +[metosin/jsonista "0.2.2"] is available but we use "0.2.1" +``` + +## 2.0.0-alpha25 (2018-09-03) + +* update deps: + +```clj +[metosin/muuntaja "0.6.0"] is available but we use "0.6.0-alpha4" +``` + +## 2.0.0-alpha24 (2018-09-01) + +* update deps: + +```clj +[metosin/muuntaja "0.6.0-alpha4"] is available but we use "0.6.0-alpha3" +``` + +## 2.0.0-alpha23 (2018-08-16) -Maintenance release, adding several patches from 2.0 branch. +* **BREAKING**: Don't encode response `:body` if `Content-Type` header is set. + +* update deps: + +```clj +[metosin/muuntaja "0.6.0-alpha3"] is available but we use "0.6.0-alpha1" +``` + +## 2.0.0-alpha21 (2018-06-26) + +* **BREAKING**: Use Muuntaja 0.6.0-alpha1 + * See all changes in the [Muuntaja CHANGELOG](https://github.com/metosin/muuntaja/blob/master/CHANGELOG.md) + * Highlights: + * Change default JSON Serializer from [Cheshire]() to [Jsonista]() + * Both [Joda Time](http://www.joda.org/joda-time/) and [java.time](https://docs.oracle.com/javase/8/docs/api/java/time/package-summary.html) are supported out-of-the-box + * up to [6x faster encoding](https://github.com/metosin/jsonista#performance) + * different [configuration params](https://cljdoc.xyz/d/metosin/jsonista/0.2.1/api/jsonista.core#object-mapper), guarded by migration assertion + * **BREAKING**: by default Jackson tries to encode everything, + * e.g. `java.security.SecureRandom` can be serialized, via reflection + * **BREAKING**: decoding doesn't try to keep the field order for small maps + * `muuntaja.core/install` helper to add new formats: + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[ring.util.http-response :refer :all]) + +(require '[muuntaja.core :as m]) +(require '[muuntaja.format.msgpack]) ;; [metosin/muuntaja-msgpack] +(require '[muuntaja.format.yaml]) ;; [metosin/muuntaja-yaml] + +(def formats + (m/create + (-> m/default-options + (m/install muuntaja.format.msgpack/format) + (m/install muuntaja.format.yaml/format) + ;; return byte[] for NIO servers + (assoc :return :bytes)))) + +(api + {:formats formats} + (POST "/ping" [] + (ok {:ping "pong"}))) +``` + +* add `compojure.api.middleware/wrap-format` to support multiple apis (or api + external static routes)in a project, fixes [#374](https://github.com/metosin/compojure-api/issues/374) + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[ring.util.http-response :refer [ok]]) +(require '[compojurea.api.middeware :as middleware]) + +(-> (routes + (api + (POST "/echo1" [] + :body [body s/Any] + (ok body))) + (api + + (POST "/echo2" [] + :body [body s/Any] + (ok body)))) + (middleware/wrap-format)) +``` + +* update deps: + +```clj +[ikitommi/linked "1.3.1-alpha1"] ;; waiting for original to update +[metosin/ring-swagger "0.26.1"] is available but we use "0.26.0" +[metosin/muuntaja "0.6.0-alpha1"] is available but we use "0.5.0" +[com.fasterxml.jackson.datatype/jackson-datatype-joda "2.9.6"] is available but we use 2.9.5" +``` + +## 2.0.0-alpha20 (2018-05-15) + +* welcome [spec transformers!](http://testi.metosin.fi/blog/spec-transformers/)! might break custom `coercion` implementations + +* update deps: + +```clj +[potemkin "0.4.5"] is available but we use "0.4.4" +[prismatic/schema "1.1.9"] is available but we use "1.1.7" +[frankiesardo/linked "1.3.0"] is available but we use "1.2.9" +[compojure "1.6.1"] is available but we use "1.6.0" +[metosin/spec-tools "0.7.0"] is available but we use "0.6.1" +[metosin/jsonista "0.2.0"] is available but we use "0.1.1" +``` + +## 2.0.0-alpha19 (2018-03-13) + +* Deal with coercion exceptions in async handlers, fixes [#371](https://github.com/metosin/compojure-api/issues/371), by [Benjamin Teuber](https://github.com/bsteuber) +* updated deps: + +```clj +[metosin/ring-swagger "0.26.0"] is available but we use "0.25.0" +[metosin/spec-tools "0.6.1"] is available but we use "0.5.1" +``` + +## 1.1.12 (2018-02-27) + +Maintenance 1.1 release, adding several patches from 2.0 branch. - Backport: Fix context child resolution with compojure-bindings [#370](https://github.com/metosin/compojure-api/issues/370) - Backport: merge-vector [#311](https://github.com/metosin/compojure-api/issues/311) - Backport: Fix metadata position on defmacro to activate CIDER indent style [#261](https://github.com/metosin/compojure-api/issues/261) +## 2.0.0-alpha18 (2018-01-18) + +* updated deps: + +```clj +[metosin/muuntaja "0.5.0"] is available but we use "0.4.1" +``` + +## 2.0.0-alpha17 (2018-01-10) + +* **BREAKING**: drop `defapi`. `def` + `api` should be used instead. +* Cleanup conflicting transitive dependencies +* Supports both old (2.*) and new (3.*) swagger-uis. + +```clj +[metosin/muuntaja "0.4.2"] is available but we use "0.4.1" +[metosin/ring-swagger "0.25.0"] is available but we use "0.24.3" +``` + +## 2.0.0-alpha16 + +* Rolled back the latest swagger-ui, which fails in `config not found`. There is an [issue in ring-swagger](https://github.com/metosin/ring-swagger/pull/123). + +```clj +[metosin/ring-swagger-ui "2.2.10"] is available but we use "3.0.17" +``` + +## 2.0.0-alpha15 + +* updated deps: + +```clj +[metosin/muuntaja "0.4.1"] is available but we use "0.4.0" +``` + +## 2.0.0-alpha14 + +* Fixes Muuntaja-bug of randomly failing on `:body` parameters with some server setups. + +* updated deps: + +```clj +[prismatic/plumbing "0.5.5"] is available but we use "0.5.4" +[metosin/muuntaja "0.4.0"] is available but we use "0.3.3" +[ring/ring-core "1.6.3"] is available but we use "1.6.2" +[metosin/spec-tools "0.5.1"] is available but we use "0.5.0" +``` + +## 2.0.0-alpha13 (2017-11-18) + +* Better error messages for bad `letk` syntax by [Erik Assum](https://github.com/slipset), fixes [#354](https://github.com/metosin/compojure-api/issues/354) +* `:coercion` applies for `context` parameters too (not just childs) + +* updated deps: + +```clj +[metosin/muuntaja "0.3.3"] is available but we use "0.3.2" +``` + +## 2.0.0-alpha12 (2017-10-26) + +* route sequences also produce swagger docs. + +## 2.0.0-alpha11 (2017-10-25) + +* `dynamic-context` is removed in favor of `:dynamic true` meta-data for contexts: + +```clj +(require '[compojure.api.help :as help]) + +(help/help :meta :dynamic) +; :dynamic +; +; If set to to `true`, makes a `context` dynamic, +; e.g. body is evaluated on each request. NOTE: +; Vanilla Compojure has this enabled by default +; while compojure-api default to `false`, being +; much faster. For details, see: +; +; https://github.com/weavejester/compojure/issues/148 +; +; (context "/static" [] +; (if (= 0 (random-int 2)) +; ;; mounting decided once +; (GET "/ping" [] (ok "pong"))) +; +; (context "/dynamic" [] +; :dynamic true +; (if (= 0 (random-int 2)) +; ;; mounted for 50% of requests +; (GET "/ping" [] (ok "pong"))) +``` + +* You can now include sequences of routes in `routes` and `context`: + +```clj +(context "/api" [] + (for [path ["/ping" "/pong"]] + (GET path [] (ok {:path path})))) +``` + +* updated deps: + +```clj +[metosin/ring-swagger "0.24.3"] is available but we use "0.24.2" +``` + +## 2.0.0-alpha10 (21.10.2017) + +* `ANY` produces swagger-docs for all methods, thanks to [Anthony](https://github.com/acron0) + +* Updated deps: + +```clj +[metosin/spec-tools "0.5.0"] is available but we use "0.4.0" +``` + +## 2.0.0-alpha9 (18.10.2017) + +* Stringify `:pred` under Spec `:problems`, fixes [#345](https://github.com/metosin/compojure-api/issues/345) +* Better error message if `:spec` coercion is tried without required dependencies +* Don't memoize keyword-specs for now - allow easier redefining + +## 2.0.0-alpha8 (11.10.2017) + +* Aligned with the latest spec-tools: `[metosin/spec-tools "0.4.0"]` + +* To use Clojure 1.9 & Spec with Swagger, these need to be imported: + +```clj +[org.clojure/clojure "1.9.0-beta2"] +[metosin/spec-tools "0.4.0"] +``` + +* Support `ring.middleware.http-response` exception handling directly: + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[ring.util.http-response :as http]) + +(api + {:exceptions {:handlers {::http/response handle-thrown-http-exceptions-here}} + (GET "/throws" [] + (http/bad-request! {:message "thrown response"}))) +``` + +* Use Muuntaja for all JSON transformations, drop direct dependency to Cheshire. +* Muuntaja `api` instance (if not undefined, e.g. `nil`) is injected into `:compojure.api.request/muuntaja` for endpoints to use. + * `path-for` and `path-for*` now use this to encode path-parameters. + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[compojure.api.request :as request]) +(require '[muuntaja.core :as m]) + +(api + (GET "/ping" {:keys [::request/muuntaja]} + (ok {:json-string (slurp (m/encode muuntaja "application/json" [:this "is" 'JSON]))}))) +``` + +* **FIXED**: separate `Muuntaja`-instance optins are merged correctly. + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[ring.util.http-response :refer [ok]]) +(require '[metosin.transit.dates :as transit-dates]) +(require '[muuntaja.core :as m]) + +(def muuntaja + (m/create + (-> muuntaja/default-options + (update-in + [:formats "application/transit+json"] + merge + {:decoder-opts {:handlers transit-dates/readers} + :encoder-opts {:handlers transit-dates/writers}})))) + +(api + {:formats muuntaja} + (GET "/pizza" [] + (ok {:now (org.joda.time.DateTime/now)}))) +``` + +* dropped dependencies: + +```clj +[cheshire "5.7.1"] +[org.tobereplaced/lettercase "1.0.0"] +``` + +* updated dependencies: + +```clj +[potemkin "0.4.4"] is available but we use "0.4.3" +[metosin/ring-swagger "0.24.2"] is available but we use "0.24.1" +[metosin/spec-tools "0.4.0"] is available but we use "0.3.2" +``` + +## 2.0.0-alpha7 (31.7.2017) + +* drop direct support for `application/yaml` & `application/msgpack`. If you want to add them back, you need to manually add the dependencies below and configure Muuntaja to handle those: + +```clj +(require '[muuntaja.core :as muuntaja]) +(require '[muuntaja.format.yaml :as yaml-format]) +(require '[muuntaja.format.msgpack :as msgpack-format]) + +(api + {:formats (-> muuntaja/default-options) + (yaml-format/with-yaml-format) + (msgpack-format/with-msgpack-format))} + ...) +``` + +* dropped dependencies: + +```clj +[circleci/clj-yaml "0.5.6"] +[clojure-msgpack "1.2.0"] +``` + +## 2.0.0-alpha6 (26.7.2017) + +* spec coericon also calls `s/unform` after `s/conform`, e.g. specs like `(s/or :int spec/int? :keyword spec/keyword?)` work now too. + +* updated dependencies: + +```clj +[circleci/clj-yaml "0.5.6"] is available but we use "0.5.5" +[metosin/muuntaja "0.3.2"] is available but we use "0.3.1" +[ring/ring-core "1.6.2"] is available but we use "1.6.1" +[metosin/ring-swagger "0.24.1"] is available but we use "0.24.0" +``` + ## 1.1.11 (25.7.2017) - * **BREAKING**: in `compojure.api.swagger`, the `swagger-ui` and `swagger-docs` now take options map with `path` key instead of separate optional path & vararg opts. - - normally you would use swagger api-options or `swagger-routes` and thus be unaffected of this. +* updated deps for the 1.* +* **BREAKING**: in `compojure.api.swagger`, the `swagger-ui` and `swagger-docs` now take options map with `path` key instead of separate optional path & vararg opts. + - normally you would use swagger api-options or `swagger-routes` and thus be unaffected of this. * updated dependencies: @@ -41,6 +508,620 @@ Maintenance release, adding several patches from 2.0 branch. [metosin/ring-swagger "0.24.1"] is available but we use "0.22.14" ``` +## 2.0.0-alpha5 (2.7.2017) + +* Spec coercion endpoints produce now Swagger2 data + * thanks to latest [spec-tools](https://github.com/metosin/spec-tools#swagger2-integration) + +* To use Clojure 1.9 & Spec with Swagger, these need to be imported: + +```clj +[org.clojure/clojure "1.9.0-alpha17"] +[metosin/spec-tools "0.3.0"] +``` + +* To use Clojure 1.8 & Spec with Swagger, these need to be imported: + +```clj +[org.clojure/clojure "1.8.0"] +[metosin/spec-tools "0.3.0" :exclusions [org.clojure/spec.alpha]] +[clojure-future-spec "1.9.0-alpha17"] +``` + +* If the dependencies are found, the following entry should appear on log: + +``` +INFO :spec swagger generation enabled in compojure.api +``` + +* updated deps: + +``` +[metosin/spec-tools "0.3.0"] is available but we use "0.2.2" +``` + +* Schema coercion errors have the :schema as `pr-str` value. +* `resource` body-params are associated over existing instead of merged. e.g. extra params are really stripped off. + +## 2.0.0-alpha4 (19.6.2017) + +* Update to latest [Muuntaja](https://github.com/metosin/muuntaja). + * by default, allow empty input body for all formats + * see [changelog](https://github.com/metosin/muuntaja/blob/master/CHANGELOG.md#030-1962017) + +* updated deps: + +``` +[metosin/muuntaja "0.3.1"] is available but we use "0.2.2" +``` + +## 2.0.0-alpha3 (13.6.2017) + +* move `compojure.api.request` back to `src`. + +## 2.0.0-alpha2 (13.6.2017) + +* **BREAKING**: Simplified pluggable coercion. + * **NO SWAGGER-DOCS YET**, see https://github.com/metosin/spec-swagger + * guide in wiki: https://github.com/metosin/compojure-api/wiki/Coercion + * injected in request under `:compojure.api.request/coercion` + * new namespace `compojure.api.coercion`, replacing `compojure.api.coerce`. + * `:coercion` can be set to `api`, `context`, endpoint macros or a `resource`. It can be either: + * something satisfying `compojure.api.coercion.core/Coercion` + * a Keyword for looking up a predefined `Coercion` via `compojure.api.coercion.core/named-coercion` multimethod. + * `coercion` is stored in Route `:info` + * signature of `Coercion`: + +```clj +(defprotocol Coercion + (get-name [this]) + (get-apidocs [this spec data]) + (encode-error [this error]) + (coerce-request [this model value type format request]) + (coerce-response [this model value type format request])) +``` + +### Predefined coercions + +* `:schema` (default) resolves to `compojure.api.coercion.schema/SchemaCoercion` +* `:spec` resolves to `compojure.api.coercion.spec/SpecCoercion` + * automatically available if [spec-tools](https://github.com/metosin/spec-tools) is found in classpath + * to enable runtime conforming, use [Spec Records](https://github.com/metosin/spec-tools#spec-records) + * works both with vanilla specs & [data-specs](https://github.com/metosin/spec-tools#data-specs) +* `nil` removes the coercion (was: `nil` or `(constantly nil)`). + +#### Spec with resources + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[clojure.spec.alpha :as s]) +(require '[spec-tools.spec :as spec]) + +(s/def ::id spec/int?) +(s/def ::name spec/string?) +(s/def ::description spec/string?) +(s/def ::type spec/keyword?) +(s/def ::new-pizza (s/keys :req-un [::name ::type] :opt-un [::description])) +(s/def ::pizza (s/keys :req-un [::id ::name ::type] :opt-un [::description])) + +(resource + {:coercion :spec + :summary "a spec resource, no swagger yet" + :post {:parameters {:body-params ::new-pizza} + :responses {200 {:schema ::pizza}} + :handler (fn [{new-pizza :body-params}] + (ok (assoc new-pizza :id 1)))}}) +``` + +#### Spec with endpoints + +```clj +(require '[spec-tools.data-spec :as ds]) + +(s/def ::id spec/int?) + +(context "/spec" [] + :coercion :spec + + (POST "/pizza" [] + :summary "a spec endpoint" + :return ::pizza + :body [new-pizza ::new-pizza] + (ok (assoc new-pizza :id 1))) + + (POST "/math/:x" [] + :summary "a spec endpoint" + :return {:total int?} + :path-params [x :- spec/int?] + :query-params [y :- spec/int?, + {z :- spec/int? 0}] + (ok {:total (+ x y z)}))) +``` + +* To use Clojure 1.9 & Spec, these need to be imported: + +```clj +[org.clojure/clojure "1.9.0-alpha17"] +[metosin/spec-tools "0.2.2"] +``` + +* To use Clojure 1.8 & Spec, these need to be imported: + +```clj +[org.clojure/clojure "1.8.0"] +[metosin/spec-tools "0.2.2" :exclusions [org.clojure/spec.alpha]] +[clojure-future-spec "1.9.0-alpha17"] +``` + +* **BREAKING**: Clojure 1.7.0 is no longer supported (no back-port for `clojure.spec`). + +* use ClassLoader -scoped Schema memoization instead of api-scoped - same for anonymous map specs + +* `:body-params` is available for exception handlers, fixes [#306](https://github.com/metosin/compojure-api/issues/306) & [#313](https://github.com/metosin/compojure-api/issues/313) + +* **BREAKING**: Restructuring internal key changes in `compojure.api.meta`: + * `:swagger` is removed in favor of `:info`. + * swagger-data is pushed to `[:info :public]` instead of `[:swagger]` + * top-level `:info` can contain: + - `:static-context?` -> `true` if the `context` is internally optimized as static + - `:name`, route name + - `:coercion`, the defined coercion + +## 2.0.0-alpha1 (30.5.2017) + +* More descriptive error messages, fixes [#304](https://github.com/metosin/compojure-api/issues/304) and [#306](https://github.com/metosin/compojure-api/issues/306): + * when request or response validation fails, more info is provided both to exception hanlders and via default implementations to external clients: + +```clj +(let [app (GET "/" [] + :return {:x String} + (ok {:kikka 2}))] + (try + (app {:request-method :get, :uri "/"}) + (catch Exception e + (ex-data e)))) +; {:type :compojure.api.exception/response-validation, +; :validation :schema, +; :in [:response :body], +; :schema {:x java.lang.String}, +; :errors {:x missing-required-key, +; :kikka disallowed-key}, +; :response {:status 200, +; :headers {}, +; :body {:kikka 2}}} +``` + +```clj +(let [app (GET "/" [] + :query-params [x :- String] + (ok))] + (try + (app {:request-method :get, :uri "/" :query-params {:x 1}}) + (catch Exception e + (ex-data e)))) +; {:type :compojure.api.exception/request-validation, +; :validation :schema, +; :value {:x 1}, +; :in [:request :query-params], +; :schema {Keyword Any, :x java.lang.String}, +; :errors {:x (not (instance? java.lang.String 1))}, +; :request {:request-method :get, +; :uri "/", +; :query-params {:x 1}, +; :route-params {}, +; :params {}, +; :compojure/route [:get "/"]}} +``` + +* Introduce `dynamic-context` that works like `context` before the fast context optimization ([#253](https://github.com/metosin/compojure-api/pull/253)). + * If you build routes dynamically inside `context`, they will not work as intended. If you need this, replace `context` with `dynamic-context`. + * See issue [#300](https://github.com/metosin/compojure-api/issues/300). + + For example: + +```clj +;; compojure-api 1.1 +(context "/static" [] + (if (its-noon?) + (GET "/noon-route" [] (ok "it's noon"))) + +;; compojure-api 1.2: +(dynamic-context "/static" [] + (if (its-noon?) + (GET "/noon-route" [] (ok "it's noon"))) +``` + +* Remove restructuring migration helpers for `1.0.0` (for `:parameters` and `:middlewares`) + +## 1.2.0-alpha8 (18.5.2017) + +* **BREAKING**: `resource` function is always 1-arity, options and info are merged. + +* `resource` can have `:middleware` on both top-level & method-level. + * top-level mw are applied first if the resource can handle the request + * method-level mw are applied second if the method matches + +```clj +(def mw [handler value] + (fn [request] + (println value) + (handler request))) + +(resource + {:middleware [[mw :top1] [mw :top2]] + :get {:middleware [[mw :get1] [mw :get2]]} + :post {:middleware [[mw :post1] [mw :post2]]} + :handler (constantly (ok))}) +``` + +* updated deps: + +```clj +[prismatic/schema "1.1.6"] is available but we use "1.1.5" +``` + +## 1.2.0-alpha7 (15.5.2017) + +* **BREAKING**: `resource` separates 1-arity `:handler` and 3-arity `:async-handler`. Rules: + * if resource is called with 1-arity, `:handler` is used, sent via `compojure.response/render` + * if resource is called with 3-arity, `:async-handler` is used, with fallback to `:handler`. + * sent via `compojure.response/send` so [manifold](https://github.com/ztellman/manifold) `Deferred` and [core.async](https://github.com/clojure/core.async) `ManyToManyChannel` can be returned. + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[clojure.core.async :as a]) +(require '[manifold.deferred :as d]) + +(resource + {:summary "async resource" + :get {:summary "normal ring async" + :async-handler (fn [request respond raise] + (future + (Thread/sleep 100) + (respond (ok {:hello "world"}))) + nil)} + :put {:summary "core.async" + :handler (fn [request] + (a/go + (a/ request :query-params :x)}))) + nil)}) +``` + +* updated deps: + +```clj +[ring/ring-core "1.6.0"] +[cheshire "5.7.1"] is available but we use "5.7.0" +[compojure "1.6.0"] is available but we use "1.5.2" +[prismatic/schema "1.1.5"] is available but we use "1.1.4" +[prismatic/plumbing "0.5.4"] is available but we use "0.5.3" +[metosin/ring-http-response "0.9.0"] is available but we use "0.8.2" +[metosin/ring-swagger "0.24.0"] is available but we use "0.23.0" +[compojure "1.6.0"] is available but we use "1.6.0-beta3" +``` + +## 1.2.0-alpha5 (31.3.2017) + +* Use the latest Muuntaja. +* Test with `[org.clojure/clojure "1.9.0-alpha15"]` (requires Midje `1.9.0-alpha6`) + +```clj +[metosin/muuntaja "0.2.1"] is available but we use "0.2.0-20170323.064148-15" +``` + +## 1.2.0-alpha4 (23.3.2017) + +* Initial support for Async Ring, using CPS, [manifold](https://github.com/ztellman/manifold) or [core.async](https://github.com/clojure/core.async) + * more info at https://github.com/metosin/compojure-api/wiki/Async + +* `compojure.api.core/ring-handler` to turn a compojure-api route into a 1-arity function + * can be passed into servers requiring handlers to be `Fn` + +* `:params` are populated correctly from `:body-params` + +* Allow `nil` paths in routing, allows easy (static) conditional routing like: + +```clj +(defn app [dev-mode?] + (api + (GET "ping" [] (ok "pong")) + (if dev-mode? + (GET "/drop-the-db" [] (ok "dropped"))))) +``` + +* Support `java.io.File` as response type, mapping to file downloads + * no response coercion + * fixes [#259](https://github.com/metosin/compojure-api/issues/259) + +```clj +(GET "/file" [] + :summary "a file download" + :return java.io.File + :produces #{"image/png"} + (-> (io/resource "screenshot.png") + (io/input-stream) + (ok) + (header "Content-Type" "image/png")))) +``` + +* Fix help-for for some restructure methods [#275](https://github.com/metosin/compojure-api/pull/275) by [Nicolás Berger](https://github.com/nberger) +* **BREAKING**: in `compojure.api.swagger`, the `swagger-ui` and `swagger-docs` now take options map with `path` key instead of separate optional path & vararg opts. + - normally you would use swagger api-options or `swagger-routes` and thus be unaffected of this. +* **BREAKING**: `middleware` is removed because it dangerously applied the +middleware even to requests that didn't match the contained routes. New `route-middleware` +only applies middlewares when the request is matched against contained routes. + * `route-middleware` is not exposed in `sweet` namespace but is available at `compojure.api.core` + +* Updated deps: + +```clj +[metosin/muuntaja "0.2.0-20170323.064148-15"] is available but we use "0.2.0-20170122.164054-8" +[prismatic/schema "1.1.4"] is available but we use "1.1.3" +[metosin/ring-swagger-ui "2.2.10"] is available but we use "2.2.8" +[metosin/ring-swagger "0.23.0"] is available but we use "0.22.14" +[metosin/ring-http-response "0.8.2"] is available but we use "0.8.1" +``` + +## 1.2.0-alpha3 (31.1.2017) + +* Class-based exception handling made easier, the `[:exceptions :handlers]` options also allows exception classes as keys. + * First do a `:type`-lookup, then by Exception class and it's superclasses. + * Fixes [#266](https://github.com/metosin/compojure-api/issues/272) + +```clj +(api + {:exceptions + {:handlers + {::ex/default handle-defaults + java.sql.SQLException handle-all-sql-exceptions}}} + ...) +``` + +* Lovely inline-help, `compojure.api.help/help`. + +```clojure +(require '[compojure.api.help :refer [help]]) + +(help) +; ------------------------------------------------------------ +; Usage: +; +; (help) +; (help topic) +; (help topic subject) +; +; Topics: +; +; :meta +; +; Topics & subjects: +; +; :meta :body +; :meta :body-params +; :meta :coercion +; :meta :components +; :meta :consumes +; :meta :description +; :meta :form-params +; :meta :header-params +; :meta :middleware +; :meta :multipart-params +; :meta :name +; :meta :no-doc +; :meta :operationId +; :meta :path-params +; :meta :produces +; :meta :responses +; :meta :return +; :meta :summary +; :meta :swagger +; :meta :tags + +(help/help :meta :middleware) +; ------------------------------------------------------------ +; +; :middleware +; +; Applies the given vector of middleware to the route. +; Middleware is presented as data in a Duct-style form: +; +; 1) ring mw-function (handler->request->response) +; +; 2) mw-function and it's arguments separately - mw is +; created by applying function with handler and args +; +; (defn require-role [handler role] +; (fn [request] +; (if (has-role? request role) +; (handler request) +; (unauthorized)))) +; +; (def require-admin (partial require-role :admin)) +; +; (GET "/admin" [] +; :middleware [require-admin] +; (ok)) +; +; (GET "/admin" [] +; :middleware [[require-role :admin]] +; (ok)) +; +; (GET "/admin" [] +; :middleware [#(require-admin % :admin)] +; (ok)) +; +``` + +* help can be of anything. contributing to help: + +```clojure +(defmethod help/help-for [:restructuring :query-params] [_ _] + (help/text + "Restructures query-params with plumbing letk notation.\n" + "Example: read x and optionally y (defaulting to 1)" + "from query parameters. Body of the endpoint sees the" + "coerced values.\n" + (help/code + "(GET \"/ping\"" + " :query-params [x :- Long, {y :- Long 1}]" + " (ok (+ x y)))"))) +``` + + +* Updated deps: + +```clj +[metosin/muuntaja "0.2.0-20170130.142747-9"] is available but we use "0.2.0-20170122.164054-8" +``` + +## 1.2.0-alpha2 (22.1.2017) + +**this is an alpha release, feedback welcome** + +* **BREAKING**: Requires Java 1.8 (as Muuntaja requires it) +* Fix Cider indentation for route macros, by [Joe Littlejohn](https://github.com/joelittlejohn) +* Restructuring `:body` does not keywordize all keys, + * e.g. EDN & Transit keys are not transformed, JSON keys based on the JSON decoder settings (defaulting to `true`). +* `resource` under `context` requires exact routing match, fixes [#269](https://github.com/metosin/compojure-api/issues/269) +* Endpoints can return `compojure.api.routes/Routes`, returned routes don't commit to swagger-docs - as they can be generated at runtime +* **BREAKING**: Better request & response coercion + * in `compojure.api.middleware`, the `default-coercion-matchers` is removed in favour of `create-coercion` & `default-coercion-options` + * uses negotiated format information provided by [Muuntaja](https://github.com/metosin/muuntaja#request), fixes [#266](https://github.com/metosin/compojure-api/issues/266) + * old custom `coercion` should work as before, as the contract has not changed + * **Old defaults**: coerce everything (request & response body) with `json-coercion-matcher` + * **New defaults**: see the table below: + +| Format | Request | Response | +| --------|:-------:|:------------:| +| `application/edn` | validate | validate | +| `application/transit+json` | validate | validate | +| `application/transit+msgpack` | validate | validate | +| `application/json` | `json-coercion-matcher` | validate | +| `application/msgpack` | `json-coercion-matcher` | validate | +| `application/x-yaml` | `json-coercion-matcher` | validate | + +defaults as code: + +```clj +(def default-coercion-options + {:body {:default (constantly nil) + :formats {"application/json" json-coercion-matcher + "application/msgpack" json-coercion-matcher + "application/x-yaml" json-coercion-matcher}} + :string string-coercion-matcher + :response {:default (constantly nil) + :formats {}}}) +``` + +to create a valid `coercion` (for api or to routes): + +```clj +;; create (with defaults) +(mw/create-coercion) +(mw/create-coercion mw/default-coercion-options) + +;; no response coercion +(mw/create-coercion (dissoc mw/default-coercion-options :response) + +;; disable all coercion +nil +(mw/create-coercion nil) +``` + +* Route-records printing is cleaned up + +```clj +(context "/api" [] + (GET "/ping" [] (ok)) + (POST "/echo" [] + :body [data {:name s/Str}] + :return {:name s/Str} + (ok data)) + (context "/resource" [] + (resource + {:get {:handler (constantly (ok))}}))) +; #Route {:path "/api", +; :childs [#Route {:path "/ping" +; :method :get} +; #Route {:path "/echo", +; :method :post, +; :info {:parameters {:body {:name Str}}, +; :responses {200 {:schema {:name Str} +; :description ""}}}} +; #Route {:path "/resource" +; :childs [#Route{:childs [#Route{:path "/" +; :method :get}]}]}]} +``` + +* Updated deps: + +```clj +[cheshire "5.7.0"] is available but we use "5.6.3" +[metosin/muuntaja "0.2.0-20170122.164054-8"] is available but we use "0.2.0-20161031.085120-3" +[metosin/ring-http-response "0.8.1"] is available but we use "0.8.0" +[metosin/ring-swagger "0.22.14"] is available but we use "0.22.12" +[metosin/ring-swagger-ui "2.2.8"] is available but we use "2.2.5-0" +``` + +## 1.2.0-alpha1 + +* **BREAKING**: use [Muuntaja](https://github.com/metosin/muuntaja) instead of [ring-middleware-format](https://github.com/ngrunwald/ring-middleware-format), [#255](https://github.com/metosin/compojure-api/pull/255) + for format negotiation, encoding and decoding. + - 4x more throughput on 1k JSON request-response echo + - api key `:format` has been deprecated (fails at api creation time), use `:formats` instead. It consumes either a + Muuntaja instance, Muuntaja options map or `nil` (unmounts it). See [how to configure Muuntaja](https://github.com/metosin/muuntaja/blob/master/doc/Configuration.md) how to use. +* ~~**EXPERIMENTAL**~~: fast `context`s, [#253](https://github.com/metosin/compojure-api/pull/253) - use static routes if a `context` doesn't do any lexical bindings + - up to 4x faster `context` routing. +* Support delayed child route resolution. +* Removed pre 0.23.0 api option format assertions. +* `:middleware` for `api` & `api-middleware`, run last just before the actual routes. Uses same syntax as with the routing macros. + +```clj +(api + {:middleware [no-cache [wrap-require-role :user]]} + ...) +``` + +* Updated deps: + +```clj +[metosin/muuntaja "0.2.0-SNAPSHOT"] +[metosin/ring-swagger "0.22.12"] is available but we use "0.22.11" +``` + +* Removed deps: + +```clj +[ring-middleware-format "0.7.0"] +``` + ## 1.1.10 (11.1.2017) * Updated dependencies to [avoid a path traversal vulnerability](https://groups.google.com/forum/#!topic/clojure/YDrKBV26rnA) in Ring. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..1341c2de --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +## Library Version(s) + +## Problem diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f735bee0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ +Eclipse Public License - v 1.0 + +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and documentation + distributed under this Agreement, and +b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + + where such changes and/or additions to the Program originate from and are + distributed by that particular Contributor. A Contribution 'originates' + from a Contributor if it was added to the Program by such Contributor + itself or anyone acting on such Contributor's behalf. Contributions do not + include additions to the Program which: (i) are separate modules of + software distributed in conjunction with the Program under their own + license agreement, and (ii) are not derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + a) Subject to the terms of this Agreement, each Contributor hereby grants + Recipient a non-exclusive, worldwide, royalty-free copyright license to + reproduce, prepare derivative works of, publicly display, publicly + perform, distribute and sublicense the Contribution of such Contributor, + if any, and such derivative works, in source code and object code form. + b) Subject to the terms of this Agreement, each Contributor hereby grants + Recipient a non-exclusive, worldwide, royalty-free patent license under + Licensed Patents to make, use, sell, offer to sell, import and otherwise + transfer the Contribution of such Contributor, if any, in source code and + object code form. This patent license shall apply to the combination of + the Contribution and the Program if, at the time the Contribution is + added by the Contributor, such addition of the Contribution causes such + combination to be covered by the Licensed Patents. The patent license + shall not apply to any other combinations which include the Contribution. + No hardware per se is licensed hereunder. + c) Recipient understands that although each Contributor grants the licenses + to its Contributions set forth herein, no assurances are provided by any + Contributor that the Program does not infringe the patent or other + intellectual property rights of any other entity. Each Contributor + disclaims any liability to Recipient for claims brought by any other + entity based on infringement of intellectual property rights or + otherwise. As a condition to exercising the rights and licenses granted + hereunder, each Recipient hereby assumes sole responsibility to secure + any other intellectual property rights needed, if any. For example, if a + third party patent license is required to allow Recipient to distribute + the Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + d) Each Contributor represents that to its knowledge it has sufficient + copyright rights in its Contribution, if any, to grant the copyright + license set forth in this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under +its own license agreement, provided that: + + a) it complies with the terms and conditions of this Agreement; and + b) its license agreement: + i) effectively disclaims on behalf of all Contributors all warranties + and conditions, express and implied, including warranties or + conditions of title and non-infringement, and implied warranties or + conditions of merchantability and fitness for a particular purpose; + ii) effectively excludes on behalf of all Contributors all liability for + damages, including direct, indirect, special, incidental and + consequential damages, such as lost profits; + iii) states that any provisions which differ from this Agreement are + offered by that Contributor alone and not by any other party; and + iv) states that source code for the Program is available from such + Contributor, and informs licensees how to obtain it in a reasonable + manner on or through a medium customarily used for software exchange. + +When the Program is made available in source code form: + + a) it must be made available under this Agreement; and + b) a copy of this Agreement must be included with each copy of the Program. + Contributors may not remove or alter any copyright notices contained + within the Program. + +Each Contributor must identify itself as the originator of its Contribution, +if +any, in a manner that reasonably allows subsequent Recipients to identify the +originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a manner +which does not create potential liability for other Contributors. Therefore, +if a Contributor includes the Program in a commercial product offering, such +Contributor ("Commercial Contributor") hereby agrees to defend and indemnify +every other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits and +other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such Commercial +Contributor in connection with its distribution of the Program in a commercial +product offering. The obligations in this section do not apply to any claims +or Losses relating to any actual or alleged intellectual property +infringement. In order to qualify, an Indemnified Contributor must: +a) promptly notify the Commercial Contributor in writing of such claim, and +b) allow the Commercial Contributor to control, and cooperate with the +Commercial Contributor in, the defense and any related settlement +negotiations. The Indemnified Contributor may participate in any such claim at +its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If +that Commercial Contributor then makes performance claims, or offers +warranties related to Product X, those performance claims and warranties are +such Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a +court requires any other Contributor to pay any damages as a result, the +Commercial Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED 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. Each +Recipient is solely responsible for determining the appropriateness of using +and distributing the Program and assumes all risks associated with its +exercise of rights under this Agreement , including but not limited to the +risks and costs of program errors, compliance with applicable laws, damage to +or loss of data, programs or equipment, and unavailability or interruption of +operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION +LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of the +remainder of the terms of this Agreement, and without further action by the +parties hereto, such provision shall be reformed to the minimum extent +necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted +under Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and does +not cure such failure in a reasonable period of time after becoming aware of +such noncompliance. If all Recipient's rights under this Agreement terminate, +Recipient agrees to cease use and distribution of the Program as soon as +reasonably practicable. However, Recipient's obligations under this Agreement +and any licenses granted by Recipient relating to the Program shall continue +and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to +time. No one other than the Agreement Steward has the right to modify this +Agreement. The Eclipse Foundation is the initial Agreement Steward. The +Eclipse Foundation may assign the responsibility to serve as the Agreement +Steward to a suitable separate entity. Each new version of the Agreement will +be given a distinguishing version number. The Program (including +Contributions) may always be distributed subject to the version of the +Agreement under which it was received. In addition, after a new version of the +Agreement is published, Contributor may elect to distribute the Program +(including its Contributions) under the new version. Except as expressly +stated in Sections 2(a) and 2(b) above, Recipient receives no rights or +licenses to the intellectual property of any Contributor under this Agreement, +whether expressly, by implication, estoppel or otherwise. All rights in the +Program not expressly granted under this Agreement are reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial in +any resulting litigation. diff --git a/README.md b/README.md index 914f95b3..ce6258ef 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,117 @@ -# Compojure-api 1.1.x +# Compojure-api -[Clojars 1.1.14](https://clojars.org/metosin/compojure-api/versions/1.1.14) -[![Slack](https://img.shields.io/badge/clojurians-ring_swagger-blue.svg?logo=slack)](https://clojurians.slack.com/messages/ring-swagger/) +**Psst!** If you're starting a new project, why not try out [reitit](https://github.com/metosin/reitit)? Stuff on top of [Compojure](https://github.com/weavejester/compojure) for making sweet web apis. -- [API Docs](https://cljdoc.org/d/metosin/compojure-api/1.1.14/doc/readme) & [Wiki](https://github.com/metosin/compojure-api/wiki) -- [Schema](https://github.com/Prismatic/schema) for input & output data coercion -- [Swagger](https://swagger.io/) for api documentation, via [ring-swagger](https://github.com/metosin/ring-swagger) -- Extendable route DSL via [metadata handlers](https://github.com/metosin/compojure-api/wiki/Creating-your-own-metadata-handlers) -- Client negotiable formats: [JSON](https://www.json.org/), [EDN](https://github.com/edn-format/edn), [YAML](https://yaml.org/) & [Transit](https://github.com/cognitect/transit-format) (JSON & MessagePack) +- [Schema](https://github.com/Prismatic/schema) & [clojure.spec](https://clojure.org/about/spec) (2.0.0) for input & output data coercion +- [Swagger](http://swagger.io/) for api documentation, via [ring-swagger](https://github.com/metosin/ring-swagger) & [spec-tools](https://github.com/metosin/spec-tools) +- [Async](https://github.com/metosin/compojure-api/wiki/Async) with async-ring, [manifold](https://github.com/ztellman/manifold) and [core.async](https://github.com/clojure/core.async) (2.0.0) +- Client negotiable formats: [JSON](http://www.json.org/), [EDN](https://github.com/edn-format/edn) & [Transit](https://github.com/cognitect/transit-format), optionally [YAML](http://yaml.org/) and [MessagePack](http://msgpack.org/) - Data-driven [resources](https://github.com/metosin/compojure-api/wiki/Resources-and-Liberator) - [Bi-directional](https://github.com/metosin/compojure-api/wiki/Routing#bi-directional-routing) routing - Bundled middleware for common api behavior ([exception handling](https://github.com/metosin/compojure-api/wiki/Exception-handling), parameters & formats) +- Extendable route DSL via [metadata handlers](https://github.com/metosin/compojure-api/wiki/Creating-your-own-metadata-handlers) - Route functions & macros for putting things together, including the [Swagger-UI](https://github.com/wordnik/swagger-ui) via [ring-swagger-ui](https://github.com/metosin/ring-swagger-ui) +- Requires Clojure 1.9.0 & Java 1.8 + +[API Docs](http://metosin.github.io/compojure-api/doc/) & [Wiki](https://github.com/metosin/compojure-api/wiki) ## Latest version -[1.1.14](https://clojars.org/metosin/compojure-api/versions/1.1.14) +[![Clojars Project](http://clojars.org/metosin/compojure-api/latest-version.svg)](http://clojars.org/metosin/compojure-api) + +Latest non-alpha: `[metosin/compojure-api "1.1.14"]`. + +See [CHANGELOG](https://github.com/metosin/compojure-api/blob/master/CHANGELOG.md) for details. ## For information and help +### [Read the Version 1.0 Blog Post](http://www.metosin.fi/blog/compojure-api-100/) + +### [Schema & Spec Coercion with 2.0.0](https://github.com/metosin/compojure-api/wiki/Coercion) + ### [Check wiki for documentation](https://github.com/metosin/compojure-api/wiki) -[Clojurians slack](https://clojurians.slack.com/) ([join](https://clojurians.net/)) has a channel [#ring-swagger](https://clojurians.slack.com/messages/ring-swagger/) for talk about any libraries using Ring-swagger. You can also ask questions about Compojure-api and Ring-swagger on other channels at Clojurians Slack or at #clojure on Freenode IRC (mention `compojure-api` or `ring-swagger` to highlight us). +[Clojurians slack](https://clojurians.slack.com/) ([join](http://clojurians.net/)) has a channel [#ring-swagger](https://clojurians.slack.com/messages/ring-swagger/) for talk about any libraries using Ring-swagger. You can also ask questions about Compojure-api and Ring-swagger on other channels at Clojurians Slack or at #clojure on Freenode IRC (mention `compojure-api` or `ring-swagger` to highlight us). ## Examples -### Hello World +### Hello World Api ```clj (require '[compojure.api.sweet :refer :all]) (require '[ring.util.http-response :refer :all]) -(defapi app - (GET "/hello" [] - :query-params [name :- String] +(def app + (api + (GET "/hello" [] + :query-params [name :- String] + (ok {:message (str "Hello, " name)})))) +``` + +### Hello World, async + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[clojure.core.async :as a]) + +(GET "/hello-async" [] + :query-params [name :- String] + (a/go + (a/* requires server to be run in [async mode](https://github.com/metosin/compojure-api/wiki/Async) + +### Hello World, async & data-driven + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[clojure.core.async :as a]) +(require '[schema.core :as s]) + +(context "/hello-async" [] + (resource + {:get + {:parameters {:query-params {:name String}} + :responses {200 {:schema {:message String}} + 404 {} + 500 {:schema s/Any}} + :handler (fn [{{:keys [name]} :query-params}] + (a/go + (a/* Note that empty body responses can be specified with `{}` or `{:schema s/Any}` + +### Hello World, async, data-driven & clojure.spec + +```clj +(require '[compojure.api.sweet :refer :all]) +(require '[clojure.core.async :as a]) +(require '[clojure.spec.alpha :as s]) + +(s/def ::name string?) +(s/def ::message string?) + +(context "/hello-async" [] + (resource + {:coercion :spec + :get {:parameters {:query-params (s/keys :req-un [::name])} + :responses {200 {:schema (s/keys :req-un [::message])}} + :handler (fn [{{:keys [name]} :query-params}] + (a/go + (a/ (io/resource "screenshot.png") + (io/input-stream) + (ok) + (header "Content-Type" "image/png")))) + (context "/component" [] :tags ["component"] (GET "/example" [] diff --git a/project.clj b/project.clj index ac12927b..e08a7716 100644 --- a/project.clj +++ b/project.clj @@ -1,22 +1,27 @@ -(defproject metosin/compojure-api "1.1.15-SNAPSHOT" +(defproject metosin/compojure-api "2.0.0-alpha34-SNAPSHOT" :description "Compojure Api" :url "https://github.com/metosin/compojure-api" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html" :distribution :repo :comments "same as Clojure"} - :scm {:name "git" - :url "https://github.com/metosin/compojure-api"} - :dependencies [[prismatic/plumbing "0.6.0"] - [cheshire "5.13.0"] - [compojure "1.6.1"] - [prismatic/schema "1.1.12"] - [org.tobereplaced/lettercase "1.0.0"] - [frankiesardo/linked "1.3.0"] - [ring-middleware-format "0.7.4"] + :dependencies [[prismatic/schema "1.1.12"] + [prismatic/plumbing "0.5.5"] + [ikitommi/linked "1.3.1-alpha1"] ;; waiting for the original + [metosin/muuntaja "0.6.6"] + [com.fasterxml.jackson.datatype/jackson-datatype-joda "2.10.1"] + [ring/ring-core "1.8.0"] + [compojure "1.6.1" ] + [metosin/spec-tools "0.10.6"] [metosin/ring-http-response "0.9.1"] + [metosin/ring-swagger-ui "3.24.3"] [metosin/ring-swagger "1.0.0"] - [metosin/ring-swagger-ui "2.2.10"]] + + ;; Fix dependency conflicts + [clj-time "0.15.2"] + [joda-time "2.10.5"] + [riddley "0.2.0"]] + :pedantic? :abort :profiles {:uberjar {:aot :all :ring {:handler examples.thingie/app} :source-paths ["examples/thingie/src"] @@ -24,27 +29,25 @@ [http-kit "2.3.0"] [reloaded.repl "0.2.4"] [com.stuartsierra/component "0.4.0"]]} - :dev {:jvm-opts ["-Dcompojure.api.core.allow-dangerous-middleware=true"] - :repl-options {:init-ns user} - :plugins [[lein-clojars "0.9.1"] - [lein-midje "3.2.1"] - [lein-ring "0.12.0"] + :dev {:plugins [[lein-clojars "0.9.1"] + [lein-ring "0.12.5"] [funcool/codeina "0.5.0"]] :dependencies [[org.clojure/clojure "1.9.0"] - ;; bump - [fipp "0.6.26"] - [metosin/spec-tools "0.10.6"] - [metosin/muuntaja "0.6.6"] - [metosin/jsonista "0.2.5"] - [com.fasterxml.jackson.datatype/jackson-datatype-joda "2.10.1"] - [slingshot "0.12.2"] - [peridot "0.5.1"] - [javax.servlet/servlet-api "2.5"] - [midje "1.9.9"] + [org.clojure/core.unify "0.6.0"] + [org.clojure/core.async "0.6.532"] + [javax.servlet/javax.servlet-api "4.0.1"] + [peridot "0.5.2"] [com.stuartsierra/component "0.4.0"] + [expound "0.8.2"] + [metosin/jsonista "0.2.5"] [reloaded.repl "0.2.4"] + [metosin/muuntaja-msgpack "0.6.6"] + [metosin/muuntaja-yaml "0.6.6"] + [org.immutant/immutant "2.1.10"] [http-kit "2.3.0"] [criterium "0.4.5"]] + :jvm-opts ["-Dcompojure.api.meta.static-context-coach={:default :assert :verbose true}"] + :test-paths ["test19"] :ring {:handler examples.thingie/app :reload-paths ["src" "examples/thingie/src"]} :source-paths ["examples/thingie/src" "examples/thingie/dev-src"] @@ -52,10 +55,16 @@ :perf {:jvm-opts ^:replace ["-server" "-Xmx4096m" "-Dclojure.compiler.direct-linking=true"]} - :logging {:dependencies [[org.clojure/tools.logging "0.5.0"]]} + :logging {:dependencies [[org.clojure/tools.logging "0.5.0"] + [org.slf4j/jcl-over-slf4j "1.7.30"] + [org.slf4j/jul-to-slf4j "1.7.30"] + [org.slf4j/log4j-over-slf4j "1.7.30"] + [ch.qos.logback/logback-classic "1.2.3" ]]} :1.10 {:dependencies [[org.clojure/clojure "1.10.1"]]} :1.11 {:dependencies [[org.clojure/clojure "1.11.3"]]} - :1.12 {:dependencies [[org.clojure/clojure "1.12.0-alpha11"]]}} + :1.12 {:dependencies [[org.clojure/clojure "1.12.0-alpha11"]]} + :async {:jvm-opts ["-Dcompojure-api.test.async=true"] + :dependencies [[manifold "0.1.8" :exclusions [org.clojure/tools.logging]]]}} :eastwood {:namespaces [:source-paths] :add-linters [:unused-namespaces]} :codeina {:sources ["src"] @@ -79,10 +88,10 @@ ["change" "version" "leiningen.release/bump-version"] ["vcs" "commit"] ["vcs" "push"]] - :aliases {"all" ["with-profile" "dev:dev,logging:dev,1.10:dev,1.11:dev,1.12"] + :aliases {"all" ["with-profile" "dev:dev,async:dev,1.10:dev,1.11:dev,1.12"] "start-thingie" ["run"] "aot-uberjar" ["with-profile" "uberjar" "do" "clean," "ring" "uberjar"] - "test-ancient" ["midje"] + "test-ancient" ["test"] "perf" ["with-profile" "default,dev,perf"] "deploy!" ^{:doc "Recompile sources, then deploy if tests succeed."} - ["do" ["clean"] ["midje"] ["deploy" "clojars"]]}) + ["do" ["clean"] ["test"] ["deploy" "clojars"]]}) diff --git a/scripts/update-legacy-changelog.sh b/scripts/update-legacy-changelog.sh new file mode 100755 index 00000000..f1fbed36 --- /dev/null +++ b/scripts/update-legacy-changelog.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail +git fetch origin +git show origin/1.1.x:CHANGELOG.md > CHANGELOG-1.1.x.md diff --git a/src/compojure/api/api.clj b/src/compojure/api/api.clj index 763a0dc2..89835e78 100644 --- a/src/compojure/api/api.clj +++ b/src/compojure/api/api.clj @@ -1,16 +1,17 @@ (ns compojure.api.api (:require [compojure.api.core :as c] [compojure.api.swagger :as swagger] - [compojure.api.middleware :as middleware] + [compojure.api.middleware :as mw] + [compojure.api.request :as request] [compojure.api.routes :as routes] [compojure.api.common :as common] - [compojure.api.coerce :as coerce] + [compojure.api.request :as request] [ring.swagger.common :as rsc] [ring.swagger.middleware :as rsm])) (def api-defaults (merge - middleware/api-middleware-defaults + mw/api-middleware-defaults {:api {:invalid-routes-fn routes/log-invalid-child-routes :disable-api-middleware? false} :swagger {:ui nil, :spec nil}})) @@ -23,8 +24,7 @@ options map as the first parameter: (api - {:formats [:json-kw :edn :transit-msgpack :transit-json] - :exceptions {:handlers {:compojure.api.exception/default my-logging-handler}} + {:exceptions {:handlers {:compojure.api.exception/default my-logging-handler}} :api {:invalid-routes-fn (constantly nil)} :swagger {:spec \"/swagger.json\" :ui \"/api-docs\" @@ -47,30 +47,55 @@ ### api-middleware options + See `compojure.api.middleware/api-middleware` for more available options. + " (:doc (meta #'compojure.api.middleware/api-middleware)))} api [& body] (let [[options handlers] (common/extract-parameters body false) + _ (assert (not (contains? options :format)) + (str "ERROR: Option [:format] is not used with 2.* version.\n" + "Compojure-api uses now Muuntaja insted of ring-middleware-format,\n" + "the new formatting options for it should be under [:formats]. See\n" + "[[api-middleware]] documentation for more details.\n")) + _ (when (and (not (:formatter options)) + (not (contains? options :formats)) + (not (System/getProperty "compojure.api.middleware.global-default-formatter"))) + (throw (ex-info (str "ERROR: Please set `:formatter :muuntaja` in the options map of `api`.\n" + "e.g., (api {:formatter :muuntaja} routes...)\n" + "To prepare for backwards compatibility with compojure-api 1.x, the formatting library must be\n" + "explicitly chosen if not configured by `:format` (ring-middleware-format) or \n" + "`:formats` (muuntaja). Once 2.x is stable, the default will be `:formatter :ring-middleware-format`.\n" + "To globally override the formatter, use -Dcompojure.api.middleware.global-default-formatter=:muuntaja") + {}))) options (rsc/deep-merge api-defaults options) handler (apply c/routes (concat [(swagger/swagger-routes (:swagger options))] handlers)) - routes (routes/get-routes handler (:api options)) + partial-api-route (routes/map->Route + {:childs [handler] + :info {:coercion (:coercion options)}}) + routes (routes/get-routes partial-api-route (:api options)) paths (-> routes routes/ring-swagger-paths swagger/transform-operations) lookup (routes/route-lookup-table routes) swagger-data (get-in options [:swagger :data]) enable-api-middleware? (not (get-in options [:api :disable-api-middleware?])) - api-handler (cond-> handler - swagger-data (rsm/wrap-swagger-data swagger-data) - enable-api-middleware? (middleware/api-middleware - (dissoc options :api :swagger)) - true (middleware/wrap-options - {:paths paths - :coercer (coerce/memoized-coercer) - :lookup lookup}))] - (routes/create nil nil {} [handler] api-handler))) + api-middleware-options (dissoc (mw/api-middleware-options (assoc (dissoc options :api :swagger) ::via-api true)) + ::mw/api-middleware-defaults) + api-handler (-> handler + (cond-> swagger-data (rsm/wrap-swagger-data swagger-data)) + (cond-> enable-api-middleware? (mw/api-middleware + api-middleware-options)) + (mw/wrap-inject-data + {::request/paths paths + ::request/lookup lookup}))] + (assoc partial-api-route :handler api-handler))) (defmacro - ^{:doc (str - "Defines an api. + ^{:superseded-by "api" + :deprecated "2.0.0" + :doc (str + "Deprecated: please use (def name (api ...body..)) + + Defines an api. API middleware options: diff --git a/src/compojure/api/coerce.clj b/src/compojure/api/coerce.clj index c63a8b6d..5a147a14 100644 --- a/src/compojure/api/coerce.clj +++ b/src/compojure/api/coerce.clj @@ -1,3 +1,4 @@ +;; 1.1.x (ns compojure.api.coerce (:require [schema.coerce :as sc] [compojure.api.middleware :as mw] diff --git a/src/compojure/api/coercion/schema.clj b/src/compojure/api/coercion/schema.clj index 9a7e01b0..5891ab45 100644 --- a/src/compojure/api/coercion/schema.clj +++ b/src/compojure/api/coercion/schema.clj @@ -34,7 +34,11 @@ (common/fifo-memoize sc/coercer 1000)) ;; don't use coercion for certain types -(defmulti coerce-response? identity :default ::default) +(defmulti coerce-response? #(if (or (class? %) + (keyword? %)) + % + ::default) + :default ::default) (defmethod coerce-response? ::default [_] true) (defmethod coerce-response? File [_] false) diff --git a/src/compojure/api/core.clj b/src/compojure/api/core.clj index 07b22a14..0c9cb97e 100644 --- a/src/compojure/api/core.clj +++ b/src/compojure/api/core.clj @@ -54,16 +54,16 @@ :deprecated "1.1.14" :superseded-by "route-middleware"} [middleware & body] - (when (not= "true" (System/getProperty "compojure.api.core.suppress-middleware-warning")) - (println (str "compojure.api.core.middleware is deprecated because of security issues. " - "Please use route-middleware instead. middleware will be disabled in a future release." - "Set -dcompojure.api.core.suppress-middleware-warning=true to suppress this warning."))) + (assert (= "true" (System/getProperty "compojure.api.core.allow-dangerous-middleware")) + (str "compojure.api.core.middleware is deprecated because of security issues. " + "Please use route-middleware instead. " + "Set compojure.api.core.allow-dangerous-middleware=true to keep using middleware.")) `(let [body# (routes ~@body) wrap-mw# (mw/compose-middleware ~middleware)] (routes/create nil nil {} [body#] (wrap-mw# body#)))) (defn route-middleware - "Wraps routes with given middleware using thread-first macro." + "Wraps routes with given middlewares using thread-first macro." {:style/indent 1 :supercedes "middleware"} [middleware & body] @@ -76,11 +76,11 @@ (defmacro context {:style/indent 2} [& args] (meta/restructure nil args {:context? true :&form &form :&env &env})) -(defmacro GET {:style/indent 2} [& args] (meta/restructure :get args nil)) -(defmacro ANY {:style/indent 2} [& args] (meta/restructure nil args nil)) -(defmacro HEAD {:style/indent 2} [& args] (meta/restructure :head args nil)) -(defmacro PATCH {:style/indent 2} [& args] (meta/restructure :patch args nil)) -(defmacro DELETE {:style/indent 2} [& args] (meta/restructure :delete args nil)) +(defmacro GET {:style/indent 2} [& args] (meta/restructure :get args nil)) +(defmacro ANY {:style/indent 2} [& args] (meta/restructure nil args nil)) +(defmacro HEAD {:style/indent 2} [& args] (meta/restructure :head args nil)) +(defmacro PATCH {:style/indent 2} [& args] (meta/restructure :patch args nil)) +(defmacro DELETE {:style/indent 2} [& args] (meta/restructure :delete args nil)) (defmacro OPTIONS {:style/indent 2} [& args] (meta/restructure :options args nil)) -(defmacro POST {:style/indent 2} [& args] (meta/restructure :post args nil)) -(defmacro PUT {:style/indent 2} [& args] (meta/restructure :put args nil)) +(defmacro POST {:style/indent 2} [& args] (meta/restructure :post args nil)) +(defmacro PUT {:style/indent 2} [& args] (meta/restructure :put args nil)) diff --git a/src/compojure/api/exception.clj b/src/compojure/api/exception.clj index 0844bc5d..cc281da6 100644 --- a/src/compojure/api/exception.clj +++ b/src/compojure/api/exception.clj @@ -2,10 +2,8 @@ (:require [ring.util.http-response :as response] [clojure.walk :as walk] [compojure.api.impl.logging :as logging] - [schema.utils :as su]) - (:import [schema.utils ValidationError NamedError] - [com.fasterxml.jackson.core JsonParseException] - [org.yaml.snakeyaml.parser ParserException])) + [compojure.api.coercion.core :as cc] + [compojure.api.coercion.schema])) ;; ;; Default exception handlers @@ -21,42 +19,62 @@ (response/internal-server-error {:type "unknown-exception" :class (.getName (.getClass e))})) -(defn stringify-error - "Stringifies symbols and validation errors in Schema error, keeping the structure intact." - [error] - (walk/postwalk - (fn [x] - (cond - (instance? ValidationError x) (str (su/validation-error-explain x)) - (instance? NamedError x) (str (su/named-error-explain x)) - :else x)) - error)) - +;; TODO: coercion should handle how to publish data (defn response-validation-handler - "Creates error response based on Schema error." + "Creates error response based on a response error. The following keys are available: + + :type type of the exception (::response-validation) + :coercion coercion instance used + :in location of the value ([:response :body]) + :schema schema to be validated against + :error schema error + :request raw request + :response raw response" [e data req] - (response/internal-server-error {:errors (stringify-error (su/error-val data))})) + (response/internal-server-error + (-> data + (dissoc :request :response) + (update :coercion cc/get-name) + (assoc :value (-> data :response :body)) + (->> (cc/encode-error (:coercion data)))))) +;; TODO: coercion should handle how to publish data (defn request-validation-handler - "Creates error response based on Schema error." + "Creates error response based on Schema error. The following keys are available: + + :type type of the exception (::request-validation) + :coercion coercion instance used + :value value that was validated + :in location of the value (e.g. [:request :query-params]) + :schema schema to be validated against + :error schema error + :request raw request" [e data req] - (response/bad-request {:errors (stringify-error (su/error-val data))})) + (response/bad-request + (-> data + (dissoc :request) + (update :coercion cc/get-name) + (->> (cc/encode-error (:coercion data)))))) + +(defn http-response-handler + "reads response from ex-data :response" + [_ {:keys [response]} _] + response) (defn schema-error-handler "Creates error response based on Schema error." [e data req] - ; FIXME: Why error is not wrapped to ErrorContainer here? - (response/bad-request {:errors (stringify-error (:error data))})) + (response/bad-request + {:errors (compojure.api.coercion.schema/stringify (:error data))})) (defn request-parsing-handler [^Exception ex data req] - (let [cause (.getCause ex)] - (response/bad-request {:type (cond - (instance? JsonParseException cause) "json-parse-exception" - (instance? ParserException cause) "yaml-parse-exception" - :else "parse-exception") - :message (.getMessage cause)}))) - + (let [cause (.getCause ex) + original (.getCause cause)] + (response/bad-request + (merge (select-keys data [:type :format :charset]) + (if original {:original (.getMessage original)}) + {:message (.getMessage cause)})))) ;; ;; Logging ;; @@ -75,5 +93,6 @@ ;; Mappings from other Exception types to our base types ;; -(def legacy-exception-types - {:ring.swagger.schema/validation ::request-validation}) +(def mapped-exception-types + {:ring.swagger.schema/validation ::request-validation + :muuntaja/decode ::request-parsing}) diff --git a/src/compojure/api/impl/logging.clj b/src/compojure/api/impl/logging.clj index 1244fb30..f1ad70cb 100644 --- a/src/compojure/api/impl/logging.clj +++ b/src/compojure/api/impl/logging.clj @@ -6,17 +6,18 @@ (declare log!) ;; use c.t.l logging if available, default to console logging -(if (find-ns 'clojure.tools.logging) +(try (eval `(do (require 'clojure.tools.logging) (defmacro ~'log! [& ~'args] - `(do - (clojure.tools.logging/log ~@~'args))))) - (let [log (fn [level more] (println (.toUpperCase (name level)) (str/join " " more)))] - (defn log! [level x & more] - (if (instance? Throwable x) - (do - (log level more) - (.printStackTrace ^Throwable x)) - (log level (into [x] more)))))) + `(clojure.tools.logging/log ~@~'args)))) + (catch Exception _ + (let [log (fn [level more] (println (.toUpperCase (name level)) (str/join " " more)))] + (defn log! [level x & more] + (if (instance? Throwable x) + (do + (log level more) + (.printStackTrace ^Throwable x)) + (log level (into [x] more)))) + (log! :warn "clojure.tools.logging not found on classpath, compojure.api logging to console.")))) diff --git a/src/compojure/api/meta.clj b/src/compojure/api/meta.clj index 1b093871..4a2a4e38 100644 --- a/src/compojure/api/meta.clj +++ b/src/compojure/api/meta.clj @@ -1,5 +1,7 @@ (ns compojure.api.meta - (:require [compojure.api.common :as common :refer [extract-parameters]] + (:require [clojure.edn :as edn] ;; TODO load lazily + [clojure.pprint :as pp] ;;TODO load lazily + [compojure.api.common :refer [extract-parameters]] [compojure.api.middleware :as mw] [compojure.api.routes :as routes] [plumbing.core :as p] @@ -8,13 +10,19 @@ [ring.swagger.json-schema :as js] [schema.core :as s] [schema-tools.core :as st] - [compojure.api.coerce :as coerce] - compojure.core)) + [compojure.api.coercion :as coercion] + [compojure.api.help :as help] + compojure.core + compojure.api.compojure-compat + [compojure.api.common :as common])) (def +compojure-api-request+ "lexically bound ring-request for handlers." '+compojure-api-request+) +(defn- var->sym [^clojure.lang.Var v] + (symbol (-> v .ns ns-name name) (-> v .sym name))) + ;; ;; Schema ;; @@ -23,19 +31,34 @@ (dissoc schema 'schema.core/Keyword)) (defn fnk-schema [bind] - (->> - (:input-schema - (fnk-impl/letk-input-schema-and-body-form - nil (with-meta bind {:schema s/Any}) [] nil)) - reverse - (into {}))) + (try + (->> + (:input-schema + (fnk-impl/letk-input-schema-and-body-form + nil (with-meta bind {:schema s/Any}) [] nil)) + reverse + (into {})) + (catch Exception _ + (let [hint (cond + ;; [a ::a] + (and (= 2 (count bind)) (keyword? (second bind))) + (str "[" (first bind) " :- " (second bind) "]") + + :else nil)] + (throw (IllegalArgumentException. + (str "Binding is not valid, please refer to " + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + " for more information.\n\n" + " binding: " bind "\n\n" + (if hint (str " did you mean: " hint "\n\n"))))))))) (s/defn src-coerce! "Return source code for coerce! for a schema with coercion type, extracted from a key in a ring request." - [schema, key, type :- mw/CoercionType] - (assert (not (#{:query :json} type)) (str type " is DEPRECATED since 0.22.0. Use :body or :string instead.")) - `(coerce/coerce! ~schema ~key ~type ~+compojure-api-request+)) + ([schema, key, type] + (src-coerce! schema, key, type, true)) + ([schema, key, type, keywordize?] + `(coercion/coerce-request! ~schema ~key ~type ~keywordize? false ~+compojure-api-request+))) (defn- convert-return [schema] {200 {:schema schema @@ -51,179 +74,565 @@ (fn [k v acc] k)) ;; -;; Pass-through swagger metadata +;; dynamic +;; + +(defmethod help/help-for [:meta :dynamic] [_ _] + (help/text + "If set to to `true`, makes a `context` dynamic," + "which wraps the body in a closure that is evaluated on each request." + "This is the default behavior in vanilla compojure. In compojure-api," + "this is also the usual behavior, except:" + "If the context does not bind any variables and its body contains" + "just top-level calls to compojure.api endpoint macros like GET," + "then the body will be cached for each request." + + (help/code + "(context \"/static\" []" + " :static true" + " (when (= 0 (random-int 2))" + " (println 'ping!) ;; never printed during request" + " ;; mounting decided once" + " (GET \"/ping\" [] (ok \"pong\"))))" + "" + "(context \"/dynamic\" []" + " :dynamic true" + " (when (= 0 (random-int 2))" + " (println 'ping!) ;; printed 50% of requests" + " ;; mounted for 50% of requests" + " (GET \"/ping\" [] (ok \"pong\"))))"))) + +(defmethod restructure-param :dynamic [k v acc] + (update-in acc [:info :public] assoc k v)) + +(defmethod help/help-for [:meta :static] [_ _] + (help/text + "If set to to `true`, makes a `context` static," + "which resolves the body before processing requests." + "This is much faster than :dynamic contexts at the" + "cost of expressivity: routes under a static context" + "cannot change based on the request." + + (help/code + "(context \"/static\" []" + " :static true" + " (when (= 0 (random-int 2))" + " (println 'ping!) ;; never printed during request" + " ;; mounting decided once" + " (GET \"/ping\" [] (ok \"pong\"))))" + "" + "(context \"/dynamic\" []" + " :dynamic true" + " (when (= 0 (random-int 2))" + " (println 'ping!) ;; printed 50% of requests" + " ;; mounted for 50% of requests" + " (GET \"/ping\" [] (ok \"pong\")))"))) + +(defmethod restructure-param :static [k v acc] + (update-in acc [:info :public] assoc k v)) + +;; +;; summary ;; +(defmethod help/help-for [:meta :summary] [_ _] + (help/text + "A short summary of what the operation does. For maximum" + "readability in the swagger-ui, this field SHOULD be less" + "than 120 characters.\n" + (help/code + "(GET \"/ok\" []" + " :summary \"this endpoint alreays returns 200\"" + " (ok))"))) + (defmethod restructure-param :summary [k v acc] - (update-in acc [:swagger] assoc k v)) + (update-in acc [:info :public] assoc k v)) + +;; +;; description +;; + +(defmethod help/help-for [:meta :description] [_ _] + (help/text + "A verbose explanation of the operation behavior." + "GFM syntax can be used for rich text representation." + (help/code + "(GET \"/ok\" []" + " :description \"this is a `GET`.\"" + " (ok))"))) (defmethod restructure-param :description [k v acc] - (update-in acc [:swagger] assoc k v)) + (update-in acc [:info :public] assoc k v)) + +;; +;; OperationId +;; + +(defmethod help/help-for [:meta :operationId] [_ _] + (help/text + "Unique string used to identify the operation. The id MUST be" + "unique among all operations described in the API. Tools and" + "libraries MAY use the operationId to uniquely identify an operation," + "therefore, it is recommended to follow common programming naming conventions.\n" + (help/code + "(GET \"/pets\" []" + " :operationId \"get-pets\"" + " (ok))"))) (defmethod restructure-param :operationId [k v acc] - (update-in acc [:swagger] assoc k v)) + (update-in acc [:info :public] assoc k v)) + +;; +;; Consumes +;; + +(defmethod help/help-for [:meta :consumes] [_ _] + (help/text + "Swagger-ui hint about mime-types the endpoints can consume." + "Takes a vector or set. Just for docs.\n" + (help/code + "(GET \"/edn-endpoint\" []" + " :consumes #{\"application/edn\"}" + " :produces #(\"application/edn\"}" + " (ok))"))) (defmethod restructure-param :consumes [k v acc] - (update-in acc [:swagger] assoc k v)) + (update-in acc [:info :public] assoc k v)) + +;; +;; Provides +;; + +(defmethod help/help-for [:meta :produces] [_ _] + (help/text + "Swagger-ui hint about mime-types the endpoints produces." + "Takes a vector or set. Just for docs.\n" + (help/code + "(GET \"/edn-endpoint\" []" + " :consumes #{\"application/edn\"}" + " :produces #{\"application/edn\"}" + " (ok))"))) (defmethod restructure-param :produces [k v acc] - (update-in acc [:swagger] assoc k v)) + (update-in acc [:info :public] assoc k v)) ;; -;; Smart restructurings +;; no-doc ;; -; Boolean to discard the route out from api documentation -; Example: -; :no-doc true +(defmethod help/help-for [:meta :no-doc] [_ _] + (help/text + "Boolean to discard the route out from api documentation\n" + (help/code + "(GET \"/secret\" []" + " :no-doc true" + " (ok))"))) + (defmethod restructure-param :no-doc [_ v acc] - (update-in acc [:swagger] assoc :x-no-doc v)) - -; publishes the data as swagger-parameters without any side-effects / coercion. -; Examples: -; :swagger {:responses {200 {:schema User} -; 404 {:schema Error -; :description "Not Found"} } -; :paramerers {:query {:q s/Str} -; :body NewUser}}} + (update-in acc [:info] assoc :no-doc v)) + +;; +;; swagger +;; + +(defmethod help/help-for [:meta :swagger] [_ _] + (help/text + "Raw swagger-data, just for docs.\n" + (help/code + "(GET \"/ok\" []" + " :swagger {:responses {200 {:schema User}" + " 404 {:schema Error" + " :description \"Not Found\"}}" + " :parameters {:query {:q s/Str}" + " :body NewUser}}" + " (ok))"))) + (defmethod restructure-param :swagger [_ swagger acc] - (assoc-in acc [:swagger :swagger] swagger)) + (assoc-in acc [:info :public :swagger] swagger)) + +;; +;; name +;; + +(defmethod help/help-for [:meta :name] [_ _] + (help/text + "Name of a route. Used in bi-directional routing.\n" + (help/code + "(context \"/user\" []" + " (GET \"/:id\" []" + " :path-params [id :- s/Int]" + " :name ::user" + " (ok))" + " (POST \"/\" []" + " (created (path-for ::user {:id (random-int)}))))"))) -; Route name, used with path-for -; Example: -; :name :user-route (defmethod restructure-param :name [_ v acc] - (update-in acc [:swagger] assoc :x-name v)) + (-> acc + (assoc-in [:info :name] v) + (assoc-in [:info :public :x-name] v))) + +;; +;; tags +;; + +(defmethod help/help-for [:meta :tags] [_ _] + (help/text + "Takes a sequence or a set of tags for the swagger-doc.\n" + (help/code + "(GET \"/ok\" []" + " :tags #{\"admin\", \"common\"}" + " (ok))"))) -; Tags for api categorization. Ignores duplicates. -; Examples: -; :tags [:admin] (defmethod restructure-param :tags [_ tags acc] - (update-in acc [:swagger :tags] (comp set into) tags)) + (update-in acc [:info :public :tags] (comp set into) tags)) + +;; +;; return +;; + +(defmethod help/help-for [:meta :return] [_ _] + (help/text + "Request (status 200) body schema for coercion and api-docs.\n" + (help/code + "(GET \"/user\" []" + " :return {:name (s/maybe s/Str)" + " (ok {:name \"Kirsi\"))"))) -; Defines a return type and coerces the return value of a body against it. -; Examples: -; :return MySchema -; :return {:value String} -; :return #{{:key (s/maybe Long)}} (defmethod restructure-param :return [_ schema acc] - (let [response (convert-return schema)] + (let [response (convert-return schema) + g (gensym 'response)] (-> acc - (update-in [:swagger :responses] (fnil conj []) response) - (update-in [:responses] (fnil conj []) response)))) - -; value is a map of http-response-code -> Schema. Translates to both swagger -; parameters and return schema coercion. Schemas can be decorated with meta-data. -; Examples: -; :responses {403 nil} -; :responses {403 {:schema ErrorEnvelope}} -; :responses {403 {:schema ErrorEnvelope, :description \"Underflow\"}} + (update-in [:outer-lets] into [g response]) + (update-in [:info :public :responses] (fnil conj []) g) + (update-in [:responses] (fnil conj []) g)))) + +;; +;; responses +;; + +(defmethod help/help-for [:meta :responses] [_ _] + (help/text + "Map of response status code (or :default) -> response schema." + "Response can have keys :schema, :description and :headers.\n" + (help/code + "(GET \"/user\" []" + " :responses {200 {:schema {:name (s/maybe s/Str)}}" + " 404 {:schema {:error s/Int}" + " :description \"not found\"}" + " (ok {:name nil}))" + "" + "(GET \"/user\" []" + " :responses {200 {:schema s/Any, :description \"ok\"}" + " :default {:schema s/Any, :description \"default\"" + " (bad-request \"kosh\"))"))) + (defmethod restructure-param :responses [_ responses acc] - (-> acc - (update-in [:swagger :responses] (fnil conj []) responses) - (update-in [:responses] (fnil conj []) responses))) - -; reads body-params into a enhanced let. First parameter is the let symbol, -; second is the Schema to be coerced! against. -; Examples: -; :body [user User] -(defmethod restructure-param :body [_ [value schema] acc] - (-> acc - (update-in [:lets] into [value (src-coerce! schema :body-params :body)]) - (assoc-in [:swagger :parameters :body] schema))) - -; reads query-params into a enhanced let. First parameter is the let symbol, -; second is the Schema to be coerced! against. -; Examples: -; :query [user User] -(defmethod restructure-param :query [_ [value schema] acc] - (-> acc - (update-in [:lets] into [value (src-coerce! schema :query-params :string)]) - (assoc-in [:swagger :parameters :query] schema))) - -; reads header-params into a enhanced let. First parameter is the let symbol, -; second is the Schema to be coerced! against. -; Examples: -; :headers [headers Headers] -(defmethod restructure-param :headers [_ [value schema] acc] - (-> acc - (update-in [:lets] into [value (src-coerce! schema :headers :string)]) - (assoc-in [:swagger :parameters :header] schema))) + (let [g (gensym 'responses)] + (-> acc + (update :outer-lets into [g responses]) + (update-in [:info :public :responses] (fnil conj []) g) + (update-in [:responses] (fnil conj []) g)))) + +;; +;; body +;; + +(defmethod help/help-for [:meta :body] [_ _] + (help/text + "body-params with let-syntax. First parameter is the symbol," + "second is the Schema used in coercion (and api-docs).\n" + (help/code + "(POST \"/echo\" []" + " :body [body User]" + " (ok body))"))) + +(defmethod restructure-param :body [_ [value schema :as bv] acc] + (when-not (= "true" (System/getProperty "compojure.api.meta.allow-bad-body")) + (assert (= 2 (count bv)) + (str ":body should be [sym schema], provided: " bv + "\nDisable this check with -Dcompojure.api.meta.allow-bad-body=true"))) + (let [g (gensym 'body-schema)] + (-> acc + (update :outer-lets into [g schema]) + (update-in [:lets] into [value (src-coerce! g :body-params :body false)]) + (assoc-in [:info :public :parameters :body] g)))) + +;; +;; query +;; + +(defmethod help/help-for [:meta :query] [_ _] + (help/text + "query-params with let-syntax. First parameter is the symbol," + "second is the Schema used in coercion (and api-docs).\n" + (help/code + "(GET \"/search\" []" + " :query [params {:q s/Str, :max s/Int}]" + " (ok params))"))) + +(defmethod restructure-param :query [_ [value schema :as bv] acc] + (when-not (= "true" (System/getProperty "compojure.api.meta.allow-bad-query")) + (assert (= 2 (count bv)) + (str ":query should be [sym schema], provided: " bv + "\nDisable this check with -Dcompojure.api.meta.allow-bad-query=true"))) + (let [g (gensym 'query-schema)] + (-> acc + (update :outer-lets into [g schema]) + (update-in [:lets] into [value (src-coerce! g :query-params :string)]) + (assoc-in [:info :public :parameters :query] g)))) + +;; +;; headers +;; + +(defmethod help/help-for [:meta :headers] [_ _] + (help/text + "header-params with let-syntax. First parameter is the symbol," + "second is the Schema used in coercion (and api-docs).\n" + (help/code + "(GET \"/headers\" []" + " :headers [headers HeaderSchema]" + " (ok headers))"))) + +(defmethod restructure-param :headers [_ [value schema :as bv] acc] + (when-not (= "true" (System/getProperty "compojure.api.meta.allow-bad-headers")) + (assert (= 2 (count bv)) + (str ":headers should be [sym schema], provided: " bv + "\nDisable this check with -Dcompojure.api.meta.allow-bad-headers=true"))) + (let [g (gensym 'headers-schema)] + (-> acc + (update :outer-lets into [g schema]) + (update-in [:lets] into [value (src-coerce! g :headers :string)]) + (assoc-in [:info :public :parameters :header] g)))) + +;; +;; body-params +;; + +(defmethod help/help-for [:meta :body-params] [_ _] + (help/text + "body-params with letk. Schema is used for both coercion and api-docs." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(POST \"/math\" []" + " :body-params [x :- s/Int, {y :- s/Int 1}]" + " (ok {:total (+ x y)}))"))) -; restructures body-params with plumbing letk notation. Example: -; :body-params [id :- Long name :- String] (defmethod restructure-param :body-params [_ body-params acc] - (let [schema (strict (fnk-schema body-params))] + (let [schema (strict (fnk-schema body-params)) + g (gensym 'body-params-schema)] (-> acc - (update-in [:letks] into [body-params (src-coerce! schema :body-params :body)]) - (assoc-in [:swagger :parameters :body] schema)))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [body-params (src-coerce! g :body-params :body)]) + (assoc-in [:info :public :parameters :body] g)))) + +;; +;; form-params +;; + +(defmethod help/help-for [:meta :form-params] [_ _] + (help/text + "form-params with letk. Schema is used for both coercion and api-docs." + "Also sets the :swagger :consumes to #{\"application/x-www-form-urlencoded\"}." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(POST \"/math\" []" + " :form-params [x :- s/Int, {y :- s/Int 1}]" + " (ok {:total (+ x y)}))"))) -; restructures form-params with plumbing letk notation. Example: -; :form-params [id :- Long name :- String] (defmethod restructure-param :form-params [_ form-params acc] - (let [schema (strict (fnk-schema form-params))] + (let [schema (strict (fnk-schema form-params)) + g (gensym 'form-params-schema)] (-> acc - (update-in [:letks] into [form-params (src-coerce! schema :form-params :string)]) - (update-in [:swagger :parameters :formData] st/merge schema) - (assoc-in [:swagger :consumes] ["application/x-www-form-urlencoded"])))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [form-params (src-coerce! g :form-params :string)]) + (update-in [:info :public :parameters :formData] #(if % (list `st/merge % g) g)) + (assoc-in [:info :public :consumes] ["application/x-www-form-urlencoded"])))) + +;; +;; multipart-params +;; + +(defmethod help/help-for [:meta :multipart-params] [_ _] + (help/text + "multipart-params with letk. Schema is used for both coercion and api-docs." + "Should be used with a middleware to do the actual file-upload." + "Sets also the :swagger :consumes to #{\"multipart/form-data\"}." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(require '[compojure.api.upload :as upload]" + "" + "(POST \"/file\" []" + " :multipart-params [foo :- upload/TempFileUpload]" + " :middleware [upload/wrap-multipart-params]" + " (ok (dissoc foo :tempfile)))"))) -; restructures multipart-params with plumbing letk notation and consumes "multipart/form-data" -; :multipart-params [file :- compojure.api.upload/TempFileUpload] (defmethod restructure-param :multipart-params [_ params acc] - (let [schema (strict (fnk-schema params))] + (let [schema (strict (fnk-schema params)) + g (gensym 'multipart-params-schema)] (-> acc - (update-in [:letks] into [params (src-coerce! schema :multipart-params :string)]) - (update-in [:swagger :parameters :formData] st/merge schema) - (assoc-in [:swagger :consumes] ["multipart/form-data"])))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [params (src-coerce! g :multipart-params :string)]) + (update-in [:info :public :parameters :formData] #(if % (list `st/merge % g) g)) + (assoc-in [:info :public :consumes] ["multipart/form-data"])))) + +;; +;; header-params +;; + +(defmethod help/help-for [:meta :header-params] [_ _] + (help/text + "header-params with letk. Schema is used for both coercion and api-docs." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(POST \"/math\" []" + " :header-params [x :- s/Int, {y :- s/Int 1}]" + " (ok {:total (+ x y)}))"))) -; restructures header-params with plumbing letk notation. Example: -; :header-params [id :- Long name :- String] (defmethod restructure-param :header-params [_ header-params acc] - (let [schema (fnk-schema header-params)] + (let [schema (fnk-schema header-params) + g (gensym 'multipart-params-schema)] (-> acc - (update-in [:letks] into [header-params (src-coerce! schema :headers :string)]) - (assoc-in [:swagger :parameters :header] schema)))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [header-params (src-coerce! g :headers :string)]) + (assoc-in [:info :public :parameters :header] g)))) + +;; +;; :query-params +;; + +(defmethod help/help-for [:meta :query-params] [_ _] + (help/text + "query-params with letk. Schema is used for both coercion and api-docs." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(POST \"/math\" []" + " :query-params [x :- s/Int, {y :- s/Int 1}]" + " (ok {:total (+ x y)}))"))) -; restructures query-params with plumbing letk notation. Example: -; :query-params [id :- Long name :- String] (defmethod restructure-param :query-params [_ query-params acc] - (let [schema (fnk-schema query-params)] + (let [schema (fnk-schema query-params) + g (gensym 'multipart-params-schema)] (-> acc - (update-in [:letks] into [query-params (src-coerce! schema :query-params :string)]) - (assoc-in [:swagger :parameters :query] schema)))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [query-params (src-coerce! g :query-params :string)]) + (assoc-in [:info :public :parameters :query] g)))) + +;; +;; path-params +;; + +(defmethod help/help-for [:meta :path-params] [_ _] + (help/text + "path-params with letk. Schema is used for both coercion and api-docs." + "https://github.com/plumatic/plumbing/tree/master/src/plumbing/fnk#fnk-syntax\n" + (help/code + "(POST \"/math/:x/:y\" []" + " :path-params [x :- s/Int, {y :- s/Int 1}]" + " (ok {:total (+ x y)}))"))) -; restructures path-params by plumbing letk notation. Example: -; :path-params [id :- Long name :- String] (defmethod restructure-param :path-params [_ path-params acc] - (let [schema (fnk-schema path-params)] + (let [schema (fnk-schema path-params) + g (gensym 'form-params-schema)] (-> acc - (update-in [:letks] into [path-params (src-coerce! schema :route-params :string)]) - (assoc-in [:swagger :parameters :path] schema)))) + (update :outer-lets into [g schema]) + (update-in [:letks] into [path-params (src-coerce! g :route-params :string)]) + (assoc-in [:info :public :parameters :path] g)))) + +;; +;; middleware +;; + +(defmethod help/help-for [:meta :middleware] [_ _] + (help/text + "Applies the given vector of middleware to the route." + "Middleware is presented as data in a Duct-style form:" + "" + "1) ring mw-function (handler->request->response)" + "" + "2) mw-function and it's arguments separately - mw is" + " created by applying function with handler and args\n" + (help/code + "(defn require-role [handler role]" + " (fn [request]" + " (if (has-role? request role)" + " (handler request)" + " (unauthorized))))" + "" + "(def require-admin #(require-role % :admin))" + "" + "(GET \"/admin\" []" + " :middleware [require-admin]" + " (ok))" + "" + "(GET \"/admin\" []" + " :middleware [[require-role :admin]]" + " (ok))" + "" + "(GET \"/admin\" []" + " :middleware [#(require-role % :admin)]" + " (ok))" + ))) -; Applies the given vector of middlewares to the route (defmethod restructure-param :middleware [_ middleware acc] (update-in acc [:middleware] into middleware)) -; Bind to stuff in request components using letk syntax +;; +;; components +;; + +(defmethod help/help-for [:meta :components] [_ _] + (help/text + "binds components into request via letk. Schema is not used here." + "to enable component injection into request, one should use either:" + "" + "1) `api`-options :components" + "2) `compojure.api.middleware/wrap-components" + "" + (help/code + "(defn app [{:keys [db] :as system}]" + " (api" + " {:components system}" + " (GET \"/ok\" []" + " :components [db]" + " (ok (do-something-with db)))))"))) + (defmethod restructure-param :components [_ components acc] (update-in acc [:letks] into [components `(mw/get-components ~+compojure-api-request+)])) -; route-specific override for coercers +;; +;; coercion +;; + +(defmethod help/help-for [:meta :coercion] [_ _] + (help/text + "Route-specific overrides for coercion. See more on wiki:" + "https://github.com/metosin/compojure-api/wiki/Validation-and-coercion\n" + (help/code + "(POST \"/user\" []" + " :coercion my-custom-coercion" + " :body [user User]" + " (ok user))"))) + (defmethod restructure-param :coercion [_ coercion acc] - (update-in acc [:middleware] conj [mw/wrap-coercion coercion])) + (let [g (gensym 'coercion)] + (-> acc + (update :outer-lets into [g coercion]) + (assoc-in [:info :coercion] g) + (update-in [:middleware] conj [`mw/wrap-coercion g])))) ;; ;; Impl ;; (defmacro dummy-let - "Dummy let-macro used in resolving route-docs. not part of normal invokation chain." + "Dummy let-macro used in resolving route-docs. not part of normal invocation chain." [bindings & body] (let [bind-form (vec (apply concat (for [n (take-nth 2 bindings)] [n nil])))] `(let ~bind-form ~@body))) (defmacro dummy-letk - "Dummy letk-macro used in resolving route-docs. not part of normal invokation chain." + "Dummy letk-macro used in resolving route-docs. not part of normal invocation chain." [bindings & body] (reduce (fn [cur-body-form [bind-form]] @@ -269,50 +678,401 @@ (RuntimeException. (str "unknown compojure destruction syntax: " arg)))))) -(defn merge-parameters - "Merge parameters at runtime to allow usage of runtime-paramers with route-macros." +(defn- merge-public-parameters [{:keys [responses swagger] :as parameters}] (cond-> parameters (seq responses) (assoc :responses (common/merge-vector responses)) swagger (-> (dissoc :swagger) (rsc/deep-merge swagger)))) -(defn restructure [method [path arg & args] {:keys [context?]}] +(defn merge-parameters + "Merge parameters at runtime to allow usage of runtime-paramers with route-macros." + [info] + (cond-> info + (contains? info :public) (update :public merge-public-parameters))) + +(defn- route-args? [arg] + (not= arg [])) + +(defn- resolve-var [&env sym] + (when (symbol? sym) + (let [v (resolve &env sym)] + (when (var? v) + v)))) + +(def endpoint-vars (into #{} + (mapcat (fn [n] + (map #(symbol (name %) (name n)) + '[compojure.api.core + compojure.api.sweet]))) + '[GET ANY HEAD PATCH DELETE OPTIONS POST PUT])) + +(def routes-vars #{'compojure.api.sweet/routes + 'compojure.api.core/routes}) + +(declare static-body? static-form?) + +(defn- static-endpoint? [&env form] + (and (seq? form) + (boolean + (let [sym (first form)] + (when (symbol? sym) + (when-some [v (resolve &env sym)] + (when (var? v) + (let [sym (var->sym v)] + (or (endpoint-vars sym) + (and (routes-vars sym) + (static-body? &env (next form)))))))))))) + +(def resource-vars '#{compojure.api.sweet/resource + compojure.api.resource/resource}) + +(defn- static-resource? [&env form] + (and (seq? form) + (boolean + (let [sym (first form)] + (when (symbol? sym) + (when-some [v (resolve &env sym)] + (when (var? v) + (let [sym (var->sym v)] + (when (and (resource-vars sym) + (= 2 (count form))) + (let [[_ data] form] + (static-form? &env data))))))))))) + +(def context-vars (into #{} + (mapcat (fn [n] + (map #(symbol (name %) (name n)) + '[compojure.api.core + compojure.api.sweet]))) + '[context])) + +(defn- static-context? [&env body] + (and (seq? body) + (boolean + (let [sym (first body)] + (when (symbol? sym) + (when-some [v (resolve &env sym)] + (when (var? v) + (context-vars (var->sym v))))))))) + +(def middleware-vars (into #{} + (mapcat (fn [n] + (map #(symbol (name %) (name n)) + '[compojure.api.core + compojure.api.sweet]))) + '[middleware])) + +(defn- static-middleware? [&env body] + (and (seq? body) + (boolean + (when-some [v (resolve-var &env (first body))] + (when (middleware-vars (var->sym v)) + (let [[_ mid & body] body] + (and (static-form? &env mid) + (static-body? &env body)))))))) + +(def route-middleware-vars (into #{} + (mapcat (fn [n] + (map #(symbol (name %) (name n)) + '[compojure.api.core + compojure.api.sweet]))) + '[route-middleware])) + +(def ^:private ^:dynamic *not-safely-static* nil) + +(defn- static-route-middleware? [&env body] + (and (seq? body) + (boolean + (let [sym (first body)] + (when (symbol? sym) + (when-some [v (resolve &env sym)] + (when (var? v) + (when (route-middleware-vars (var->sym v)) + (let [[_ mids & body] body] + (and (some? mids) + (static-body? &env body))))))))))) + +(defn- static-cond? [&env form] + (and (seq? form) + (boolean + (let [sym (first form)] + (when (symbol? sym) + (let [v (resolve &env sym)] + (when (or (= #'when v) + (= #'cond v) + (= #'= v) + (= #'not= v) + (= #'boolean v) + (= sym 'if)) + (static-body? &env (next form))))))))) + +(defn- static-resolved-form? [&env form] + (boolean + (or (and (seq? form) + (= 2 (count form)) + (= 'var (first form)) + (when-some [v (resolve-var nil (second form))] + (not (:dynamic (meta v))))) + (when (symbol? form) + (let [r (resolve &env form)] + (or (class? r) + (and (var? r) + (not (:dynamic (meta r)))))))))) + +(defn- static-expansion? [&env form] + (boolean + (when (and (seq? form) + (symbol? (first form)) + (not (contains? &env (first form)))) + (let [form' (macroexpand-1 form)] + (when-not (identical? form' form) + (static-form? &env form')))))) + +(defn- constant-form? [&env form] + (or ((some-fn nil? keyword? number? boolean? string?) form) + (and (seq? form) + (= 2 (count form)) + (= 'quote (first form))) + (and (vector? form) + (every? #(static-form? &env %) form)) + (and (map? form) + (every? #(static-form? &env %) form)) + (and (seq? form) + (next form) + (= 'fn* (first form))) + (and (seq? form) + ('#{clojure.spec.alpha/keys} + (some-> (resolve-var &env (first form)) + var->sym))) + (and (seq? form) + (symbol? (first form)) + (when-some [v (resolve-var &env (first form))] + (when (or (#{"spec-tools.data-spec" + "spec-tools.core" + "schema.core" + "ring.util.http-response"} + (namespace (var->sym v))) + ('#{compojure.api.sweet/describe + ring.swagger.json-schema/describe + clojure.core/constantly} + (var->sym v))) + (when-not (some #{:dynamic :macro} (meta v)) + (every? #(static-form? &env %) (next form)))))))) + +(defn- static-binder-env [&env bv] + (when (and (vector? bv) + (even? (count bv))) + (let [flat (eduction + (partition-all 2) + (mapcat (fn [[l init]] + (if (and (= :let l) + (even? count init)) + (partition-all 2 init) + [[l init]]))) + bv)] + (reduce (fn [&env [l init]] + (if-not (or (simple-symbol? l) + (simple-keyword? l) ;;for + (static-form? init)) + (reduced nil) + (cond-> &env + (simple-symbol? l) + (assoc l true)))) + (or &env {}) + flat)))) + +(defn- static-let? [&env form] + (and (seq? form) + (symbol? (first form)) + (when-some [op (or (when (= 'let* (first form)) + 'let*) + (when-some [v (resolve-var &env (first form))] + (let [sym (var->sym v)] + (when (contains? + '#{clojure.core/let clojure.core/for + compojure.api.sweet/let-routes compojure.api.core/let-routes} + sym) + sym))))] + (let [;; expand destructuring + [_ bv & body] (macroexpand + (if ('#{compojure.api.sweet/let-routes compojure.api.core/let-routes} op) + form + (list* `let (next form))))] + (when-some [&env (static-binder-env &env bv)] + (static-body? &env body)))))) + +(defn- static-vector? [&env body] + (and (vector? body) + (every? #(static-body? &env %) body))) + +(defn- static-form? [&env form] + (let [res (boolean + (or (contains? &env form) ;;local + (static-resolved-form? &env form) + (constant-form? &env form) + (static-endpoint? &env form) + (static-resource? &env form) + (static-let? &env form) + (static-cond? &env form) + (static-context? &env form) + (static-middleware? &env form) + (static-route-middleware? &env form) + (static-expansion? &env form)))] + (when-not res + (some-> *not-safely-static* (swap! conj {:form form :&env (into {} (map (fn [[k v]] + [k (if (boolean? v) v (class v))])) + &env)}))) + res)) + +(defn- static-body? [&env body] + (every? #(static-form? &env %) body)) + +(def ^:private warned-non-static? (atom false)) + +(defn restructure [method [path route-arg & args] {:keys [context? &form &env]}] (let [[options body] (extract-parameters args true) - [path-string lets arg-with-request arg] (destructure-compojure-api-request path arg) + [path-string lets arg-with-request] (destructure-compojure-api-request path route-arg) {:keys [lets letks + outer-lets responses middleware - middlewares + info swagger - parameters body]} (reduce (fn [acc [k v]] (restructure-param k v (update-in acc [:parameters] dissoc k))) {:lets lets :letks [] + :outer-lets [] ;; lets around the call to map->Route :responses nil :middleware [] - :swagger {} + :info {} :body body} options) - ;; migration helpers - _ (assert (not middlewares) ":middlewares is deprecated with 1.0.0, use :middleware instead.") - _ (assert (not parameters) ":parameters is deprecated with 1.0.0, use :swagger instead.") + coercion (:coercion info) + + _ (assert (not (and (-> info :public :dynamic) + (-> info :public :static))) + "Cannot be both a :dynamic and :static context.") + + ;; I think it's ok if we have :outer-lets + bindings? (boolean (or (route-args? route-arg) (seq lets) (seq letks))) + + _ (assert (not (and (-> info :public :static) + bindings?)) + "A context cannot be :static and also provide bindings. Either push bindings into endpoints or remove :static.") + + configured-dynamic? (or (-> info :public :dynamic) + (true? (get-in (meta *ns*) [:metosin/compojure-api :dynamic-contexts])) + (contains? + (some-> (System/getProperty "compojure.api.meta.dynamic-context-namespaces") + edn/read-string + set) + (ns-name *ns*))) + + configured-static? (or (-> info :public :static) + (when-not configured-dynamic? + (or (true? (get-in (meta *ns*) [:metosin/compojure-api :static-contexts])) + (contains? + (some-> (System/getProperty "compojure.api.meta.static-context-namespaces") + edn/read-string + set) + (ns-name *ns*))))) + + static? (or configured-static? + (and (not configured-dynamic?) + (not bindings?))) + + a (atom []) + safely-static? (boolean + (when context? + (when static? + (try (binding [*not-safely-static* a] + (static-body? &env body)) + (catch Exception e + (println `restructure-param "Internal error, please report the following trace to https://github.com/metosin/compojure-api") + (prn {:form &form :env &env}) + (prn e) + false))))) + + _ (when (and context? static?) + (when-not safely-static? + (when (and static? (not configured-static?)) + (let [coach (some-> (System/getProperty "compojure.api.meta.static-context-coach") + edn/read-string)] + (if-not coach + (when (first (reset-vals! warned-non-static? true)) + (println + (str (format "WARNING: Performance issue detected with compojure-api usage in %s.\n" (ns-name *ns*)) + "To fix this warning, set: -Dcompojure.api.meta.static-context-coach={:default :print}.\n" + "To suppress this warning, set: -Dcompojure.api.meta.static-context-coach={:default :off}.\n" + "This warning will only print once, other namespaces may be affected."))) + (let [_ (assert (map? coach) + (str "-Dcompojure.api.meta.static-context-coach should be a map, given: " + (pr-str coach))) + nsym (ns-name *ns*) + mode (or (get coach nsym) + (get coach :default) + :print) + _ (when (:verbose coach) + (println "The following forms were not inferred static:") + (pp/pprint @a)) + msg (str "This looks like it could be a static context: " (pr-str {:form &form :meta (meta &form)}) + "\n\n" + "If you intend for the body of this context to be evaluated on every request, please " + "use (context ... :dynamic true ...)." + "\n\n" + "If you intend for the body of this context to be fixed for every request, please " + "use (context ... :static true ...)." + "\n\n" + "If you feel this case could be automatically inferred as :static, please suggest a " + "new inference rule at https://github.com/metosin/compojure-api. Use " + "-Dcompojure.api.meta.static-context-coach={:verbose true} to print additional information " + "and include it in the issue." + "\n\n" + "To suppress this message for this namespace use -Dcompojure.api.meta.static-context-coach=" + "{" nsym " " :off "}" + "\n\nCurrent coach config: " (pr-str coach))] + (case mode + :off nil + :print (println msg) + :assert (throw (ex-info msg + {:form &form + :meta (meta &form)})) + (throw (ex-info "compojure.api.meta.static-context-coach mode must be either :off, :print, or :assert" {:coach coach + :provided mode}))))))))) + + ;; :dynamic by default + static-context? (and static? context? safely-static?) + + info (cond-> info + static-context? (assoc :static-context? static-context?)) + + _ (assert (nil? swagger) ":swagger is deprecated with 2.0.0, use [:info :public] instead") ;; response coercion middleware, why not just code? - middleware (if (seq responses) (conj middleware `[coerce/body-coercer-middleware (common/merge-vector ~responses)]) middleware)] + middleware (if (seq responses) (conj middleware `[coercion/wrap-coerce-response (common/merge-vector ~responses)]) middleware)] (if context? ;; context - (let [form `(compojure.core/routes ~@body) + (let [form `(routing [~@body]) form (if (seq letks) `(p/letk ~letks ~form) form) form (if (seq lets) `(let ~lets ~form) form) + ;; coercion is set via middleware. for contexts, middleware is applied after let & letk -bindings + ;; to make coercion visible to the lets & letks, we apply it before any let & letk -bindings + form (if (and coercion (not static-context?)) + `(let [~+compojure-api-request+ (coercion/set-request-coercion ~+compojure-api-request+ ~coercion)] + ~form) + form) form (if (seq middleware) `((mw/compose-middleware ~middleware) ~form) form) - form `(compojure.core/context ~path ~arg-with-request ~form) + form (if static-context? + `(let [form# ~form] + (compojure.core/context ~path ~arg-with-request form#)) + `(compojure.core/context ~path ~arg-with-request ~form)) ;; create and apply a separate lookup-function to find the inner routes childs (let [form (vec body) @@ -320,16 +1080,27 @@ form (if (seq lets) `(dummy-let ~lets ~form) form) form `(compojure.core/let-request [~arg-with-request ~'+compojure-api-request+] ~form) form `(fn [~'+compojure-api-request+] ~form) - form `(~form {})] - form)] - - `(routes/create ~path-string ~method (merge-parameters ~swagger) ~childs ~form)) + form `(delay (flatten (~form {})))] + form) + form `(routes/map->Route + {:path ~path-string + :method ~method + :info (merge-parameters ~info) + :childs ~childs + :handler ~form}) + form (if (seq outer-lets) `(let ~outer-lets ~form) form)] + form) ;; endpoints (let [form `(do ~@body) form (if (seq letks) `(p/letk ~letks ~form) form) form (if (seq lets) `(let ~lets ~form) form) form (compojure.core/compile-route method path arg-with-request (list form)) - form (if (seq middleware) `(compojure.core/wrap-routes ~form (mw/compose-middleware ~middleware)) form)] - - `(routes/create ~path-string ~method (merge-parameters ~swagger) nil ~form))))) + form (if (seq middleware) `(compojure.core/wrap-routes ~form (mw/compose-middleware ~middleware)) form) + form `(routes/map->Route + {:path ~path-string + :method ~method + :info (merge-parameters ~info) + :handler ~form}) + form (if (seq outer-lets) `(let ~outer-lets ~form) form)] + form)))) diff --git a/src/compojure/api/middleware.clj b/src/compojure/api/middleware.clj index 9d6c7705..3d6ba644 100644 --- a/src/compojure/api/middleware.clj +++ b/src/compojure/api/middleware.clj @@ -1,34 +1,48 @@ (ns compojure.api.middleware (:require [compojure.core :refer :all] [compojure.api.exception :as ex] + [compojure.api.common :as common] + [compojure.api.coercion :as coercion] + [compojure.api.request :as request] [compojure.api.impl.logging :as logging] - [ring.middleware.format-params :refer [wrap-restful-params]] - [ring.middleware.format-response :refer [wrap-restful-response]] - ring.middleware.http-response [ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.nested-params :refer [wrap-nested-params]] [ring.middleware.params :refer [wrap-params]] - [ring.swagger.common :as rsc] - [ring.swagger.middleware :as rsm] [ring.swagger.coerce :as coerce] - [ring.util.http-response :refer :all] - [schema.core :as s]) - (:import [com.fasterxml.jackson.core JsonParseException] - [org.yaml.snakeyaml.parser ParserException] - [clojure.lang ArityException])) + + [muuntaja.middleware] + [muuntaja.core :as m] + + [ring.swagger.common :as rsc] + [ring.util.http-response :refer :all]) + (:import [clojure.lang ArityException] + [com.fasterxml.jackson.datatype.joda JodaModule])) ;; ;; Catch exceptions ;; -(def rethrow-exceptions? ::rethrow-exceptions?) - -(defn- call-error-handler [error-handler error data request] - (try - (error-handler error data request) - (catch ArityException _ - (logging/log! :warn "Error-handler arity has been changed.") - (error-handler error)))) +(defn- super-classes [^Class k] + (loop [sk (.getSuperclass k), ks []] + (if-not (= sk Object) + (recur (.getSuperclass sk) (conj ks sk)) + ks))) + +(defn- call-error-handler [default-handler handlers error request] + (let [{:keys [type] :as data} (ex-data error) + type (or (get ex/mapped-exception-types type) type) + ex-class (class error) + error-handler (or (get handlers type) + (get handlers ex-class) + (some + (partial get handlers) + (super-classes ex-class)) + default-handler)] + (try + (error-handler error (assoc data :type type) request) + (catch ArityException _ + (logging/log! :warn "Error-handler arity has been changed.") + (error-handler error))))) (defn wrap-exceptions "Catches all exceptions and delegates to correct error handler according to :type of Exceptions @@ -36,19 +50,24 @@ - **:compojure.api.exception/default** - Handler used when exception type doesn't match other handler, by default prints stack trace." [handler {:keys [handlers]}] - (let [default-handler (get handlers ::ex/default ex/safe-handler)] - (assert (fn? default-handler) "Default exception handler must be a function.") - (fn [request] - (try - (handler request) - (catch Throwable e - (let [{:keys [type] :as data} (ex-data e) - type (or (get ex/legacy-exception-types type) type) - handler (or (get handlers type) default-handler)] - ; FIXME: Used for validate - (if (rethrow-exceptions? request) - (throw e) - (call-error-handler handler e data request)))))))) + (let [default-handler (get handlers ::ex/default ex/safe-handler) + rethrow-or-respond (fn [e request respond raise] + ;; FIXME: Used for validate + (if (::rethrow-exceptions? request) + (raise e) + (respond (call-error-handler default-handler handlers e request))))] + (assert (ifn? default-handler) "Default exception handler must be a function.") + (fn + ([request] + (try + (handler request) + (catch Throwable e + (rethrow-or-respond e request identity #(throw %))))) + ([request respond raise] + (try + (handler request respond (fn [e] (rethrow-or-respond e request respond raise))) + (catch Throwable e + (rethrow-or-respond e request respond raise))))))) ;; ;; Component integration @@ -57,41 +76,52 @@ (defn wrap-components "Assoc given components to the request." [handler components] - (fn [req] - (handler (assoc req ::components components)))) + (fn + ([req] + (handler (assoc req ::components components))) + ([req respond raise] + (handler (assoc req ::components components) respond raise)))) (defn get-components [req] (::components req)) ;; -;; Ring-swagger options +;; Options ;; -(defn wrap-options - "Injects compojure-api options into the request." - [handler options] - (fn [request] - (handler (update-in request [::options] merge options)))) - +;; 1.1.x (defn get-options "Extracts compojure-api options from the request." [request] (::options request)) +(defn wrap-inject-data + "Injects data into the request." + [handler data] + (fn + ([request] + (handler (common/fast-map-merge request data))) + ([request respond raise] + (handler (common/fast-map-merge request data) respond raise)))) + ;; ;; coercion ;; -(s/defschema CoercionType (s/enum :body :string :response)) +(defn wrap-coercion [handler coercion] + (fn + ([request] + (handler (coercion/set-request-coercion request coercion))) + ([request respond raise] + (handler (coercion/set-request-coercion request coercion) respond raise)))) +;; 1.1.x (def default-coercion-matchers {:body coerce/json-schema-coercion-matcher :string coerce/query-schema-coercion-matcher :response coerce/json-schema-coercion-matcher}) -(def no-response-coercion - (constantly (dissoc default-coercion-matchers :response))) - +;; 1.1.x (defn coercion-matchers [request] (let [options (get-options request)] (if (contains? options :coercion) @@ -99,68 +129,108 @@ (provider request)) default-coercion-matchers))) -(def coercion-request-ks [::options :coercion]) +;; +;; Muuntaja +;; -(defn wrap-coercion [handler coercion] - (fn [request] - (handler (assoc-in request coercion-request-ks coercion)))) +(defn encode? + "Returns true if the response body is serializable: body is a + collection or response has key :compojure.api.meta/serializable?" + [_ response] + (or (:compojure.api.meta/serializable? response) + (coll? (:body response)))) + +(def default-muuntaja-options + (assoc-in + m/default-options + [:formats "application/json" :opts :modules] + [(JodaModule.)])) + +(defn create-muuntaja + ([] + (create-muuntaja default-muuntaja-options)) + ([muuntaja-or-options] + (let [opts #(assoc-in % [:http :encode-response-body?] encode?)] + (cond + + (nil? muuntaja-or-options) + nil + + (= ::default muuntaja-or-options) + (m/create (opts default-muuntaja-options)) + + (m/muuntaja? muuntaja-or-options) + (-> muuntaja-or-options (m/options) (opts) (m/create)) + + (map? muuntaja-or-options) + (m/create (opts muuntaja-or-options)) + + :else + (throw + (ex-info + (str "Invalid :formats - " muuntaja-or-options) + {:options muuntaja-or-options})))))) + +;; +;; middleware +;; + +(defn middleware-fn [middleware] + (if (vector? middleware) + (let [[f & arguments] middleware] + #(apply f % arguments)) + middleware)) + +(defn compose-middleware [middleware] + (->> middleware + (keep identity) + (map middleware-fn) + (apply comp identity))) ;; -;; ring-middleware-format stuff +;; swagger-data ;; -(def ^:private default-mime-types - {:json "application/json" - :json-kw "application/json" - :edn "application/edn" - :clojure "application/clojure" - :yaml "application/x-yaml" - :yaml-kw "application/x-yaml" - :yaml-in-html "text/html" - :transit-json "application/transit+json" - :transit-msgpack "application/transit+msgpack"}) - -(defn mime-types - [format] - (get default-mime-types format - (some-> format :content-type))) - -(def ^:private response-only-mimes #{:clojure :yaml-in-html}) - -(defn ->mime-types [formats] (keep mime-types formats)) - -(defn handle-req-error [^Throwable e handler request] - ;; Ring-middleware-format catches all exceptions in req handling, - ;; i.e. (handler req) is inside try-catch. If r-m-f was changed to catch only - ;; exceptions from parsing the request, we wouldn't need to check the exception class. - (if (or (instance? JsonParseException e) (instance? ParserException e)) - (throw (ex-info "Error parsing request" {:type ::ex/request-parsing} e)) - (throw e))) - -(defn serializable? - "Predicate which returns true if the response body is serializable. - That is, return type is set by :return compojure-api key or it's - a collection." - [_ {:keys [body] :as response}] - (when response - (or (:compojure.api.meta/serializable? response) - (coll? body)))) +(defn set-swagger-data + "Add extra top-level swagger-data into a request. + Data can be read with get-swagger-data." + ([request data] + (update request ::request/swagger (fnil conj []) data))) + +(defn get-swagger-data + "Reads and deep-merges top-level swagger-data from request, + pushed in by set-swagger-data." + [request] + (apply rsc/deep-merge (::request/swagger request))) + +(defn wrap-swagger-data + "Middleware that adds top level swagger-data into request." + [handler data] + (fn + ([request] + (handler (set-swagger-data request data))) + ([request respond raise] + (handler (set-swagger-data request data) respond raise)))) ;; ;; Api Middleware ;; (def api-middleware-defaults - {:format {:formats [:json-kw :yaml-kw :edn :transit-json :transit-msgpack] - :params-opts {} - :response-opts {}} - :exceptions {:handlers {::ex/request-validation ex/request-validation-handler + {::api-middleware-defaults true + :formats ::default + :exceptions {:handlers {:ring.util.http-response/response ex/http-response-handler + ::ex/request-validation ex/request-validation-handler ::ex/request-parsing ex/request-parsing-handler ::ex/response-validation ex/response-validation-handler ::ex/default ex/safe-handler}} - :coercion (constantly default-coercion-matchers) + :middleware nil + :coercion coercion/default-coercion :ring-swagger nil}) +(defn api-middleware-options [options] + (rsc/deep-merge api-middleware-defaults options)) + ;; TODO: test all options! (https://github.com/metosin/compojure-api/issues/137) (defn api-middleware "Opinionated chain of middlewares for web apis. Takes optional options-map. @@ -186,76 +256,126 @@ ### Options + - **:formatter** either :ring-middleware-format or :muuntaja. + During 2.x pre-releases, this will be a required key, unless + :formats is provided, which is equivalent to setting to :muuntaja. + Stable 2.x releases will default to :ring-middleware-format if + not provided or :format is set, unless :formats is provided, + which is equivalent to setting to :muuntaja. + Stable 2.x will print a deprecation warning if implicitly + or explicitly set to :ring-middleware-format. + - **:exceptions** for *compojure.api.middleware/wrap-exceptions* (nil to unmount it) - **:handlers** Map of error handlers for different exception types, type refers to `:type` key in ExceptionInfo data. - - **:format** for ring-middleware-format middlewares (nil to unmount it) - - **:formats** sequence of supported formats, e.g. `[:json-kw :edn]` - - **:params-opts** for *ring.middleware.format-params/wrap-restful-params*, - e.g. `{:transit-json {:handlers readers}}` - - **:response-opts** for *ring.middleware.format-params/wrap-restful-response*, - e.g. `{:transit-json {:handlers writers}}` + - **:formats** for Muuntaja middleware. Value can be a valid muuntaja options-map, + a Muuntaja instance or nil (to unmount it). See + https://github.com/metosin/muuntaja/blob/master/doc/Configuration.md for details. + + - **:middleware** vector of extra middleware to be applied last (just before the handler). - **:ring-swagger** options for ring-swagger's swagger-json method. e.g. `{:ignore-missing-mappings? true}` - **:coercion** A function from request->type->coercion-matcher, used - in endpoint coercion for :body, :string and :response. - Defaults to `(constantly compojure.api.middleware/default-coercion-matchers)` - Setting value to nil disables all coercion + in endpoint coercion for types :body, :string and :response. + Defaults to `compojure.api.middleware/default-coercion` + Setting value to nil disables all coercion. - **:components** Components which should be accessible to handlers using :components restructuring. (If you are using api, you might want to take look at using wrap-components middleware manually.). Defaults to nil (middleware not mounted)." - ([handler] (api-middleware handler nil)) + ([handler] + (throw (ex-info (str "ERROR: Please set `:formatter :muuntaja` in the options map of `api-middleware.\n" + "e.g., (api-middleware {:formatter :muuntaja})\n" + "To prepare for backwards compatibility with compojure-api 1.x, the formatting library must be \n" + "explicitly chosen if not configured by `:format` (ring-middleware-format) or\n" + "`:formats` (muuntaja). Once 2.x is stable, the default will be `:formatter :ring-middleware-format`.") + {})) + (api-middleware handler api-middleware-defaults)) ([handler options] - (let [options (rsc/deep-merge api-middleware-defaults options) - {:keys [exceptions format components]} options - {:keys [formats params-opts response-opts]} format] - ; Break at compile time if there are deprecated options - ; These three have been deprecated with 0.23 - (assert (not (:error-handler (:validation-errors options))) - (str "ERROR: Option: [:validation-errors :error-handler] is no longer supported, " - "use {:exceptions {:handlers {:compojure.api.middleware/request-validation your-handler}}} instead." - "Also note that exception-handler arity has been changed.")) - (assert (not (:catch-core-errors? (:validation-errors options))) - (str "ERROR: Option [:validation-errors :catch-core-errors?] is no longer supported, " - "use {:exceptions {:handlers {:schema.core/error compojure.api.exception/schema-error-handler}}} instead." - "Also note that exception-handler arity has been changed.")) - (assert (not (:exception-handler (:exceptions options))) - (str "ERROR: Option [:exceptions :exception-handler] is no longer supported, " - "use {:exceptions {:handlers {:compojure.api.exception/default your-handler}}} instead." - "Also note that exception-handler arity has been changed.")) - (assert (not (map? (:coercion options))) - (str "ERROR: Option [:coercion] should be a funtion of request->type->matcher, got a map instead." - "From 1.0.0 onwards, you should wrap your type->matcher map into a request-> function. If you " - "want to apply the matchers for all request types, wrap your option with 'constantly'")) - (cond-> handler - components (wrap-components components) - true ring.middleware.http-response/wrap-http-response - (seq formats) (rsm/wrap-swagger-data {:produces (->mime-types (remove response-only-mimes formats)) - :consumes (->mime-types formats)}) - true (wrap-options (select-keys options [:ring-swagger :coercion])) - (seq formats) (wrap-restful-params {:formats (remove response-only-mimes formats) - :handle-error handle-req-error - :format-options params-opts}) - exceptions (wrap-exceptions exceptions) - (seq formats) (wrap-restful-response {:formats formats - :predicate serializable? - :format-options response-opts}) - true wrap-keyword-params - true wrap-nested-params - true wrap-params)))) - -(defn middleware-fn [middleware] - (if (vector? middleware) - (let [[f & arguments] middleware] - #(apply f % arguments)) - middleware)) + (when (and (::api-middleware-defaults options) + (not (:formatter options)) + (not (System/getProperty "compojure.api.middleware.global-default-formatter"))) + (throw (ex-info (str "ERROR: Please set `:formatter :muuntaja` in the options map of `api-middleware.\n" + "e.g., (api-middleware {:formatter :muuntaja})\n" + "To prepare for backwards compatibility with compojure-api 1.x, the formatting library must be\n" + "explicitly chosen if not configured by `:format` (ring-middleware-format) or\n" + ":formats (muuntaja). Once 2.x is stable, the default will be `:formatter :ring-middleware-format`.\n" + "To globally override the default formatter, use -Dcompojure.api.middleware.global-default-formatter=:muuntaja") + {}))) + (let [formatter (or (:formatter options) + (when (or (contains? options :formats) + (= (System/getProperty "compojure.api.middleware.global-default-formatter") + ":muuntaja")) + :muuntaja) + (throw (ex-info (str "ERROR: Please set `:formatter :muuntaja` in the options map of `api-middleware.\n" + "e.g., (api-middleware {:formatter :muuntaja})\n" + "To prepare for backwards compatibility with compojure-api 1.x, the formatting library must be\n" + "explicitly chosen if not configured by `:format` (ring-middleware-format) or\n" + ":formats (muuntaja). Once 2.x is stable, the default will be `:formatter :ring-middleware-format`.\n" + "To globally override the default formatter, use -Dcompojure.api.middleware.global-default-formatter=:muuntaja") + {})) + ;; TODO 2.x stable + :ring-middleware-format) + _ (assert (= :muuntaja formatter) + (str "Invalid :formatter: " (pr-str formatter) ". Must be :muuntaja.")) + options (api-middleware-options options) + {:keys [exceptions components formats middleware ring-swagger coercion]} options + muuntaja (create-muuntaja formats)] + + ;; 1.2.0+ + (assert (not (contains? options :format)) + (str "ERROR: Option [:format] is not used with 2.* version.\n" + "Compojure-api uses now Muuntaja insted of ring-middleware-format,\n" + "the new formatting options for it should be under [:formats]. See\n" + "[[api-middleware]] documentation for more details.\n")) + + (-> handler + (cond-> middleware ((compose-middleware middleware))) + (cond-> components (wrap-components components)) + (cond-> muuntaja (wrap-swagger-data {:consumes (m/decodes muuntaja) + :produces (m/encodes muuntaja)})) + (wrap-inject-data + (cond-> {::request/coercion coercion} + muuntaja (assoc ::request/muuntaja muuntaja) + ring-swagger (assoc ::request/ring-swagger ring-swagger))) + (cond-> muuntaja (muuntaja.middleware/wrap-params)) + ;; all but request-parsing exceptions (to make :body-params visible) + (cond-> exceptions (wrap-exceptions + (update exceptions :handlers dissoc ::ex/request-parsing))) + (cond-> muuntaja (muuntaja.middleware/wrap-format-request muuntaja)) + ;; just request-parsing exceptions + (cond-> exceptions (wrap-exceptions + (update exceptions :handlers select-keys [::ex/request-parsing]))) + (cond-> muuntaja (muuntaja.middleware/wrap-format-response muuntaja)) + (cond-> muuntaja (muuntaja.middleware/wrap-format-negotiate muuntaja)) + + ;; these are really slow middleware, 4.5µs => 9.1µs (+100%) + + ;; 7.8µs => 9.1µs (+27%) + wrap-keyword-params + ;; 7.1µs => 7.8µs (+23%) + wrap-nested-params + ;; 4.5µs => 7.1µs (+50%) + wrap-params)))) + +(defn wrap-format + "Muuntaja format middleware. Can be safely mounted on top of multiple api + + - **:formats** for Muuntaja middleware. Value can be a valid muuntaja options-map, + a Muuntaja instance or nil (to unmount it). See + https://github.com/metosin/muuntaja/blob/master/doc/Configuration.md for details." + ([handler] + (wrap-format handler {:formats ::default})) + ([handler options] + (let [options (rsc/deep-merge {:formats ::default} options) + muuntaja (create-muuntaja (:formats options))] -(defn compose-middleware [middleware] - (->> middleware - (keep identity) - (map middleware-fn) - (apply comp identity))) + (cond-> handler + muuntaja (-> (wrap-swagger-data {:consumes (m/decodes muuntaja) + :produces (m/encodes muuntaja)}) + (muuntaja.middleware/wrap-format-request muuntaja) + (muuntaja.middleware/wrap-format-response muuntaja) + (muuntaja.middleware/wrap-format-negotiate muuntaja)))))) diff --git a/src/compojure/api/resource.clj b/src/compojure/api/resource.clj index b3d2a770..ec74a061 100644 --- a/src/compojure/api/resource.clj +++ b/src/compojure/api/resource.clj @@ -1,11 +1,13 @@ (ns compojure.api.resource (:require [compojure.api.routes :as routes] - [compojure.api.coerce :as coerce] + [compojure.api.coercion :as coercion] [compojure.api.methods :as methods] [ring.swagger.common :as rsc] [schema.core :as s] [plumbing.core :as p] - [compojure.api.middleware :as mw])) + [compojure.api.async] + [compojure.api.middleware :as mw] + [compojure.api.coercion.core :as cc])) (def ^:private +mappings+ {:methods methods/all-methods @@ -26,51 +28,112 @@ (:parameters +mappings+)) (dissoc info :handler))) +(defn- inject-coercion [request info] + (if (contains? info :coercion) + (coercion/set-request-coercion request (:coercion info)) + request)) + (defn- coerce-request [request info ks] (reduce-kv (fn [request ring-key [compojure-key _ type open?]] - (if-let [schema (get-in info (concat ks [:parameters ring-key]))] - (let [schema (if open? (assoc schema s/Keyword s/Any) schema)] - (update request ring-key merge (coerce/coerce! schema compojure-key type request))) + (if-let [model (get-in info (concat ks [:parameters ring-key]))] + (let [coerced (coercion/coerce-request! + model compojure-key type (not= :body type) open? request)] + (if open? + (update request ring-key merge coerced) + (assoc request ring-key coerced))) request)) - request + (inject-coercion request info) (:parameters +mappings+))) (defn- coerce-response [response info request ks] - (coerce/coerce-response! request response (get-in info (concat ks [:responses])))) - -(defn- resolve-handler [info request-method] - (or - (get-in info [request-method :handler]) - (get-in info [:handler]))) + (coercion/coerce-response! request response (get-in info (concat ks [:responses])))) + +(defn- maybe-async [async? x] + (if (and async? x) [x true])) + +(defn- maybe-sync [x] + (if x [x false])) + +(defn- resolve-handler [info path-info route request-method async?] + (and + (or + ;; directly under a context + (= path-info "/") + ;; under an compojure endpoint + route + ;; vanilla ring + (nil? path-info)) + (let [[handler async] (or + (maybe-async async? (get-in info [request-method :async-handler])) + (maybe-sync (get-in info [request-method :handler])) + (maybe-async async? (get-in info [:async-handler])) + (maybe-sync (get-in info [:handler])))] + (if handler + [handler async])))) + +(defn- middleware-chain [info request-method handler] + (let [direct-mw (:middleware info) + method-mw (:middleware (get info request-method)) + middleware (mw/compose-middleware (concat direct-mw method-mw))] + (middleware handler))) (defn- create-childs [info] (map (fn [[method info]] - (routes/create "/" method (swaggerize info) nil nil)) + (routes/map->Route + {:path "/" + :method method + :info {:public (swaggerize info)}})) (select-keys info (:methods +mappings+)))) -(defn- create-handler [info {:keys [coercion]}] - (fn [{:keys [request-method] :as request}] - (let [request (if coercion (assoc-in request mw/coercion-request-ks coercion) request) - ks (if (contains? info request-method) [request-method] [])] - (if-let [handler (resolve-handler info request-method)] - (-> (coerce-request request info ks) - handler - (coerce-response info request ks)))))) +(defn- handle-sync [info {:keys [request-method path-info :compojure/route] :as request}] + (when-let [[raw-handler] (resolve-handler info path-info route request-method false)] + (let [ks (if (contains? info request-method) [request-method] []) + handler (middleware-chain info request-method raw-handler)] + (-> (coerce-request request info ks) + (handler) + (compojure.response/render request) + (coerce-response info request ks))))) + +(defn- handle-async [info {:keys [request-method path-info :compojure/route] :as request} respond raise] + (if-let [[raw-handler async?] (resolve-handler info path-info route request-method true)] + (let [ks (if (contains? info request-method) [request-method] []) + respond-coerced (fn [response] + (respond + (try (coerce-response response info request ks) + (catch Throwable e (raise e))))) + handler (middleware-chain info request-method raw-handler)] + (try + (as-> (coerce-request request info ks) $ + (if async? + (handler $ #(compojure.response/send % $ respond-coerced raise) raise) + (compojure.response/send (handler $) $ respond-coerced raise))) + (catch Throwable e + (raise e)))) + (respond nil))) + +(defn- create-handler [info] + (fn + ([request] + (handle-sync info request)) + ([request respond raise] + (handle-async info request respond raise)))) (defn- merge-parameters-and-responses [info] (let [methods (select-keys info (:methods +mappings+))] (-> info (merge - (p/for-map [[method method-info] methods] - method (-> method-info - (->> (rsc/deep-merge (select-keys info [:parameters]))) - (update :responses (fn [responses] (merge (:responses info) responses))))))))) - -(defn- root-info [info] + (p/for-map [[method method-info] methods + :let [responses (merge + (:responses info) + (:responses method-info))]] + method (cond-> (->> method-info (rsc/deep-merge (select-keys info [:parameters]))) + (seq responses) (assoc :responses responses))))))) + +(defn- public-root-info [info] (-> (reduce dissoc info (:methods +mappings+)) - (dissoc :parameters :responses))) + (dissoc :parameters :responses :coercion))) ;; ;; Public api @@ -82,17 +145,21 @@ ; TODO: validate input against ring-swagger schema, fail for missing handlers ; TODO: extract parameter schemas from handler fnks? (defn resource - "Creates a nested compojure-api Route from enchanced ring-swagger operations map and options. + "Creates a nested compojure-api Route from enchanced ring-swagger operations map. By default, applies both request- and response-coercion based on those definitions. - Options: + Extra keys: + + - **:middleware** Middleware in duct-format either at top-level or under methods. + Top-level mw are applied first if route matches, method-level + mw are applied next if method matches - **:coercion** A function from request->type->coercion-matcher, used in resource coercion for :body, :string and :response. Setting value to `(constantly nil)` disables both request- & response coercion. See tests and wiki for details. - Enchancements to ring-swagger operations map: + Enhancements to ring-swagger operations map: 1) :parameters use ring request keys (query-params, path-params, ...) instead of swagger-params (query, path, ...). This keeps things simple as ring keys are used in @@ -105,9 +172,14 @@ 2.2) :responses are merged into operation :responses (operation can fully override them) 2.3) all others (:produces, :consumes, :summary,...) are deep-merged by compojure-api - 3) special key `:handler` either under operations or at top-level. Value should be a - ring-handler function, responsible for the actual request processing. Handler lookup - order is the following: operations-level, top-level. + 3) special keys `:handler` and/or `:async-handler` either under operations or at top-level. + They should be 1-ary and 3-ary Ring handler functions, respectively, that are responsible + for the actual request processing. Handler lookup order is the following: + + 3.1) If called asynchronously, operations-level :async-handler + 3.2) Operations-level :handler + 3.3) If called asynchronously, top-level :async-handler + 3.4) Top-level :handler 4) request-coercion is applied once, using deep-merged parameters for a given operation or resource-level if only resource-level handler is defined. @@ -131,11 +203,13 @@ :post {} :handler (constantly (internal-server-error {:reason \"not implemented\"}))})" - ([info] - (resource info {})) - ([info options] - (let [info (merge-parameters-and-responses info) - root-info (swaggerize (root-info info)) - childs (create-childs info) - handler (create-handler info options)] - (routes/create nil nil root-info childs handler)))) + [data] + (let [data (merge-parameters-and-responses data) + public-info (swaggerize (public-root-info data)) + info (merge {:public public-info} (select-keys data [:coercion])) + childs (create-childs data) + handler (create-handler data)] + (routes/map->Route + {:info info + :childs childs + :handler handler}))) diff --git a/src/compojure/api/routes.clj b/src/compojure/api/routes.clj index 317ddb02..a468d326 100644 --- a/src/compojure/api/routes.clj +++ b/src/compojure/api/routes.clj @@ -1,15 +1,18 @@ (ns compojure.api.routes (:require [compojure.core :refer :all] [clojure.string :as string] - [cheshire.core :as json] - [compojure.api.middleware :as mw] + [compojure.api.methods :as methods] + [compojure.api.request :as request] [compojure.api.impl.logging :as logging] + [compojure.api.impl.json :as json] [compojure.api.common :as common] + [muuntaja.core :as m] [ring.swagger.common :as rsc] [clojure.string :as str] [linked.core :as linked] [compojure.response] - [schema.core :as s]) + [schema.core :as s] + [compojure.api.coercion :as coercion]) (:import (clojure.lang AFn IFn Var IDeref) (java.io Writer))) @@ -64,24 +67,22 @@ (defrecord Route [path method info childs handler] Routing (-get-routes [this options] - (let [valid-childs (filter-routes this options)] - (if (seq childs) + (let [this (-> this realize-childs) + valid-childs (filter-routes this options) + make-method-path-fn (fn [m] [path m info])] + (if (-> this filter-childs :childs seq) (vec (for [[p m i] (mapcat #(-get-routes % options) valid-childs)] [(->paths path p) m (rsc/deep-merge info i)])) - (into [] (if path [[path method info]]))))) + (into [] (cond + (and path method) [(make-method-path-fn method)] + path (mapv make-method-path-fn methods/all-methods)))))) compojure.response/Renderable - (render [_ {:keys [uri request-method]}] - (throw - (ex-info - (str "\ncompojure.api.routes/Route can't be returned from endpoint " - (-> request-method name str/upper-case) " \"" uri "\". " - "For nested routes, use `context` instead: (context \"path\" [] ...)\n") - {:request-method request-method - :path path - :method method - :uri uri}))) + (render [_ request] + (handler request)) + + ;; Sendable implementation in compojure.api.async IFn (invoke [_ request] @@ -144,11 +145,16 @@ {:paths (reduce (fn [acc [path method info]] - (update-in - acc [path method] - (fn [old-info] - (let [info (or old-info info)] - (ensure-path-parameters path info))))) + (if-not (:no-doc info) + (if-let [public-info (->> (get info :public {}) + (coercion/get-apidocs (:coercion info) "swagger"))] + (update-in + acc [path method] + (fn [old-info] + (let [public-info (or old-info public-info)] + (ensure-path-parameters path public-info)))) + acc) + acc)) (linked/map) routes)}) @@ -160,8 +166,19 @@ (for [[id freq] (frequencies seq) :when (> freq 1)] id)) +(defn all-paths [routes] + (reduce + (fn [acc [path method info]] + (let [public-info (get info :public {})] + (update-in acc [path method] + (fn [old-info] + (let [public-info (or old-info public-info)] + (ensure-path-parameters path public-info)))))) + (linked/map) + routes)) + (defn route-lookup-table [routes] - (let [entries (for [[path endpoints] (-> routes ring-swagger-paths :paths) + (let [entries (for [[path endpoints] (all-paths routes) [method {:keys [x-name parameters]}] endpoints :let [params (:path parameters)] :when x-name] @@ -182,12 +199,6 @@ ;; Endpoint Trasformers ;; -(defn strip-no-doc-endpoints - "Endpoint transformer, strips all endpoints that have :x-no-doc true." - [endpoint] - (if-not (some-> endpoint :x-no-doc true?) - endpoint)) - (defn non-nil-routes [endpoint] (or endpoint {})) @@ -198,7 +209,7 @@ (defn- un-quote [s] (str/replace s #"^\"(.+(?=\"$))\"$" "$1")) -(defn- path-string [s params] +(defn- path-string [m s params] (-> s (str/replace #":([^/]+)" " :$1 ") (str/split #" ") @@ -208,7 +219,7 @@ (let [key (keyword (subs token 1)) value (key params)] (if value - (un-quote (json/generate-string value)) + (un-quote (slurp (m/encode m "application/json" value))) (throw (IllegalArgumentException. (str "Missing path-parameter " key " for path " s))))) @@ -218,14 +229,14 @@ (defn path-for* "Extracts the lookup-table from request and finds a route by name." [route-name request & [params]] - (let [[path details] (some-> request - mw/get-options - :lookup + (let [m (or (::request/muuntaja request) json/muuntaja) + [path details] (some-> request + ::request/lookup route-name first) path-params (:params details)] (if (seq path-params) - (path-string path params) + (path-string m path params) path))) (defmacro path-for diff --git a/src/compojure/api/swagger.clj b/src/compojure/api/swagger.clj index 18055863..a2f160da 100644 --- a/src/compojure/api/swagger.clj +++ b/src/compojure/api/swagger.clj @@ -1,14 +1,15 @@ (ns compojure.api.swagger (:require [compojure.api.core :as c] - [compojure.api.common :as common] [compojure.api.middleware :as mw] + [compojure.api.request :as request] [ring.util.http-response :refer [ok]] [ring.swagger.common :as rsc] [ring.swagger.middleware :as rsm] [ring.swagger.core :as swagger] [ring.swagger.swagger-ui :as swagger-ui] [ring.swagger.swagger2 :as swagger2] - [compojure.api.routes :as routes])) + [compojure.api.routes :as routes] + [spec-tools.swagger.core])) (defn base-path [request] (let [context (swagger/context request)] @@ -24,9 +25,7 @@ first)) (defn transform-operations [swagger] - (->> swagger - (swagger2/transform-operations routes/non-nil-routes) - (swagger2/transform-operations routes/strip-no-doc-endpoints))) + (swagger2/transform-operations routes/non-nil-routes swagger)) (defn swagger-ui [options] (assert (map? options) "Since 1.1.11, compojure.api.swagger/swagger-ui takes just one map as argument, with `:path` for the path.") @@ -39,12 +38,13 @@ (c/GET path request :no-doc true :name ::swagger - (let [runtime-info (rsm/get-swagger-data request) + (let [runtime-info1 (mw/get-swagger-data request) + runtime-info2 (rsm/get-swagger-data request) base-path {:basePath (base-path request)} - options (:ring-swagger (mw/get-options request)) - paths (:paths (mw/get-options request)) - swagger (apply rsc/deep-merge (keep identity [base-path paths extra-info runtime-info])) - spec (swagger2/swagger-json swagger options)] + options (::request/ring-swagger request) + paths (::request/paths request) + swagger (apply rsc/deep-merge (keep identity [base-path paths extra-info runtime-info1 runtime-info2])) + spec (spec-tools.swagger.core/swagger-spec (swagger2/swagger-json swagger options))] (ok spec))))) ;; @@ -56,15 +56,21 @@ (defn swagger-routes "Returns routes for swagger-articats (ui & spec). Accepts an options map, with the following options: + **:ui** Path for the swagger-ui (defaults to \"/\"). Setting the value to nil will cause the swagger-ui not to be mounted + **:spec** Path for the swagger-spec (defaults to \"/swagger.json\") Setting the value to nil will cause the swagger-ui not to be mounted + **:data** Swagger data in the Ring-Swagger format. + **:options** **:ui** Options to configure the ui **:spec** Options to configure the spec. Nada at the moment. + Example options: + {:ui \"/api-docs\" :spec \"/swagger.json\" :options {:ui {:jsonEditor true} diff --git a/src/compojure/api/sweet.clj b/src/compojure/api/sweet.clj index 1a84d637..737a45c3 100644 --- a/src/compojure/api/sweet.clj +++ b/src/compojure/api/sweet.clj @@ -4,8 +4,8 @@ (defmacro defroutes {:doc "Define a Ring handler function from a sequence of routes.\n The name may optionally be followed by a doc-string and metadata map."} [name & routes] (list* (quote compojure.api.core/defroutes) name routes)) (defmacro let-routes {:doc "Takes a vector of bindings and a body of routes.\n\n Equivalent to: `(let [...] (routes ...))`"} [bindings & body] (list* (quote compojure.api.core/let-routes) bindings body)) (def ^{:arglists (quote ([& handlers])), :doc "Routes without route-documentation. Can be used to wrap routes,\n not satisfying compojure.api.routes/Routing -protocol."} undocumented compojure.api.core/undocumented) -(defmacro middleware {:deprecated "1.1.14", :doc "Wraps routes with given middleware using thread-first macro.\n\n Note that middlewares will be executed even if routes in body\n do not match the request uri. Be careful with middleware that\n has side-effects."} [middleware & body] (list* (quote compojure.api.core/middleware) middleware body)) -(def ^{:arglists (quote ([middleware & body])), :doc "Wraps routes with given middleware using thread-first macro."} route-middleware compojure.api.core/route-middleware) +(defmacro middleware {:deprecated "1.1.14", :doc "Wraps routes with given middlewares using thread-first macro.\n\n Note that middlewares will be executed even if routes in body\n do not match the request uri. Be careful with middleware that\n has side-effects."} [middleware & body] (list* (quote compojure.api.core/middleware) middleware body)) +(def ^{:arglists (quote ([middleware & body])), :doc "Wraps routes with given middlewares using thread-first macro."} route-middleware compojure.api.core/route-middleware) (defmacro context [& args] (list* (quote compojure.api.core/context) args)) (defmacro GET [& args] (list* (quote compojure.api.core/GET) args)) (defmacro ANY [& args] (list* (quote compojure.api.core/ANY) args)) @@ -15,9 +15,9 @@ (defmacro OPTIONS [& args] (list* (quote compojure.api.core/OPTIONS) args)) (defmacro POST [& args] (list* (quote compojure.api.core/POST) args)) (defmacro PUT [& args] (list* (quote compojure.api.core/PUT) args)) -(def ^{:arglists (quote ([& body])), :doc "Returns a ring handler wrapped in compojure.api.middleware/api-middlware.\n Creates the route-table at api creation time and injects that into the request via\n middlewares. Api and the mounted api-middleware can be configured by optional\n options map as the first parameter:\n\n (api\n {:formats [:json-kw :edn :transit-msgpack :transit-json]\n :exceptions {:handlers {:compojure.api.exception/default my-logging-handler}}\n :api {:invalid-routes-fn (constantly nil)}\n :swagger {:spec \"/swagger.json\"\n :ui \"/api-docs\"\n :data {:info {:version \"1.0.0\"\n :title \"My API\"\n :description \"the description\"}}}}\n (context \"/api\" []\n ...))\n\n ### direct api options:\n\n - **:api** All api options are under `:api`.\n - **:invalid-routes-fn** A 2-arity function taking handler and a sequence of\n invalid routes (not satisfying compojure.api.route.Routing)\n setting value to nil ignores invalid routes completely.\n defaults to `compojure.api.routes/log-invalid-child-routes`\n - **:disable-api-middleware?** boolean to disable the `api-middleware` from api.\n - **:swagger** Options to configure the Swagger-routes. Defaults to nil.\n See `compojure.api.swagger/swagger-routes` for details.\n\n ### api-middleware options\n\n Opinionated chain of middlewares for web apis. Takes optional options-map.\n\n ### Exception handlers\n\n An error handler is a function of exception, ex-data and request to response.\n\n When defining these options, it is suggested to use alias for the exceptions namespace,\n e.g. `[compojure.api.exception :as ex]`.\n\n Default:\n\n {::ex/request-validation ex/request-validation-handler\n ::ex/request-parsing ex/request-parsing-handler\n ::ex/response-validation ex/response-validation-handler\n ::ex/default ex/safe-handler}\n\n Note: Because the handlers are merged into default handlers map, to disable default handler you\n need to provide `nil` value as handler.\n\n Note: To catch Schema errors use `{:schema.core/error ex/schema-error-handler}`.\n\n ### Options\n\n - **:exceptions** for *compojure.api.middleware/wrap-exceptions* (nil to unmount it)\n - **:handlers** Map of error handlers for different exception types, type refers to `:type` key in ExceptionInfo data.\n\n - **:format** for ring-middleware-format middlewares (nil to unmount it)\n - **:formats** sequence of supported formats, e.g. `[:json-kw :edn]`\n - **:params-opts** for *ring.middleware.format-params/wrap-restful-params*,\n e.g. `{:transit-json {:handlers readers}}`\n - **:response-opts** for *ring.middleware.format-params/wrap-restful-response*,\n e.g. `{:transit-json {:handlers writers}}`\n\n - **:ring-swagger** options for ring-swagger's swagger-json method.\n e.g. `{:ignore-missing-mappings? true}`\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in endpoint coercion for :body, :string and :response.\n Defaults to `(constantly compojure.api.middleware/default-coercion-matchers)`\n Setting value to nil disables all coercion\n\n - **:components** Components which should be accessible to handlers using\n :components restructuring. (If you are using api,\n you might want to take look at using wrap-components\n middleware manually.). Defaults to nil (middleware not mounted)."} api compojure.api.api/api) -(defmacro defapi {:doc "Defines an api.\n\n API middleware options:\n\n Opinionated chain of middlewares for web apis. Takes optional options-map.\n\n ### Exception handlers\n\n An error handler is a function of exception, ex-data and request to response.\n\n When defining these options, it is suggested to use alias for the exceptions namespace,\n e.g. `[compojure.api.exception :as ex]`.\n\n Default:\n\n {::ex/request-validation ex/request-validation-handler\n ::ex/request-parsing ex/request-parsing-handler\n ::ex/response-validation ex/response-validation-handler\n ::ex/default ex/safe-handler}\n\n Note: Because the handlers are merged into default handlers map, to disable default handler you\n need to provide `nil` value as handler.\n\n Note: To catch Schema errors use `{:schema.core/error ex/schema-error-handler}`.\n\n ### Options\n\n - **:exceptions** for *compojure.api.middleware/wrap-exceptions* (nil to unmount it)\n - **:handlers** Map of error handlers for different exception types, type refers to `:type` key in ExceptionInfo data.\n\n - **:format** for ring-middleware-format middlewares (nil to unmount it)\n - **:formats** sequence of supported formats, e.g. `[:json-kw :edn]`\n - **:params-opts** for *ring.middleware.format-params/wrap-restful-params*,\n e.g. `{:transit-json {:handlers readers}}`\n - **:response-opts** for *ring.middleware.format-params/wrap-restful-response*,\n e.g. `{:transit-json {:handlers writers}}`\n\n - **:ring-swagger** options for ring-swagger's swagger-json method.\n e.g. `{:ignore-missing-mappings? true}`\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in endpoint coercion for :body, :string and :response.\n Defaults to `(constantly compojure.api.middleware/default-coercion-matchers)`\n Setting value to nil disables all coercion\n\n - **:components** Components which should be accessible to handlers using\n :components restructuring. (If you are using api,\n you might want to take look at using wrap-components\n middleware manually.). Defaults to nil (middleware not mounted)."} [name & body] (list* (quote compojure.api.api/defapi) name body)) -(def ^{:arglists (quote ([info] [info options])), :doc "Creates a nested compojure-api Route from enchanced ring-swagger operations map and options.\n By default, applies both request- and response-coercion based on those definitions.\n\n Options:\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in resource coercion for :body, :string and :response.\n Setting value to `(constantly nil)` disables both request- &\n response coercion. See tests and wiki for details.\n\n Enchancements to ring-swagger operations map:\n\n 1) :parameters use ring request keys (query-params, path-params, ...) instead of\n swagger-params (query, path, ...). This keeps things simple as ring keys are used in\n the handler when destructuring the request.\n\n 2) at resource root, one can add any ring-swagger operation definitions, which will be\n available for all operations, using the following rules:\n\n 2.1) :parameters are deep-merged into operation :parameters\n 2.2) :responses are merged into operation :responses (operation can fully override them)\n 2.3) all others (:produces, :consumes, :summary,...) are deep-merged by compojure-api\n\n 3) special key `:handler` either under operations or at top-level. Value should be a\n ring-handler function, responsible for the actual request processing. Handler lookup\n order is the following: operations-level, top-level.\n\n 4) request-coercion is applied once, using deep-merged parameters for a given\n operation or resource-level if only resource-level handler is defined.\n\n 5) response-coercion is applied once, using merged responses for a given\n operation or resource-level if only resource-level handler is defined.\n\n Note: Swagger operations are generated only from declared operations (:get, :post, ..),\n despite the top-level handler could process more operations.\n\n Example:\n\n (resource\n {:parameters {:query-params {:x Long}}\n :responses {500 {:schema {:reason s/Str}}}\n :get {:parameters {:query-params {:y Long}}\n :responses {200 {:schema {:total Long}}}\n :handler (fn [request]\n (ok {:total (+ (-> request :query-params :x)\n (-> request :query-params :y))}))}\n :post {}\n :handler (constantly\n (internal-server-error {:reason \"not implemented\"}))})"} resource compojure.api.resource/resource) +(def ^{:arglists (quote ([& body])), :doc "Returns a ring handler wrapped in compojure.api.middleware/api-middlware.\n Creates the route-table at api creation time and injects that into the request via\n middlewares. Api and the mounted api-middleware can be configured by optional\n options map as the first parameter:\n\n (api\n {:exceptions {:handlers {:compojure.api.exception/default my-logging-handler}}\n :api {:invalid-routes-fn (constantly nil)}\n :swagger {:spec \"/swagger.json\"\n :ui \"/api-docs\"\n :data {:info {:version \"1.0.0\"\n :title \"My API\"\n :description \"the description\"}}}}\n (context \"/api\" []\n ...))\n\n ### direct api options:\n\n - **:api** All api options are under `:api`.\n - **:invalid-routes-fn** A 2-arity function taking handler and a sequence of\n invalid routes (not satisfying compojure.api.route.Routing)\n setting value to nil ignores invalid routes completely.\n defaults to `compojure.api.routes/log-invalid-child-routes`\n - **:disable-api-middleware?** boolean to disable the `api-middleware` from api.\n - **:swagger** Options to configure the Swagger-routes. Defaults to nil.\n See `compojure.api.swagger/swagger-routes` for details.\n\n ### api-middleware options\n\n See `compojure.api.middleware/api-middleware` for more available options.\n\n Opinionated chain of middlewares for web apis. Takes optional options-map.\n\n ### Exception handlers\n\n An error handler is a function of exception, ex-data and request to response.\n\n When defining these options, it is suggested to use alias for the exceptions namespace,\n e.g. `[compojure.api.exception :as ex]`.\n\n Default:\n\n {::ex/request-validation ex/request-validation-handler\n ::ex/request-parsing ex/request-parsing-handler\n ::ex/response-validation ex/response-validation-handler\n ::ex/default ex/safe-handler}\n\n Note: Because the handlers are merged into default handlers map, to disable default handler you\n need to provide `nil` value as handler.\n\n Note: To catch Schema errors use `{:schema.core/error ex/schema-error-handler}`.\n\n ### Options\n\n - **:exceptions** for *compojure.api.middleware/wrap-exceptions* (nil to unmount it)\n - **:handlers** Map of error handlers for different exception types, type refers to `:type` key in ExceptionInfo data.\n\n - **:formats** for Muuntaja middleware. Value can be a valid muuntaja options-map,\n a Muuntaja instance or nil (to unmount it). See\n https://github.com/metosin/muuntaja/blob/master/doc/Configuration.md for details.\n\n - **:middleware** vector of extra middleware to be applied last (just before the handler).\n\n - **:ring-swagger** options for ring-swagger's swagger-json method.\n e.g. `{:ignore-missing-mappings? true}`\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in endpoint coercion for types :body, :string and :response.\n Defaults to `compojure.api.middleware/default-coercion`\n Setting value to nil disables all coercion.\n\n - **:components** Components which should be accessible to handlers using\n :components restructuring. (If you are using api,\n you might want to take look at using wrap-components\n middleware manually.). Defaults to nil (middleware not mounted)."} api compojure.api.api/api) +(defmacro defapi {:deprecated "2.0.0", :doc "Deprecated: please use (def name (api ...body..))\n \n Defines an api.\n\n API middleware options:\n\n Opinionated chain of middlewares for web apis. Takes optional options-map.\n\n ### Exception handlers\n\n An error handler is a function of exception, ex-data and request to response.\n\n When defining these options, it is suggested to use alias for the exceptions namespace,\n e.g. `[compojure.api.exception :as ex]`.\n\n Default:\n\n {::ex/request-validation ex/request-validation-handler\n ::ex/request-parsing ex/request-parsing-handler\n ::ex/response-validation ex/response-validation-handler\n ::ex/default ex/safe-handler}\n\n Note: Because the handlers are merged into default handlers map, to disable default handler you\n need to provide `nil` value as handler.\n\n Note: To catch Schema errors use `{:schema.core/error ex/schema-error-handler}`.\n\n ### Options\n\n - **:exceptions** for *compojure.api.middleware/wrap-exceptions* (nil to unmount it)\n - **:handlers** Map of error handlers for different exception types, type refers to `:type` key in ExceptionInfo data.\n\n - **:formats** for Muuntaja middleware. Value can be a valid muuntaja options-map,\n a Muuntaja instance or nil (to unmount it). See\n https://github.com/metosin/muuntaja/blob/master/doc/Configuration.md for details.\n\n - **:middleware** vector of extra middleware to be applied last (just before the handler).\n\n - **:ring-swagger** options for ring-swagger's swagger-json method.\n e.g. `{:ignore-missing-mappings? true}`\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in endpoint coercion for types :body, :string and :response.\n Defaults to `compojure.api.middleware/default-coercion`\n Setting value to nil disables all coercion.\n\n - **:components** Components which should be accessible to handlers using\n :components restructuring. (If you are using api,\n you might want to take look at using wrap-components\n middleware manually.). Defaults to nil (middleware not mounted)."} [name & body] (list* (quote compojure.api.api/defapi) name body)) +(def ^{:arglists (quote ([data])), :doc "Creates a nested compojure-api Route from enchanced ring-swagger operations map.\n By default, applies both request- and response-coercion based on those definitions.\n\n Extra keys:\n\n - **:middleware** Middleware in duct-format either at top-level or under methods.\n Top-level mw are applied first if route matches, method-level\n mw are applied next if method matches\n\n - **:coercion** A function from request->type->coercion-matcher, used\n in resource coercion for :body, :string and :response.\n Setting value to `(constantly nil)` disables both request- &\n response coercion. See tests and wiki for details.\n\n Enchancements to ring-swagger operations map:\n\n 1) :parameters use ring request keys (query-params, path-params, ...) instead of\n swagger-params (query, path, ...). This keeps things simple as ring keys are used in\n the handler when destructuring the request.\n\n 2) at resource root, one can add any ring-swagger operation definitions, which will be\n available for all operations, using the following rules:\n\n 2.1) :parameters are deep-merged into operation :parameters\n 2.2) :responses are merged into operation :responses (operation can fully override them)\n 2.3) all others (:produces, :consumes, :summary,...) are deep-merged by compojure-api\n\n 3) special keys `:handler` and/or `:async-handler` either under operations or at top-level.\n They should be 1-ary and 3-ary Ring handler functions, respectively, that are responsible\n for the actual request processing. Handler lookup order is the following:\n\n 3.1) If called asynchronously, operations-level :async-handler\n 3.2) Operations-level :handler\n 3.3) If called asynchronously, top-level :async-handler\n 3.4) Top-level :handler\n\n 4) request-coercion is applied once, using deep-merged parameters for a given\n operation or resource-level if only resource-level handler is defined.\n\n 5) response-coercion is applied once, using merged responses for a given\n operation or resource-level if only resource-level handler is defined.\n\n Note: Swagger operations are generated only from declared operations (:get, :post, ..),\n despite the top-level handler could process more operations.\n\n Example:\n\n (resource\n {:parameters {:query-params {:x Long}}\n :responses {500 {:schema {:reason s/Str}}}\n :get {:parameters {:query-params {:y Long}}\n :responses {200 {:schema {:total Long}}}\n :handler (fn [request]\n (ok {:total (+ (-> request :query-params :x)\n (-> request :query-params :y))}))}\n :post {}\n :handler (constantly\n (internal-server-error {:reason \"not implemented\"}))})"} resource compojure.api.resource/resource) (defmacro path-for {:doc "Extracts the lookup-table from request and finds a route by name."} [route-name & arg2] (list* (quote compojure.api.routes/path-for) route-name arg2)) -(def ^{:arglists (quote ([] [options])), :doc "Returns routes for swagger-articats (ui & spec). Accepts an options map, with the\n following options:\n **:ui** Path for the swagger-ui (defaults to \"/\").\n Setting the value to nil will cause the swagger-ui not to be mounted\n **:spec** Path for the swagger-spec (defaults to \"/swagger.json\")\n Setting the value to nil will cause the swagger-ui not to be mounted\n **:data** Swagger data in the Ring-Swagger format.\n **:options**\n **:ui** Options to configure the ui\n **:spec** Options to configure the spec. Nada at the moment.\n Example options:\n {:ui \"/api-docs\"\n :spec \"/swagger.json\"\n :options {:ui {:jsonEditor true}\n :spec {}}\n :data {:basePath \"/app\"\n :info {:version \"1.0.0\"\n :title \"Sausages\"\n :description \"Sausage description\"\n :termsOfService \"http://helloreverb.com/terms/\"\n :contact {:name \"My API Team\"\n :email \"foo@example.com\"\n :url \"http://www.metosin.fi\"}\n :license {:name: \"Eclipse Public License\"\n :url: \"http://www.eclipse.org/legal/epl-v10.html\"}}\n :tags [{:name \"sausages\", :description \"Sausage api-set\"}]}}"} swagger-routes compojure.api.swagger/swagger-routes) +(def ^{:arglists (quote ([] [options])), :doc "Returns routes for swagger-articats (ui & spec). Accepts an options map, with the\n following options:\n\n **:ui** Path for the swagger-ui (defaults to \"/\").\n Setting the value to nil will cause the swagger-ui not to be mounted\n\n **:spec** Path for the swagger-spec (defaults to \"/swagger.json\")\n Setting the value to nil will cause the swagger-ui not to be mounted\n\n **:data** Swagger data in the Ring-Swagger format.\n\n **:options**\n **:ui** Options to configure the ui\n **:spec** Options to configure the spec. Nada at the moment.\n\n Example options:\n\n {:ui \"/api-docs\"\n :spec \"/swagger.json\"\n :options {:ui {:jsonEditor true}\n :spec {}}\n :data {:basePath \"/app\"\n :info {:version \"1.0.0\"\n :title \"Sausages\"\n :description \"Sausage description\"\n :termsOfService \"http://helloreverb.com/terms/\"\n :contact {:name \"My API Team\"\n :email \"foo@example.com\"\n :url \"http://www.metosin.fi\"}\n :license {:name: \"Eclipse Public License\"\n :url: \"http://www.eclipse.org/legal/epl-v10.html\"}}\n :tags [{:name \"sausages\", :description \"Sausage api-set\"}]}}"} swagger-routes compojure.api.swagger/swagger-routes) (def ^{:arglists (quote ([schema desc & kvs])), :doc "Attach description and possibly other meta-data to a schema."} describe ring.swagger.json-schema/describe) diff --git a/src/compojure/api/upload.clj b/src/compojure/api/upload.clj index 0a59e5e1..61ca42a0 100644 --- a/src/compojure/api/upload.clj +++ b/src/compojure/api/upload.clj @@ -1,5 +1,5 @@ ;; NOTE: This namespace is generated by compojure.api.dev.gen (ns compojure.api.upload (:require ring.middleware.multipart-params ring.swagger.upload)) -(def ^{:arglists (quote ([handler] [handler options])), :doc "Middleware to parse multipart parameters from a request. Adds the\n following keys to the request map:\n\n :multipart-params - a map of multipart parameters\n :params - a merged map of all types of parameter\n\n The following options are accepted\n\n :encoding - character encoding to use for multipart parsing.\n Overrides the encoding specified in the request. If not\n specified, uses the encoding specified in a part named\n \"_charset_\", or the content type for each part, or\n request character encoding if the part has no encoding,\n or \"UTF-8\" if no request character encoding is set.\n\n :fallback-encoding - specifies the character encoding used in parsing if a\n part of the request does not specify encoding in its\n content type or no part named \"_charset_\" is present.\n Has no effect if :encoding is also set.\n\n :store - a function that stores a file upload. The function\n should expect a map with :filename, content-type and\n :stream keys, and its return value will be used as the\n value for the parameter in the multipart parameter map.\n The default storage function is the temp-file-store.\n\n :progress-fn - a function that gets called during uploads. The\n function should expect four parameters: request,\n bytes-read, content-length, and item-count."} wrap-multipart-params ring.middleware.multipart-params/wrap-multipart-params) +(def ^{:arglists (quote ([handler] [handler options])), :doc "Middleware to parse multipart parameters from a request. Adds the\n following keys to the request map:\n\n :multipart-params - a map of multipart parameters\n :params - a merged map of all types of parameter\n\n The following options are accepted\n\n :encoding - character encoding to use for multipart parsing.\n Overrides the encoding specified in the request. If not\n specified, uses the encoding specified in a part named\n \"_charset_\", or the content type for each part, or\n request character encoding if the part has no encoding,\n or \"UTF-8\" if no request character encoding is set.\n\n :fallback-encoding - specifies the character encoding used in parsing if a\n part of the request does not specify encoding in its\n content type or no part named \"_charset_\" is present.\n Has no effect if :encoding is also set.\n\n :store - a function that stores a file upload. The function\n should expect a map with :filename, :content-type and\n :stream keys, and its return value will be used as the\n value for the parameter in the multipart parameter map.\n The default storage function is the temp-file-store.\n\n :progress-fn - a function that gets called during uploads. The\n function should expect four parameters: request,\n bytes-read, content-length, and item-count."} wrap-multipart-params ring.middleware.multipart-params/wrap-multipart-params) (def ^{:doc "Schema for file param created by ring.middleware.multipart-params.temp-file store."} TempFileUpload ring.swagger.upload/TempFileUpload) (def ^{:doc "Schema for file param created by ring.middleware.multipart-params.byte-array store."} ByteArrayUpload ring.swagger.upload/ByteArrayUpload) diff --git a/test/compojure/api/coercion/schema_coercion_test.clj b/test/compojure/api/coercion/schema_coercion_test.clj new file mode 100644 index 00000000..d363a4f2 --- /dev/null +++ b/test/compojure/api/coercion/schema_coercion_test.clj @@ -0,0 +1,294 @@ +(ns compojure.api.coercion.schema-coercion-test + (:require [schema.core :as s] + [clojure.test :refer [deftest is testing]] + [compojure.api.sweet :refer :all] + [compojure.api.test-utils :refer :all] + [compojure.api.request :as request] + [compojure.api.coercion :as coercion] + [compojure.api.validator :as validator] + [compojure.api.coercion.schema :as cs] + [compojure.api.coercion.core :as cc]) + (:import (schema.utils ValidationError NamedError))) + +(deftest stringify-error-test + (testing "ValidationError" + (is (= ValidationError (class (s/check s/Int "foo")))) + (is (= "(not (integer? \"foo\"))" (cs/stringify (s/check s/Int "foo")))) + (is (= {:foo "(not (integer? \"foo\"))"} (cs/stringify (s/check {:foo s/Int} {:foo "foo"}))))) + (testing "NamedError" + (is (= NamedError (class (s/check (s/named s/Int "name") "foo")))) + (is (= "(named (not (integer? \"foo\")) \"name\")" (cs/stringify (s/check (s/named s/Int "name") "foo"))))) + (testing "Schema" + (is (= {:total "(constrained Int pos?)"} (cs/stringify {:total (s/constrained s/Int pos?)}))))) + +(s/defschema Schema2 {:kikka s/Keyword}) + +(def valid-value {:kikka :kukka}) +(def invalid-value {:kikka "kukka"}) + +(deftest request-coercion-test + (let [c! #(coercion/coerce-request! Schema2 :body-params :body false false %)] + + (testing "default coercion" + (is (= valid-value (c! {:body-params valid-value}))) + (is (thrown? Exception (c! {:body-params invalid-value}))) + (try + (c! {:body-params invalid-value}) + (catch Exception e + (is (contains? (ex-data e) :errors)) + (is (= {:type :compojure.api.exception/request-validation + :coercion (coercion/resolve-coercion :schema) + :in [:request :body-params] + :schema Schema2 + :value invalid-value + :request {:body-params {:kikka "kukka"}}} + (select-keys (ex-data e) + [:type :coercion :in :schema :value :request])))))) + + (testing ":schema coercion" + (is (= valid-value (c! {:body-params valid-value + ::request/coercion :schema}))) + (is (thrown? Exception (c! {:body-params invalid-value + ::request/coercion :schema})))) + + (testing "format-based coercion" + (is (= valid-value + (c! {:body-params valid-value + :muuntaja/request {:format "application/json"}}))) + (is (= valid-value + (c! {:body-params invalid-value + :muuntaja/request {:format "application/json"}})))) + + (testing "no coercion" + (is (= valid-value + (c! {:body-params valid-value + ::request/coercion nil + :muuntaja/request {:format "application/json"}}))) + (is (= invalid-value + (c! {:body-params invalid-value + ::request/coercion nil + :muuntaja/request {:format "application/json"}})))))) + +(defn ok [body] + {:status 200, :body body}) + +(def responses {200 {:schema Schema2}}) + +(def custom-coercion + (cs/->SchemaCoercion + :custom + (-> cs/default-options + (assoc-in [:response :formats "application/json"] cs/json-coercion-matcher)))) + +(deftest response-coercion-test + (let [c! coercion/coerce-response! + request {}] + + (testing "default coercion" + (is (= (ok valid-value) + (select-keys (c! request (ok valid-value) responses) + [:status :body]))) + (is (thrown? Exception (c! request (ok invalid-value) responses))) + (try + (c! request (ok invalid-value) responses) + (catch Exception e + (is (contains? (ex-data e) :errors)) + (is (= {:type :compojure.api.exception/response-validation + :coercion (coercion/resolve-coercion :schema) + :in [:response :body] + :schema Schema2 + :value invalid-value + :request request} + (select-keys (ex-data e) + [:type :coercion :in :schema :value :request])))))) + + (testing ":schema coercion" + (testing "default coercion" + (is (= (ok valid-value) + (select-keys + (c! {::request/coercion :schema} + (ok valid-value) + responses) + [:status :body]))) + (is (thrown? Exception + (c! {::request/coercion :schema} + (ok invalid-value) + responses))))) + + (testing "format-based custom coercion" + (testing "request-negotiated response format" + (is (thrown? Exception + (c! {} + (ok invalid-value) + responses))) + (is (= (ok valid-value) + (select-keys + (c! {:muuntaja/response {:format "application/json"} + ::request/coercion custom-coercion} + (ok invalid-value) + responses) + [:status :body]))))) + + (testing "no coercion" + (is (= (ok valid-value) + (select-keys + (c! {::request/coercion nil} + (ok valid-value) + responses) + [:status :body]))) + (is (= (ok invalid-value) + (select-keys + (c! {::request/coercion nil} + (ok invalid-value) + responses) + [:status :body])))))) + +(s/defschema X s/Int) +(s/defschema Y s/Int) +(s/defschema Total (s/constrained s/Int pos? 'positive-int)) +(s/defschema Schema1 {:x X, :y Y}) + +(deftest apis-test + (let [app (api + {:formatter :muuntaja + :swagger {:spec "/swagger.json"} + :coercion :schema} + + (POST "/body" [] + :body [{:keys [x y]} Schema1] + (ok {:total (+ x y)})) + + (POST "/body-map" [] + :body [{:keys [x y]} {:x X, (s/optional-key :y) Y}] + (ok {:total (+ x (or y 0))})) + + (GET "/query" [] + :query [{:keys [x y]} Schema1] + (ok {:total (+ x y)})) + + (GET "/query-params" [] + :query-params [x :- X, y :- Y] + (ok {:total (+ x y)})) + + (POST "/body-params" [] + :body-params [x :- s/Int, {y :- Y 0}] + (ok {:total (+ x y)})) + + (POST "/body-string" [] + :body [body s/Str] + (ok {:body body})) + + (GET "/response" [] + :query-params [x :- X, y :- Y] + :return {:total Total} + (ok {:total (+ x y)})) + + (context "/resource" [] + (resource + {:get {:parameters {:query-params Schema1} + :responses {200 {:schema {:total Total}}} + :handler (fn [{{:keys [x y]} :query-params}] + (ok {:total (+ x y)}))} + :post {:parameters {:body-params {:x s/Int (s/optional-key :y) s/Int}} + :responses {200 {:schema {:total Total}}} + :handler (fn [{{:keys [x y]} :body-params}] + (ok {:total (+ x (or y 0))}))}})))] + + (testing "query" + (let [[status body] (get* app "/query" {:x "1", :y 2})] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (get* app "/query" {:x "1", :y "kaks"})] + (is (= 400 status)) + (is (= {:coercion "schema" + :in ["request" "query-params"] + :errors {:y "(not (integer? \"kaks\"))"} + :schema "{:x Int, :y Int}" + :type "compojure.api.exception/request-validation" + :value {:x "1", :y "kaks"}} + body)))) + + (testing "body" + (let [[status body] (post* app "/body" (json-string {:x 1, :y 2, #_#_:z 3}))] + (is (= 200 status)) + (is (= {:total 3} body)))) + + (testing "body-map" + (let [[status body] (post* app "/body-map" (json-string {:x 1, :y 2}))] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (post* app "/body-map" (json-string {:x 1}))] + (is (= 200 status)) + (is (= {:total 1} body)))) + + (testing "body-string" + (let [[status body] (post* app "/body-string" (json-string "kikka"))] + (is (= 200 status)) + (is (= {:body "kikka"} body)))) + + (testing "query-params" + (let [[status body] (get* app "/query-params" {:x "1", :y 2})] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (get* app "/query-params" {:x "1", :y "a"})] + (is (= 400 status)) + (is (= {:coercion "schema" + :in ["request" "query-params"]} + (select-keys body [:coercion :in]))))) + + (testing "body-params" + (let [[status body] (post* app "/body-params" (json-string {:x 1, :y 2}))] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (post* app "/body-params" (json-string {:x 1}))] + (is (= 200 status)) + (is (= {:total 1} body))) + (let [[status body] (post* app "/body-params" (json-string {:x "1"}))] + (is (= 400 status)) + (is (= {:coercion "schema" + :in ["request" "body-params"]} + (select-keys body [:coercion :in]))))) + + (testing "response" + (let [[status body] (get* app "/response" {:x 1, :y 2})] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (get* app "/response" {:x -1, :y -2})] + (is (= 500 status)) + (is (= {:coercion "schema" + :in ["response" "body"]} + (select-keys body [:coercion :in]))))) + + (testing "resource" + (testing "parameters as specs" + (let [[status body] (get* app "/resource" {:x 1, :y 2})] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (get* app "/resource" {:x -1, :y -2})] + (is (= 500 status)) + (is (= {:coercion "schema" + :in ["response" "body"]} + (select-keys body [:coercion :in]))))) + + (testing "parameters as data-specs" + (let [[status body] (post* app "/resource" (json-string {:x 1, :y 2}))] + (is (= 200 status)) + (is (= {:total 3} body))) + (let [[status body] (post* app "/resource" (json-string {:x 1}))] + (is (= 200 status)) + (is (= {:total 1} body))) + (let [[status body] (post* app "/resource" (json-string {:x -1, :y -2}))] + (is (= 500 status)) + (is (= {:coercion "schema" + :in ["response" "body"]} + (select-keys body [:coercion :in])))))) + + (testing "generates valid swagger spec" + (is (validator/validate app))))) + +(def some-spec (s/conditional + identity s/Int)) +(defmethod cs/coerce-response? some-spec [_] false) + +(deftest coerce-response-memory-leak-test + (is (true? (cs/coerce-response? some-spec)))) diff --git a/test/compojure/api/coercion_test.clj b/test/compojure/api/coercion_test.clj index 7a16f523..85dd469a 100644 --- a/test/compojure/api/coercion_test.clj +++ b/test/compojure/api/coercion_test.clj @@ -1,200 +1,246 @@ (ns compojure.api.coercion-test (:require [compojure.api.sweet :refer :all] [compojure.api.test-utils :refer :all] - [midje.sweet :refer :all] + [clojure.test :refer [deftest]] + [clojure.test :refer [deftest testing is]] [ring.util.http-response :refer :all] + [clojure.core.async :as a] [schema.core :as s] - [compojure.api.middleware :as mw])) - -(defn has-body [expected] - (fn [value] - (= (second value) expected))) - -(defn fails-with [expected-status] - (fn [[status body]] - (and (= status expected-status) (contains? body :errors)))) - -(fact "response schemas" - (let [r-200 (GET "/" [] - :query-params [{value :- s/Int nil}] - :responses {200 {:schema {:value s/Str}}} - (ok {:value (or value "123")})) - r-default (GET "/" [] - :query-params [{value :- s/Int nil}] - :responses {:default {:schema {:value s/Str}}} - (ok {:value (or value "123")})) - r-200-default (GET "/" [] - :query-params [{value :- s/Int nil}] - :responses {200 {:schema {:value s/Str}} - :default {:schema {:value s/Int}}} - (ok {:value (or value "123")}))] - (fact "200" - (get* (api r-200) "/") => (has-body {:value "123"}) - (get* (api r-200) "/" {:value 123}) => (fails-with 500)) - - (fact ":default" - (get* (api r-default) "/") => (has-body {:value "123"}) - (get* (api r-default) "/" {:value 123}) => (fails-with 500)) - - (fact ":default" - (get* (api r-200-default) "/") => (has-body {:value "123"}) - (get* (api r-200-default) "/" {:value 123}) => (fails-with 500)))) - -(fact "custom coercion" - - (fact "response coercion" - (let [ping-route (GET "/ping" [] - :return {:pong s/Str} - (ok {:pong 123}))] - - (fact "by default, applies response coercion" - (let [app (api - ping-route)] - (get* app "/ping") => (fails-with 500))) - - (fact "response-coercion can be disabled" - (fact "separately" + [compojure.api.coercion.schema :as cs])) + +(defn is-has-body [expected value] + (is (= (second value) expected))) + +(defn is-fails-with [expected-status [status body]] + (is (= status expected-status)) + (is (every? (partial contains? body) [:type :coercion :in :value :schema :errors]))) + +(deftest schema-coercion-test + (testing "response schemas" + (let [r-200 (GET "/" [] + :query-params [{value :- s/Int nil}] + :responses {200 {:schema {:value s/Str}}} + (ok {:value (or value "123")})) + r-default (GET "/" [] + :query-params [{value :- s/Int nil}] + :responses {:default {:schema {:value s/Str}}} + (ok {:value (or value "123")})) + r-200-default (GET "/" [] + :query-params [{value :- s/Int nil}] + :responses {200 {:schema {:value s/Str}} + :default {:schema {:value s/Int}}} + (ok {:value (or value "123")}))] + (testing "200" + (is-has-body {:value "123"} (get* (api {:formatter :muuntaja} r-200) "/")) + (is-fails-with 500 (get* (api {:formatter :muuntaja} r-200) "/" {:value 123}))) + + (testing "exception data" + (let [ex (get* (api {:formatter :muuntaja} r-200) "/" {:value 123})] + (is (= 500 (first ex))) + (is (= {:type "compojure.api.exception/response-validation" + :coercion "schema", + :in ["response" "body"], + :value {:value 123}, + :schema "{:value java.lang.String}", + :errors {:value "(not (instance? java.lang.String 123))"}} + (select-keys (second ex) [:type :coercion :in :value :schema :errors]))))) + + (testing ":default" + (is-has-body {:value "123"} (get* (api {:formatter :muuntaja} r-default) "/")) + (is-fails-with 500 (get* (api {:formatter :muuntaja} r-default) "/" {:value 123}))) + + (testing ":default" + (is-has-body {:value "123"} (get* (api {:formatter :muuntaja} r-200-default) "/")) + (is-fails-with 500 (get* (api {:formatter :muuntaja} r-200-default) "/" {:value 123}))))) + + (testing "custom coercion" + + (testing "response coercion" + (let [ping-route (GET "/ping" [] + :return {:pong s/Str} + (ok {:pong 123}))] + + (testing "by default, applies response coercion" (let [app (api - {:coercion mw/no-response-coercion} + {:formatter :muuntaja} ping-route)] - (let [[status body] (get* app "/ping")] - status => 200 - body => {:pong 123}))) - (fact "all coercion" + (is-fails-with 500 (get* app "/ping")))) + + (testing "response-coercion can be disabled" + (testing "separately" + (let [app (api + {:formatter :muuntaja + :coercion (cs/create-coercion (dissoc cs/default-options :response))} + ping-route)] + (let [[status body] (get* app "/ping")] + (is (= 200 status)) + (is (= {:pong 123} body))))) + (testing "all coercion" + (let [app (api + {:formatter :muuntaja + :coercion nil} + ping-route)] + (let [[status body] (get* app "/ping")] + (is (= 200 status)) + (is (= {:pong 123} body)))))) + + (testing "coercion for async handlers" + (binding [*async?* true] + (testing "successful" + (let [app (api + {:formatter :muuntaja} + (GET "/async" [] + :return s/Str + (a/go (ok "abc"))))] + (is-has-body "abc" (get* app "/async")))) + (testing "failing" + (let [app (api + {:formatter :muuntaja} + (GET "/async" [] + :return s/Int + (a/go (ok "foo"))))] + (is-fails-with 500 (get* app "/async")))))))) + + (testing "body coersion" + (let [beer-route (POST "/beer" [] + :body [body {:beers #{(s/enum "ipa" "apa")}}] + (ok body))] + + (testing "by default, applies body coercion (to set)" (let [app (api - {:coercion nil} - ping-route)] - (let [[status body] (get* app "/ping")] - status => 200 - body => {:pong 123})))))) - - (fact "body coersion" - (let [beer-route (POST "/beer" [] - :body [body {:beers #{(s/enum "ipa" "apa")}}] - (ok body))] - - (fact "by default, applies body coercion (to set)" - (let [app (api - beer-route)] - (let [[status body] (post* app "/beer" (json {:beers ["ipa" "apa" "ipa"]}))] - status => 200 - body => {:beers ["ipa" "apa"]}))) - - (fact "body-coercion can be disabled" - (let [no-body-coercion (constantly (dissoc mw/default-coercion-matchers :body)) - app (api - {:coercion no-body-coercion} - beer-route)] - (let [[status body] (post* app "/beer" (json {:beers ["ipa" "apa" "ipa"]}))] - status => 200 - body => {:beers ["ipa" "apa" "ipa"]})) - (let [app (api - {:coercion nil} - beer-route)] - (let [[status body] (post* app "/beer" (json {:beers ["ipa" "apa" "ipa"]}))] - status => 200 - body => {:beers ["ipa" "apa" "ipa"]}))) - - (fact "body-coercion can be changed" - (let [nop-body-coercion (constantly (assoc mw/default-coercion-matchers :body (constantly nil))) - app (api - {:coercion nop-body-coercion} - beer-route)] - (post* app "/beer" (json {:beers ["ipa" "apa" "ipa"]})) => (fails-with 400))))) - - (fact "query coercion" - (let [query-route (GET "/query" [] - :query-params [i :- s/Int] - (ok {:i i}))] - - (fact "by default, applies query coercion (string->int)" - (let [app (api - query-route)] - (let [[status body] (get* app "/query" {:i 10})] - status => 200 - body => {:i 10}))) - - (fact "query-coercion can be disabled" - (let [no-query-coercion (constantly (dissoc mw/default-coercion-matchers :string)) - app (api - {:coercion no-query-coercion} - query-route)] - (let [[status body] (get* app "/query" {:i 10})] - status => 200 - body => {:i "10"}))) - - (fact "query-coercion can be changed" - (let [nop-query-coercion (constantly (assoc mw/default-coercion-matchers :string (constantly nil))) - app (api - {:coercion nop-query-coercion} - query-route)] - (get* app "/query" {:i 10}) => (fails-with 400))))) - - (fact "route-specific coercion" - (let [app (api - (GET "/default" [] - :query-params [i :- s/Int] - (ok {:i i})) - (GET "/disabled-coercion" [] - :coercion (constantly (assoc mw/default-coercion-matchers :string (constantly nil))) - :query-params [i :- s/Int] - (ok {:i i})) - (GET "/no-coercion" [] - :coercion (constantly nil) - :query-params [i :- s/Int] - (ok {:i i})) - (GET "/nil-coercion" [] + {:formatter :muuntaja} + beer-route)] + (let [[status body] (post* app "/beer" (json-string {:beers ["ipa" "apa" "ipa"]}))] + (is (= 200 status)) + (is (= {:beers ["ipa" "apa"]} body))))) + + (testing "body-coercion can be disabled" + (let [no-body-coercion (cs/create-coercion (dissoc cs/default-options :body)) + app (api + {:formatter :muuntaja + :coercion no-body-coercion} + beer-route)] + (let [[status body] (post* app "/beer" (json-string {:beers ["ipa" "apa" "ipa"]}))] + (is (= 200 status)) + (is (= {:beers ["ipa" "apa" "ipa"]} body)))) + (let [app (api + {:formatter :muuntaja + :coercion nil} + beer-route)] + (let [[status body] (post* app "/beer" (json-string {:beers ["ipa" "apa" "ipa"]}))] + (is (= 200 status)) + (is (= {:beers ["ipa" "apa" "ipa"]} body))))) + + (testing "body-coercion can be changed" + (let [nop-body-coercion (cs/create-coercion (assoc cs/default-options :body {:default (constantly nil)})) + app (api + {:formatter :muuntaja + :coercion nop-body-coercion} + beer-route)] + (is-fails-with 400 (post* app "/beer" (json-string {:beers ["ipa" "apa" "ipa"]}))))))) + + (testing "query coercion" + (let [query-route (GET "/query" [] + :query-params [i :- s/Int] + (ok {:i i}))] + + (testing "by default, applies query coercion (string->int)" + (let [app (api + {:formatter :muuntaja} + query-route)] + (let [[status body] (get* app "/query" {:i 10})] + (is (= 200 status)) + (is (= {:i 10} body))))) + + (testing "query-coercion can be disabled" + (let [no-query-coercion (cs/create-coercion (dissoc cs/default-options :string)) + app (api + {:formatter :muuntaja + :coercion no-query-coercion} + query-route)] + (let [[status body] (get* app "/query" {:i 10})] + (is (= 200 status)) + (is (= {:i "10"} body))))) + + (testing "query-coercion can be changed" + (let [nop-query-coercion (cs/create-coercion (assoc cs/default-options :string {:default (constantly nil)})) + app (api + {:formatter :muuntaja + :coercion nop-query-coercion} + query-route)] + (is-fails-with 400 (get* app "/query" {:i 10})))))) + + (testing "route-specific coercion" + (let [app (api + {:formatter :muuntaja} + (GET "/default" [] + :query-params [i :- s/Int] + (ok {:i i})) + (GET "/disabled-coercion" [] + :coercion (cs/create-coercion (assoc cs/default-options :string {:default (constantly nil)})) + :query-params [i :- s/Int] + (ok {:i i})) + (GET "/no-coercion" [] + :coercion nil + :query-params [i :- s/Int] + (ok {:i i})))] + + (testing "default coercion" + (let [[status body] (get* app "/default" {:i 10})] + (is (= 200 status)) + (is (= {:i 10} body)))) + + (testing "disabled coercion" + (is-fails-with 400 (get* app "/disabled-coercion" {:i 10}))) + + (testing "exception data" + (let [ex (get* app "/disabled-coercion" {:i 10})] + (is (= 400 (first ex))) + (is (= {:type "compojure.api.exception/request-validation" + :coercion "schema", + :in ["request" "query-params"], + :value {:i "10"} + :schema "{Keyword Any, :i Int}", + :errors {:i "(not (integer? \"10\"))"}} + (select-keys (second ex) + [:type :coercion :in :value :schema :errors]))))) + + (testing "no coercion" + (let [[status body] (get* app "/no-coercion" {:i 10})] + (is (= 200 status)) + (is (= {:i "10"} body))))))) + + (testing "apiless coercion" + + (testing "use default-coercion-matchers by default" + (let [app (context "/api" [] + :query-params [{y :- Long 0}] + (GET "/ping" [] + :query-params [x :- Long] + (ok [x y])))] + (is (thrown? Exception (app {:request-method :get :uri "/api/ping" :query-params {}}))) + (is (thrown? Exception (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}}))) + (is (= [1 0] (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "1"}})))) + (is (= [1 2] (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y 2}})))) + (is (thrown? Exception (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y "abba"}}))))) + + (testing "coercion can be overridden" + (let [app (context "/api" [] + :query-params [{y :- Long 0}] + (GET "/ping" [] + :coercion nil + :query-params [x :- Long] + (ok [x y])))] + (is (thrown? Exception (app {:request-method :get :uri "/api/ping" :query-params {}}))) + (is (= ["abba" 0] (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}})))) + (is (= ["1" 0] (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "1"}})))) + (is (= ["1" 2] (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y 2}})))) + (is (thrown? Exception (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y "abba"}}))))) + + (testing "context coercion is used for subroutes" + (let [app (context "/api" [] :coercion nil - :query-params [i :- s/Int] - (ok {:i i})))] - - (fact "default coercion" - (let [[status body] (get* app "/default" {:i 10})] - status => 200 - body => {:i 10})) - - (fact "disabled coercion" - (get* app "/disabled-coercion" {:i 10}) => (fails-with 400)) - - (fact "no coercion" - (let [[status body] (get* app "/no-coercion" {:i 10})] - status => 200 - body => {:i "10"}) - (let [[status body] (get* app "/nil-coercion" {:i 10})] - status => 200 - body => {:i "10"}))))) - -(facts "apiless coercion" - - (fact "use default-coercion-matchers by default" - (let [app (context "/api" [] - :query-params [{y :- Long 0}] - (GET "/ping" [] - :query-params [x :- Long] - (ok [x y])))] - (app {:request-method :get :uri "/api/ping" :query-params {}}) => throws - (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}}) => throws - (app {:request-method :get :uri "/api/ping" :query-params {:x "1"}}) => (contains {:body [1 0]}) - (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y 2}}) => (contains {:body [1 2]}) - (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y "abba"}}) => throws)) - - (fact "coercion can be overridden" - (let [app (context "/api" [] - :query-params [{y :- Long 0}] - (GET "/ping" [] - :coercion (constantly nil) - :query-params [x :- Long] - (ok [x y])))] - (app {:request-method :get :uri "/api/ping" :query-params {}}) => throws - (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}}) => (contains {:body ["abba" 0]}) - (app {:request-method :get :uri "/api/ping" :query-params {:x "1"}}) => (contains {:body ["1" 0]}) - (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y 2}}) => (contains {:body ["1" 2]}) - (app {:request-method :get :uri "/api/ping" :query-params {:x "1", :y "abba"}}) => throws)) - - (fact "context coercion is used for subroutes" - (let [app (context "/api" [] - :coercion nil - (GET "/ping" [] - :query-params [x :- Long] - (ok x)))] - (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}}) => (contains {:body "abba"})))) + (GET "/ping" [] + :query-params [x :- Long] + (ok x)))] + (is (= "abba" (:body (app {:request-method :get :uri "/api/ping" :query-params {:x "abba"}})))))))) diff --git a/test/compojure/api/common_test.clj b/test/compojure/api/common_test.clj index 04a1a411..58a5de28 100644 --- a/test/compojure/api/common_test.clj +++ b/test/compojure/api/common_test.clj @@ -1,28 +1,50 @@ (ns compojure.api.common-test (:require [compojure.api.common :as common] - [midje.sweet :refer :all])) - -(fact "group-with" - (common/group-with pos? [1 -10 2 -4 -1 999]) => [[1 2 999] [-10 -4 -1]] - (common/group-with pos? [1 2 999]) => [[1 2 999] nil]) - -(fact "extract-parameters" - - (facts "expect body" - (common/extract-parameters [] true) => [{} nil] - (common/extract-parameters [{:a 1}] true) => [{} [{:a 1}]] - (common/extract-parameters [:a 1] true) => [{:a 1} nil] - (common/extract-parameters [{:a 1} {:b 2}] true) => [{:a 1} [{:b 2}]] - (common/extract-parameters [:a 1 {:b 2}] true) => [{:a 1} [{:b 2}]]) - - (facts "don't expect body" - (common/extract-parameters [] false) => [{} nil] - (common/extract-parameters [{:a 1}] false) => [{:a 1} nil] - (common/extract-parameters [:a 1] false) => [{:a 1} nil] - (common/extract-parameters [{:a 1} {:b 2}] false) => [{:a 1} [{:b 2}]] - (common/extract-parameters [:a 1 {:b 2}] false) => [{:a 1} [{:b 2}]])) - -(fact "merge-vector" - (common/merge-vector nil) => nil - (common/merge-vector [{:a 1}]) => {:a 1} - (common/merge-vector [{:a 1} {:b 2}]) => {:a 1 :b 2}) + [clojure.test :refer [deftest testing is]] + [criterium.core :as cc])) + +(deftest group-with-test + (is (= (common/group-with pos? [1 -10 2 -4 -1 999]) [[1 2 999] [-10 -4 -1]])) + (is (= (common/group-with pos? [1 2 999]) [[1 2 999] nil]))) + +(deftest extract-parameters-test + + (testing "expect body" + (is (= (common/extract-parameters [] true) [{} nil])) + (is (= (common/extract-parameters [{:a 1}] true) [{} [{:a 1}]])) + (is (= (common/extract-parameters [:a 1] true) [{:a 1} nil])) + (is (= (common/extract-parameters [{:a 1} {:b 2}] true) [{:a 1} [{:b 2}]])) + (is (= (common/extract-parameters [:a 1 {:b 2}] true) [{:a 1} [{:b 2}]]))) + + (testing "don't expect body" + (is (= (common/extract-parameters [] false) [{} nil])) + (is (= (common/extract-parameters [{:a 1}] false) [{:a 1} nil])) + (is (= (common/extract-parameters [:a 1] false) [{:a 1} nil])) + (is (= (common/extract-parameters [{:a 1} {:b 2}] false) [{:a 1} [{:b 2}]])) + (is (= (common/extract-parameters [:a 1 {:b 2}] false) [{:a 1} [{:b 2}]])))) + +(deftest merge-vector-test + (is (= (common/merge-vector nil) nil)) + (is (= (common/merge-vector [{:a 1}]) {:a 1})) + (is (= (common/merge-vector [{:a 1} {:b 2}]) {:a 1 :b 2}))) + +(deftest fast-merge-map-test + (let [x {:a 1, :b 2, :c 3} + y {:a 2, :d 4, :e 5}] + (is (= (common/fast-map-merge x y) {:a 2, :b 2, :c 3 :d 4, :e 5})) + (is (= (common/fast-map-merge x y) (merge x y))))) + +(comment + (require '[criterium.core :as cc]) + + ;; 163ns + (cc/quick-bench + (common/fast-map-merge + {:a 1, :b 2, :c 3} + {:a 2, :d 4, :e 5})) + + ;; 341ns + (cc/quick-bench + (merge + {:a 1, :b 2, :c 3} + {:a 2, :d 4, :e 5}))) diff --git a/test/compojure/api/compojure_perf_test.clj b/test/compojure/api/compojure_perf_test.clj index 06fa9475..666fb506 100644 --- a/test/compojure/api/compojure_perf_test.clj +++ b/test/compojure/api/compojure_perf_test.clj @@ -29,6 +29,8 @@ (defn compojure-bench [] + ;; 3.8µs + ;; 2.6µs (let [app (c/routes (c/GET "/a/b/c/1" [] "ok") (c/GET "/a/b/c/2" [] "ok") @@ -42,8 +44,8 @@ (assert (-> (call) :body (= "ok"))) (cc/quick-bench (call))) - ;; 3.8µs - + ;; 15.9µs + ;; 11.6µs (let [app (c/context "/a" [] (c/context "/b" [] (c/context "/c" [] @@ -67,14 +69,12 @@ (title "Compojure - GET with context") (assert (-> (call) :body (= "ok"))) - (cc/quick-bench (call))) - - ;; 15.9µs - - ) + (cc/quick-bench (call)))) (defn compojure-api-bench [] + ;; 3.8µs + ;; 2.7µs (let [app (s/routes (s/GET "/a/b/c/1" [] "ok") (s/GET "/a/b/c/2" [] "ok") @@ -88,8 +88,9 @@ (assert (-> (call) :body (= "ok"))) (cc/quick-bench (call))) - ;; 3.8µs - + ;; 20.0µs + ;; 17.0µs + ;; 11.4µs static-context (-30%) (let [app (s/context "/a" [] (s/context "/b" [] (s/context "/c" [] @@ -113,14 +114,49 @@ (title "Compojure API - GET with context") (assert (-> (call) :body (= "ok"))) - (cc/quick-bench (call))) + (cc/quick-bench (call)))) - ;; 20.0µs - ) +(defn compojure-api-mw-bench [] + + ;; 47.0µs (15 + 3906408 calls) + ;; 10.9µs (77 + 0 calls) - static-context (-75%) + (let [calls (atom nil) + mw (fn [handler x] (swap! calls update x (fnil inc 0)) (fn [req] (handler req))) + app (s/context "/a" [] + :middleware [[mw :a]] + (s/context "/b" [] + :middleware [[mw :b]] + (s/context "/c" [] + :middleware [[mw :c]] + (s/GET "/1" [] :middleware [[mw :c1]] "ok") + (s/GET "/2" [] :middleware [[mw :c2]] "ok") + (s/GET "/3" [] :middleware [[mw :c3]] "ok") + (s/GET "/4" [] :middleware [[mw :c4]] "ok") + (s/GET "/5" [] :middleware [[mw :c5]] "ok")) + (s/GET "/1" [] :middleware [[mw :b1]] "ok") + (s/GET "/2" [] :middleware [[mw :b2]] "ok") + (s/GET "/3" [] :middleware [[mw :b3]] "ok") + (s/GET "/4" [] :middleware [[mw :b4]] "ok") + (s/GET "/5" [] :middleware [[mw :b5]] "ok")) + (s/GET "/1" [] :middleware [[mw :a1]] "ok") + (s/GET "/2" [] :middleware [[mw :a2]] "ok") + (s/GET "/3" [] :middleware [[mw :a3]] "ok") + (s/GET "/4" [] :middleware [[mw :a4]] "ok") + (s/GET "/5" [] :middleware [[mw :a5]] "ok")) + + call #(app {:request-method :get :uri "/a/b/c/5"})] + + (clojure.pprint/pprint {:calls @calls, :total (->> @calls vals (reduce +))}) + (reset! calls nil) + (title "Compojure API - GET with context with middleware") + (assert (-> (call) :body (= "ok"))) + (cc/quick-bench (call)) + (clojure.pprint/pprint {:calls @calls, :total (->> @calls vals (reduce +))}))) (defn bench [] (compojure-bench) - (compojure-api-bench)) + (compojure-api-bench) + (compojure-api-mw-bench)) (comment (bench)) diff --git a/test/compojure/api/exception_test.clj b/test/compojure/api/exception_test.clj deleted file mode 100644 index 1432ddc4..00000000 --- a/test/compojure/api/exception_test.clj +++ /dev/null @@ -1,14 +0,0 @@ -(ns compojure.api.exception-test - (:require [compojure.api.exception :refer :all] - [midje.sweet :refer :all] - [schema.core :as s]) - (:import [schema.utils ValidationError NamedError])) - -(fact "stringify-error" - (fact "ValidationError" - (class (s/check s/Int "foo")) => ValidationError - (stringify-error (s/check s/Int "foo")) => "(not (integer? \"foo\"))" - (stringify-error (s/check {:foo s/Int} {:foo "foo"})) => {:foo "(not (integer? \"foo\"))"}) - (fact "NamedError" - (class (s/check (s/named s/Int "name") "foo")) => NamedError - (stringify-error (s/check (s/named s/Int "name") "foo")) => "(named (not (integer? \"foo\")) \"name\")")) diff --git a/test/compojure/api/help_test.clj b/test/compojure/api/help_test.clj new file mode 100644 index 00000000..b7e72a1f --- /dev/null +++ b/test/compojure/api/help_test.clj @@ -0,0 +1,14 @@ +(ns compojure.api.help-test + (:require [compojure.api.help :as help] + [compojure.api.meta :as meta] + [clojure.test :refer [deftest is testing]])) + +(deftest help-for-api-meta-test + (testing "all restructure-param methods have a help text" + (let [restructure-method-names (-> meta/restructure-param methods keys) + meta-help-topics (-> (methods help/help-for) + (dissoc ::help/default) + keys + (->> (filter #(= :meta (first %))) + (map second)))] + (is (= (set restructure-method-names) (set meta-help-topics)))))) diff --git a/test/compojure/api/integration_test.clj b/test/compojure/api/integration_test.clj index 1c907ded..29c46384 100644 --- a/test/compojure/api/integration_test.clj +++ b/test/compojure/api/integration_test.clj @@ -1,27 +1,36 @@ (ns compojure.api.integration-test (:require [compojure.api.sweet :refer :all] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [compojure.api.test-domain :refer [Pizza burger-routes]] [compojure.api.test-utils :refer :all] [compojure.api.exception :as ex] [compojure.api.swagger :as swagger] - [midje.sweet :refer :all] [ring.util.http-response :refer :all] + [ring.util.http-predicates :as http] [schema.core :as s] [ring.swagger.core :as rsc] [ring.util.http-status :as status] [compojure.api.middleware :as mw] [ring.swagger.middleware :as rsm] [compojure.api.validator :as validator] + [compojure.api.request :as request] [compojure.api.routes :as routes] - - [ring.middleware.format-response :as format-response] - [cheshire.core :as json])) + [muuntaja.core :as m] + [compojure.api.core :as c] + [clojure.java.io :as io] + [muuntaja.format.msgpack] + [muuntaja.format.yaml]) + (:import (java.sql SQLException SQLWarning) + (muuntaja.protocols StreamableResponse) + (java.io File ByteArrayInputStream))) ;; ;; Data ;; -(s/defschema User {:id Long - :name String}) +(s/defschema User {:id s/Int + :name s/Str}) (def pertti {:id 1 :name "Pertti"}) @@ -38,20 +47,30 @@ (def mw* "mw") +(defn is-200-status [status] + (is (= 200 status))) + (defn middleware* "This middleware appends given value or 1 to a header in request and response." ([handler] (middleware* handler 1)) ([handler value] - (fn [request] - (let [append #(str % value) - request (update-in request [:headers mw*] append) - response (handler request)] - (update-in response [:headers mw*] append))))) + (fn + ([request] + (let [append #(str % value) + request (update-in request [:headers mw*] append) + response (handler request)] + (update-in response [:headers mw*] append))) + ([request respond raise] + (let [append #(str % value) + request (update-in request [:headers mw*] append)] + (handler request #(respond (update-in % [:headers mw*] append)) raise)))))) (defn constant-middleware "This middleware rewrites all responses with a constant response." [_ res] - (constantly res)) + (fn + ([_] res) + ([_ respond _] (respond res)))) (defn reply-mw* "Handler which replies with response where a header contains copy @@ -64,8 +83,11 @@ "If request has query-param x, presume it's a integer and multiply it by two before passing request to next handler." [handler] - (fn [req] - (handler (update-in req [:query-params "x"] #(* (Integer. %) 2))))) + (fn + ([req] + (handler (update-in req [:query-params "x"] #(* (Integer. %) 2)))) + ([req respond raise] + (handler (update-in req [:query-params "x"] #(* (Integer. %) 2)) respond raise)))) (defn custom-validation-error-handler [ex data request] (let [error-body {:custom-error (:uri request)}] @@ -73,8 +95,9 @@ ::ex/response-validation (not-implemented error-body) (bad-request error-body)))) -(defn custom-exception-handler [^Exception ex data request] - (ok {:custom-exception (str ex)})) +(defn custom-exception-handler [key] + (fn [^Exception ex data request] + (ok {key (str ex)}))) (defn custom-error-handler [ex data request] (ok {:custom-error (:data data)})) @@ -83,54 +106,71 @@ ;; Facts ;; -(facts "core routes" +(deftest core-routes-test - (fact "keyword options" + (testing "keyword options" (let [route (GET "/ping" [] :return String (ok "kikka"))] - (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"}))) + (is (= "kikka" (:body (route {:request-method :get :uri "/ping"})))))) - (fact "map options" + (testing "map options" (let [route (GET "/ping" [] {:return String} (ok "kikka"))] - (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"}))) + (is (= "kikka" (:body (route {:request-method :get :uri "/ping"})))))) - (fact "map return" + (testing "map return" (let [route (GET "/ping" [] {:body "kikka"})] - (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"})))) + (is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))))) -(facts "middleware ordering" +(deftest middleware-ordering-test (let [app (api - (middleware [middleware* [middleware* 2]] + {:formatter :muuntaja + :middleware [[middleware* 0]]} + (route-middleware [[middleware* "a"] [middleware* "b"]] (context "/middlewares" [] - :middleware [(fn [handler] (middleware* handler 3)) [middleware* 4]] + :middleware [(fn [handler] (middleware* handler 1)) [middleware* 2]] (GET "/simple" req (reply-mw* req)) - (middleware [#(middleware* % 5) [middleware* 6]] + (route-middleware [#(middleware* % "c") [middleware* "d"]] (GET "/nested" req (reply-mw* req)) (GET "/nested-declared" req - :middleware [(fn [handler] (middleware* handler 7)) [middleware* 8]] + :middleware [(fn [handler] (middleware* handler "e")) [middleware* "f"]] (reply-mw* req))))))] - (fact "are applied left-to-right" + (testing "are applied left-to-right" (let [[status _ headers] (get* app "/middlewares/simple" {})] - status => 200 - (get headers mw*) => "1234/4321")) + (is (= 200 status)) + (is (= "012ab/ba210" (get headers mw*))))) - (fact "are applied left-to-right closest one first" + (testing "are applied left-to-right closest one first" (let [[status _ headers] (get* app "/middlewares/nested" {})] - status => 200 - (get headers mw*) => "123456/654321")) + (is (= 200 status)) + (is (= "012abcd/dcba210" (get headers mw*))))) - (fact "are applied left-to-right for both nested & declared closest one first" + (testing "are applied left-to-right for both nested & declared closest one first" (let [[status _ headers] (get* app "/middlewares/nested-declared" {})] - status => 200 - (get headers mw*) => "12345678/87654321")))) + (is (= 200 status)) + (is (= "012abcdef/fedcba210" (get headers mw*))))))) -(facts "middleware - multiple routes" +(deftest context-middleware-test + (let [app (api + {:formatter :muuntaja} + (context "/middlewares" [] + :middleware [(fn [h] (fn mw + ([r] (ok {:middleware "hello"})) + ([r respond _] (respond (mw r)))))] + (GET "/simple" req (reply-mw* req))))] + + (testing "is applied even if route is not matched" + (let [[status body] (get* app "/middlewares/non-existing" {})] + (is (= 200 status)) + (is (= {:middleware "hello"} body)))))) + +(deftest middleware-multiple-routes-test (let [app (api + {:formatter :muuntaja} (GET "/first" [] (ok {:value "first"})) (GET "/second" [] @@ -138,32 +178,34 @@ (ok {:value "second"})) (GET "/third" [] (ok {:value "third"})))] - (fact "first returns first" + (testing "first returns first" (let [[status body] (get* app "/first" {})] - status => 200 - body => {:value "first"})) - (fact "second returns foo" + (is (= 200 status)) + (is (= {:value "first"} body)))) + (testing "second returns foo" (let [[status body] (get* app "/second" {})] - status => 200 - body => {:value "foo"})) - (fact "third returns third" + (is-200-status status) + (is (= {:value "foo"} body)))) + (testing "third returns third" (let [[status body] (get* app "/third" {})] - status => 200 - body => {:value "third"})))) + (is-200-status status) + (is (= {:value "third"} body)))))) -(facts "middleware - editing request" +(deftest middleware-editing-request-test (let [app (api + {:formatter :muuntaja} (GET "/first" [] :query-params [x :- Long] :middleware [middleware-x] (ok {:value x})))] - (fact "middleware edits the parameter before route body" + (testing "middleware edits the parameter before route body" (let [[status body] (get* app "/first?x=5" {})] - status => 200 - body => {:value 10})))) + (is-200-status status) + (is (= {:value 10} body)))))) -(fact ":body, :query, :headers and :return" +(deftest body-query-headers-and-return-test (let [app (api + {:formatter :muuntaja} (context "/models" [] (GET "/pertti" [] :return User @@ -197,58 +239,61 @@ :return User (ok user))))] - (fact "GET" + (testing "GET" (let [[status body] (get* app "/models/pertti")] - status => 200 - body => pertti)) + (is-200-status status) + (is (= pertti body)))) - (fact "GET with smart destructuring" + (testing "GET with smart destructuring" (let [[status body] (get* app "/models/user" pertti)] - status => 200 - body => pertti)) - - (fact "POST with smart destructuring" - (let [[status body] (post* app "/models/user" (json pertti))] - status => 200 - body => pertti)) - - (fact "POST with smart destructuring - lists" - (let [[status body] (post* app "/models/user_list" (json [pertti]))] - status => 200 - body => [pertti])) - - (fact "POST with smart destructuring - sets" - (let [[status body] (post* app "/models/user_set" (json #{pertti}))] - status => 200 - body => [pertti])) - - (fact "POST with compojure destructuring" - (let [[status body] (post* app "/models/user_legacy" (json pertti))] - status => 200 - body => pertti)) - - (fact "POST with smart destructuring - headers" + (is-200-status status) + (is (= pertti body)))) + + (testing "POST with smart destructuring" + (let [[status body] (post* app "/models/user" (json-string pertti))] + (is-200-status status) + (is (= pertti body)))) + + (testing "POST with smart destructuring - lists" + (let [[status body] (post* app "/models/user_list" (json-string [pertti]))] + (is-200-status status) + (is (= [pertti] body)))) + + (testing "POST with smart destructuring - sets" + (let [[status body] (post* app "/models/user_set" (json-string #{pertti}))] + (is-200-status status) + (is (= [pertti] body)))) + + (testing "POST with compojure destructuring" + (let [[status body] (post* app "/models/user_legacy" (json-string pertti))] + (is-200-status status) + (is (= pertti body)))) + + (testing "POST with smart destructuring - headers" (let [[status body] (headers-post* app "/models/user_headers" pertti)] - status => 200 - body => pertti)) + (is-200-status status) + (is (= pertti body)))) - (fact "Validation of returned data" + (testing "Validation of returned data" (let [[status] (get* app "/models/invalid-user")] - status => 500)) + (is (= 500 status)))) - (fact "Routes without a :return parameter aren't validated" + (testing "Routes without a :return parameter aren't validated" (let [[status body] (get* app "/models/not-validated")] - status => 200 - body => invalid-user)) + (is-200-status status) + (is (= invalid-user body)))) - (fact "Invalid json in body causes 400 with error message in json" + (testing "Invalid json in body causes 400 with error message in json" (let [[status body] (post* app "/models/user" "{INVALID}")] - status => 400 - (:message body) => (contains "Unexpected character"))))) + (is (= 400 status)) + (is (= "compojure.api.exception/request-parsing" (:type body))) + (is (str/starts-with? (:message body) "Malformed application/json")) + (is (str/starts-with? (:original body) "Unexpected character")))))) -(fact ":responses" - (fact "normal cases" +(deftest responses-test + (testing "normal cases" (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/lotto/:x" [] :path-params [x :- Long] @@ -262,36 +307,37 @@ 4 (forbidden [1]) (not-found {:message "not-found"}))))] - (fact "return case" + (testing "return case" (let [[status body] (get* app "/lotto/1")] - status => 200 - body => [1])) + (is-200-status status) + (is (= [1] body)))) - (fact "return case, non-matching model" + (testing "return case, non-matching model" (let [[status body] (get* app "/lotto/2")] - status => 500 - body => (contains {:errors vector?}))) + (is (= 500 status)) + (is (vector? (:errors body))))) - (fact "error case" + (testing "error case" (let [[status body] (get* app "/lotto/3")] - status => 403 - body => ["error"])) + (is (= 403 status)) + (is (= ["error"] body)))) - (fact "error case, non-matching model" + (testing "error case, non-matching model" (let [[status body] (get* app "/lotto/4")] - status => 500 - body => (contains {:errors vector?}))) + (is (= 500 status)) + (is (-> body :errors vector?)))) - (fact "returning non-predefined http-status code works" + (testing "returning non-predefined http-status code works" (let [[status body] (get* app "/lotto/5")] - body => {:message "not-found"} - status => 404)) + (is (= {:message "not-found"} body)) + (is (= 404 status)))) - (fact "swagger-docs for multiple returns" + (testing "swagger-docs for multiple returns" (-> app get-spec :paths vals first :get :responses keys set)))) - (fact ":responses 200 and :return" + (testing ":responses 200 and :return" (let [app (api + {:formatter :muuntaja} (GET "/lotto/:x" [] :path-params [x :- Long] :return {:return String} @@ -300,19 +346,21 @@ 1 (ok {:return "ok"}) 2 (ok {:value "ok"}))))] - (fact "return case" + (testing "return case" (let [[status body] (get* app "/lotto/1")] - status => 500 - body => (contains {:errors {:return "disallowed-key" - :value "missing-required-key"}}))) + (is (= 500 status)) + (is (= {:return "disallowed-key" + :value "missing-required-key"} + (:errors body))))) - (fact "return case" + (testing "return case" (let [[status body] (get* app "/lotto/2")] - status => 200 - body => {:value "ok"})))) + (is-200-status status) + (is (= {:value "ok"} body)))))) - (fact ":responses 200 and :return - other way around" + (testing ":responses 200 and :return - other way around" (let [app (api + {:formatter :muuntaja} (GET "/lotto/:x" [] :path-params [x :- Long] :responses {200 {:schema {:value String}}} @@ -321,19 +369,21 @@ 1 (ok {:return "ok"}) 2 (ok {:value "ok"}))))] - (fact "return case" + (testing "return case" (let [[status body] (get* app "/lotto/1")] - status => 200 - body => {:return "ok"})) + (is-200-status status) + (is (= {:return "ok"} body)))) - (fact "return case" + (testing "return case" (let [[status body] (get* app "/lotto/2")] - status => 500 - body => (contains {:errors {:return "missing-required-key" - :value "disallowed-key"}})))))) + (is (= 500 status)) + (is (= {:return "missing-required-key" + :value "disallowed-key"} + (:errors body)))))))) -(fact ":query-params, :path-params, :header-params , :body-params and :form-params" +(deftest query-params-path-params-header-params-body-params-and-form-params-test (let [app (api + {:formatter :muuntaja} (context "/smart" [] (GET "/plus" [] :query-params [x :- Long y :- Long] @@ -351,39 +401,40 @@ :form-params [x :- Long y :- Long] (ok {:total (/ x y)}))))] - (fact "query-parameters" + (testing "query-parameters" (let [[status body] (get* app "/smart/plus" {:x 2 :y 3})] - status => 200 - body => {:total 5})) + (is-200-status status) + (is (= {:total 5} body)))) - (fact "path-parameters" + (testing "path-parameters" (let [[status body] (get* app "/smart/multiply/2/3")] - status => 200 - body => {:total 6})) + (is-200-status status) + (is (= {:total 6} body)))) - (fact "header-parameters" + (testing "header-parameters" (let [[status body] (get* app "/smart/power" {} {:x 2 :y 3})] - status => 200 - body => {:total 8})) + (is-200-status status) + (is (= {:total 8} body)))) - (fact "form-parameters" + (testing "form-parameters" (let [[status body] (form-post* app "/smart/divide" {:x 6 :y 3})] - status => 200 - body => {:total 2})) + (is-200-status status) + (is (= {:total 2} body)))) - (fact "body-parameters" - (let [[status body] (post* app "/smart/minus" (json {:x 2 :y 3}))] - status => 200 - body => {:total -1})) + (testing "body-parameters" + (let [[status body] (post* app "/smart/minus" (json-string {:x 2 :y 3}))] + (is-200-status status) + (is (= {:total -1} body)))) - (fact "default parameters" - (let [[status body] (post* app "/smart/minus" (json {:x 2}))] - status => 200 - body => {:total 1})))) + (testing "default parameters" + (let [[status body] (post* app "/smart/minus" (json-string {:x 2}))] + (is-200-status status) + (is (= {:total 1} body)))))) -(fact "primitive support" +(deftest primitive-support-test (let [app (api - {:swagger {:spec "/swagger.json"}} + {:formatter :muuntaja + :swagger {:spec "/swagger.json"}} (context "/primitives" [] (GET "/return-long" [] :return Long @@ -398,47 +449,48 @@ :body [longs [Long]] (ok longs))))] - (fact "when :return is set, longs can be returned" + (testing "when :return is set, longs can be returned" (let [[status body] (raw-get* app "/primitives/return-long")] - status => 200 - body => "1")) + (is-200-status status) + (is (= "1" body)))) - (fact "when :return is not set, longs won't be encoded" + (testing "when :return is not set, longs won't be encoded" (let [[status body] (raw-get* app "/primitives/long")] - status => 200 - body => number?)) + (is-200-status status) + (is (number? body)))) - (fact "when :return is set, raw strings can be returned" + (testing "when :return is set, raw strings can be returned" (let [[status body] (raw-get* app "/primitives/return-string")] - status => 200 - body => "\"kikka\"")) + (is-200-status status) + (is (= "\"kikka\"" body)))) - (fact "primitive arrays work" - (let [[status body] (raw-post* app "/primitives/arrays" (json/generate-string [1 2 3]))] - status => 200 - body => "[1,2,3]")) + (testing "primitive arrays work" + (let [[status body] (raw-post* app "/primitives/arrays" (json-string [1 2 3]))] + (is-200-status status) + (is (= "[1,2,3]" body)))) - (fact "swagger-spec is valid" + (testing "swagger-spec is valid" (validator/validate app)) - (fact "primitive array swagger-docs are good" + (testing "primitive array swagger-docs are good" - (-> app get-spec :paths (get "/primitives/arrays") :post :parameters) - => [{:description "" - :in "body" - :name "" - :required true - :schema {:items {:format "int64" - :type "integer"} - :type "array"}}] + (is (= [{:description "" + :in "body" + :name "" + :required true + :schema {:items {:format "int64" + :type "integer"} + :type "array"}}] + (-> app get-spec :paths (get "/primitives/arrays") :post :parameters))) - (-> app get-spec :paths (get "/primitives/arrays") :post :responses :200 :schema) - => {:items {:format "int64", - :type "integer"}, - :type "array"}))) + (is (= {:items {:format "int64", + :type "integer"}, + :type "array"} + (-> app get-spec :paths (get "/primitives/arrays") :post :responses :200 :schema)))))) -(fact "compojure destructuring support" +(deftest compojure-destructuring-support-test (let [app (api + {:formatter :muuntaja} (context "/destructuring" [] (GET "/regular" {{:keys [a]} :params} (ok {:a a @@ -455,155 +507,163 @@ (GET "/symbol" req (ok {:a (-> req :params :a) :b (-> +compojure-api-request+ :params :b)})) - (GET "/integrated" [a] :query-params [b] + (GET "/integrated" [a] + :query-params [b] (ok {:a a :b b}))))] (doseq [uri ["regular" "regular2" "vector" "vector2" "symbol" "integrated"]] - (fact {:midje/description uri} + (testing uri (let [[status body] (get* app (str "/destructuring/" uri) {:a "a" :b "b"})] - status => 200 - body => {:a "a" :b "b"}))))) + (is-200-status status) + (is (= {:a "a" :b "b"} body))))))) -(fact "counting execution times, issue #19" +(deftest counting-execution-times-issue-19-test (let [execution-times (atom 0) app (api + {:formatter :muuntaja} (GET "/user" [] :return User :query [user User] (swap! execution-times inc) (ok user)))] - (fact "body is executed one" - @execution-times => 0 + (testing "body is executed one" + (is (zero? @execution-times)) (let [[status body] (get* app "/user" pertti)] - status => 200 - body => pertti) - @execution-times => 1))) + (is-200-status status) + (is (= pertti body))) + (is (= 1 @execution-times))))) -(fact "swagger-docs" +(deftest swagger-docs-test (let [app (api - {:format {:formats [:json-kw :edn :UNKNOWN]}} + {:formats (m/select-formats + m/default-options + ["application/json" "application/edn"])} (swagger-routes) (GET "/user" [] (continue)))] - (fact "api-listing shows produces & consumes for known types" - (get-spec app) => {:swagger "2.0" - :info {:title "Swagger API" - :version "0.0.1"} - :basePath "/" - :consumes ["application/json" "application/edn"] - :produces ["application/json" "application/edn"] - :definitions {} - :paths {"/user" {:get {:responses {:default {:description ""}}}}}})) + (testing "api-listing shows produces & consumes for known types" + (is (= {:swagger "2.0" + :info {:title "Swagger API" + :version "0.0.1"} + :basePath "/" + :consumes ["application/json" "application/edn"] + :produces ["application/json" "application/edn"] + :definitions {} + :paths {"/user" {:get {:responses {:default {:description ""}}}}}} + (get-spec app))))) - (fact "swagger-routes" + (testing "swagger-routes" - (fact "with defaults" - (let [app (api (swagger-routes))] + (testing "with defaults" + (let [app (api {:formatter :muuntaja} (swagger-routes))] - (fact "api-docs are mounted to /" + (testing "api-docs are mounted to /" (let [[status body] (raw-get* app "/")] - status => 200 - body => #"Swagger UI")) + (is-200-status status) + (is (str/includes? body "Swagger UI")))) - (fact "spec is mounted to /swagger.json" + (testing "spec is mounted to /swagger.json" (let [[status body] (get* app "/swagger.json")] - status => 200 - body => (contains {:swagger "2.0"}))))) + (is-200-status status) + (is (= "2.0" (:swagger body))))))) - (fact "with partial overridden values" - (let [app (api (swagger-routes {:ui "/api-docs" - :data {:info {:title "Kikka"} - :paths {"/ping" {:get {}}}}}))] + (testing "with partial overridden values" + (let [app (api + {:formatter :muuntaja} + (swagger-routes {:ui "/api-docs" + :data {:info {:title "Kikka"} + :paths {"/ping" {:get {}}}}}))] - (fact "api-docs are mounted" + (testing "api-docs are mounted" (let [[status body] (raw-get* app "/api-docs")] - status => 200 - body => #"Swagger UI")) + (is-200-status status) + (is (str/includes? body "Swagger UI")))) - (fact "spec is mounted to /swagger.json" + (testing "spec is mounted to /swagger.json" (let [[status body] (get* app "/swagger.json")] - status => 200 - body => (contains - {:swagger "2.0" - :info (contains - {:title "Kikka"}) - :paths (contains - {(keyword "/ping") anything})})))))) + (is-200-status status) + (is (= "2.0" (:swagger body))) + (is (= "Kikka" (-> body :info :title))) + (is (some? (-> body :paths (get (keyword "/ping")))))))))) - (fact "swagger via api-options" + (testing "swagger via api-options" - (fact "with defaults" - (let [app (api)] + (testing "with defaults" + (let [app (api {:formatter :muuntaja})] - (fact "api-docs are not mounted" + (testing "api-docs are not mounted" (let [[status body] (raw-get* app "/")] - status => nil)) + (is (nil? status)))) - (fact "spec is not mounted" + (testing "spec is not mounted" (let [[status body] (get* app "/swagger.json")] - status => nil)))) + (is (= nil status)))))) - (fact "with spec" - (let [app (api {:swagger {:spec "/swagger.json"}})] + (testing "with spec" + (let [app (api {:formatter :muuntaja + :swagger {:spec "/swagger.json"}})] - (fact "api-docs are not mounted" + (testing "api-docs are not mounted" (let [[status body] (raw-get* app "/")] - status => nil)) + (is (= nil status)))) - (fact "spec is mounted to /swagger.json" + (testing "spec is mounted to /swagger.json" (let [[status body] (get* app "/swagger.json")] - status => 200 - body => (contains {:swagger "2.0"})))))) + (is-200-status status) + (is (= "2.0" (:swagger body)))))))) - (fact "with ui" - (let [app (api {:swagger {:ui "/api-docs"}})] + (testing "with ui" + (let [app (api {:formatter :muuntaja + :swagger {:ui "/api-docs"}})] - (fact "api-docs are mounted" + (testing "api-docs are mounted" (let [[status body] (raw-get* app "/api-docs")] - status => 200 - body => #"Swagger UI")) + (is-200-status status) + (is (str/includes? body "Swagger UI")))) - (fact "spec is not mounted" + (testing "spec is not mounted" (let [[status body] (get* app "/swagger.json")] - status => nil)))) + (is (= nil status)))))) - (fact "with ui and spec" - (let [app (api {:swagger {:spec "/swagger.json", :ui "/api-docs"}})] + (testing "with ui and spec" + (let [app (api {:formatter :muuntaja + :swagger {:spec "/swagger.json", :ui "/api-docs"}})] - (fact "api-docs are mounted" + (testing "api-docs are mounted" (let [[status body] (raw-get* app "/api-docs")] - status => 200 - body => #"Swagger UI")) + (is-200-status status) + (str/includes? body "Swagger UI"))) - (fact "spec is mounted to /swagger.json" + (testing "spec is mounted to /swagger.json" (let [[status body] (get* app "/swagger.json")] - status => 200 - body => (contains {:swagger "2.0"})))))) + (is-200-status status) + (is (= "2.0" (:swagger body)))))))) -(facts "swagger-docs with anonymous Return and Body models" +(deftest swagger-docs-with-anonymous-Return-and-Body-models-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (POST "/echo" [] :return (s/either {:a String}) :body [_ (s/maybe {:a String})] identity))] - (fact "api-docs" + (testing "api-docs" (let [spec (get-spec app)] (let [operation (some-> spec :paths vals first :post) body-ref (some-> operation :parameters first :schema :$ref) return-ref (get-in operation [:responses :200 :schema :$ref])] - (fact "generated body-param is found in Definitions" - (find-definition spec body-ref) => truthy) + (testing "generated body-param is found in Definitions" + (is (find-definition spec body-ref))) - (fact "generated return-param is found in Definitions" - return-ref => truthy - (find-definition spec body-ref) => truthy)))))) + (testing "generated return-param is found in Definitions" + (is return-ref) + (is (find-definition spec body-ref)))))))) (def Boundary {:type (s/enum "MultiPolygon" "Polygon" "MultiPoint" "Point") @@ -612,74 +672,77 @@ (def ReturnValue {:boundary (s/maybe Boundary)}) -(facts "https://github.com/metosin/compojure-api/issues/53" +;; "https://github.com/metosin/compojure-api/issues/53" +(deftest issue-53-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (POST "/" [] :return ReturnValue :body [_ Boundary] identity))] - (fact "api-docs" + (testing "api-docs" (let [spec (get-spec app)] (let [operation (some-> spec :paths vals first :post) body-ref (some-> operation :parameters first :schema :$ref) return-ref (get-in operation [:responses :200 :schema :$ref])] - (fact "generated body-param is found in Definitions" - (find-definition spec body-ref) => truthy) + (testing "generated body-param is found in Definitions" + (is (find-definition spec body-ref))) - (fact "generated return-param is found in Definitions" - return-ref => truthy - (find-definition spec body-ref) => truthy)))))) + (testing "generated return-param is found in Definitions" + (is return-ref) + (is (find-definition spec body-ref)))))))) (s/defschema Urho {:kaleva {:kekkonen {s/Keyword s/Any}}}) (s/defschema Olipa {:kerran {:avaruus {s/Keyword s/Any}}}) ; https://github.com/metosin/compojure-api/issues/94 -(facts "preserves deeply nested schema names" +(deftest preserves-deeply-nested-schema-names-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (POST "/" [] :return Urho :body [_ Olipa] identity))] - (fact "api-docs" + (testing "api-docs" (let [spec (get-spec app)] - (fact "nested models are discovered correctly" - (-> spec :definitions keys set) - - => #{:Urho :UrhoKaleva :UrhoKalevaKekkonen - :Olipa :OlipaKerran :OlipaKerranAvaruus}))))) + (testing "nested models are discovered correctly" + (is (= #{:Urho :UrhoKaleva :UrhoKalevaKekkonen + :Olipa :OlipaKerran :OlipaKerranAvaruus} + (-> spec :definitions keys set)))))))) -(fact "swagger-docs works with the :middleware" +(deftest swagger-docs-works-with-the-middleware-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/middleware" [] :query-params [x :- String] :middleware [[constant-middleware (ok 1)]] (ok 2)))] - (fact "api-docs" - (-> app get-spec :paths vals first) - => {:get {:parameters [{:description "" - :in "query" - :name "x" - :required true - :type "string"}] - :responses {:default {:description ""}}}}))) + (testing "api-docs" + (is (= {:get {:parameters [{:description "" + :in "query" + :name "x" + :required true + :type "string"}] + :responses {:default {:description ""}}}} + (-> app get-spec :paths vals first)))))) -(fact "sub-context paths" +(deftest sub-context-paths-test (let [response {:ping "pong"} ok (ok response) ok? (fn [[status body]] (and (= status 200) (= body response))) - not-ok? (comp not ok?) app (api + {:formatter :muuntaja} (swagger-routes {:ui nil}) (GET "/" [] ok) (GET "/a" [] ok) @@ -690,67 +753,64 @@ (GET "/" [] ok) (GET "/b2" [] ok))))] - (fact "valid routes" - (get* app "/") => ok? - (get* app "/a") => ok? - (get* app "/b/b1") => ok? - (get* app "/b") => ok? - (get* app "/b/b2") => ok?) + (testing "valid routes" + (is (ok? (get* app "/"))) + (is (ok? (get* app "/a"))) + (is (ok? (get* app "/b/b1"))) + (is (ok? (get* app "/b"))) + (is (ok? (get* app "/b/b2")))) - (fact "undocumented compojure easter eggs" - (get* app "/b/b1/") => ok? - (get* app "/b/") => ok? - (fact "this is fixed in compojure 1.5.1" - (get* app "/b//") =not=> ok?)) + (testing "undocumented compojure easter eggs" + (is (ok? (get* app "/b/b1/"))) + (is (ok? (get* app "/b/"))) + (testing "this is fixed in compojure 1.5.1" + (is (not (ok? (get* app "/b//")))))) - (fact "swagger-docs have trailing slashes removed" - (->> app get-spec :paths keys) - => ["/" "/a" "/b/b1" "/b" "/b/b2"]))) + (testing "swagger-docs have trailing slashes removed" + (is (= (sort ["/" "/a" "/b/b1" "/b" "/b/b2"]) + (-> app get-spec :paths keys sort)))))) -(fact "formats supported by ring-middleware-format" +(deftest formats-supported-by-ring-middleware-format-test (let [app (api + {:formatter :muuntaja} (POST "/echo" [] :body-params [foo :- String] (ok {:foo foo})))] - (tabular - (facts - (fact {:midje/description (str ?content-type " to json")} + (doseq [[?content-type ?body] [["application/json" "{\"foo\":\"bar\"}"] + ["application/edn" "{:foo \"bar\"}"] + ["application/transit+json" "[\"^ \",\"~:foo\",\"bar\"]"]]] + (testing (pr-str [?content-type ?body]) + (testing (str ?content-type " to json") (let [[status body] (raw-post* app "/echo" ?body ?content-type {:accept "application/json"})] - status => 200 - body => "{\"foo\":\"bar\"}")) - (fact {:midje/description (str "json to " ?content-type)} + (is-200-status status) + (is (= "{\"foo\":\"bar\"}" body)))) + (testing (str "json to " ?content-type) (let [[status body] (raw-post* app "/echo" "{\"foo\":\"bar\"}" "application/json" {:accept ?content-type})] - status => 200 - body => ?body))) + (is-200-status status) + (is (= ?body body)))))))) - ?content-type ?body - "application/json" "{\"foo\":\"bar\"}" - "application/x-yaml" "{foo: bar}\n" - "application/edn" "{:foo \"bar\"}" - "application/transit+json" "[\"^ \",\"~:foo\",\"bar\"]"))) - -(fact "multiple routes in context" +(deftest multiple-routes-in-context-test (let [app (api + {:formatter :muuntaja} (context "/foo" [] (GET "/bar" [] (ok ["bar"])) (GET "/baz" [] (ok ["baz"]))))] - (fact "first route works" + (testing "first route works" (let [[status body] (get* app "/foo/bar")] - status => 200 - body => ["bar"])) - (fact "second route works" + (is-200-status status) + (is (= ["bar"] body)))) + (testing "second route works" (let [[status body] (get* app "/foo/baz")] - status => 200 - body => ["baz"])))) - -(require '[compojure.api.test-domain :refer [Pizza burger-routes]]) + (is-200-status status) + (is (= ["baz"] body)))))) -(fact "external deep schemas" +(deftest external-deep-schemas-test (let [app (api + {:formatter :muuntaja} (swagger-routes) burger-routes (POST "/pizza" [] @@ -758,23 +818,25 @@ :body [body Pizza] (ok body)))] - (fact "direct route with nested named schema works when called" + (testing "direct route with nested named schema works when called" (let [pizza {:toppings [{:name "cheese"}]} - [status body] (post* app "/pizza" (json pizza))] - status => 200 - body => pizza)) + [status body] (post* app "/pizza" (json-string pizza))] + (is-200-status status) + (is (= pizza body)))) - (fact "defroute*'d route with nested named schema works when called" + (testing "defroute*'d route with nested named schema works when called" (let [burger {:ingredients [{:name "beef"}, {:name "egg"}]} - [status body] (post* app "/burger" (json burger))] - status => 200 - body => burger)) + [status body] (post* app "/burger" (json-string burger))] + (is-200-status status) + (is (= burger body)))) - (fact "generates correct swagger-spec" - (-> app get-spec :definitions keys set) => #{:Topping :Pizza :Burger :Beef}))) + (testing "generates correct swagger-spec" + (is (= #{:Topping :Pizza :Burger :Beef} + (-> app get-spec :definitions keys set)))))) -(fact "multiple routes with same path & method in same file" +(deftest multiple-routes-with-same-path-and-method-in-same-file-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/ping" [] :summary "active-ping" @@ -783,16 +845,17 @@ :summary "passive-ping" (ok {:ping "passive"})))] - (fact "first route matches with Compojure" + (testing "first route matches with Compojure" (let [[status body] (get* app "/ping" {})] - status => 200 - body => {:ping "active"})) + (is-200-status status) + (is (= {:ping "active"} body)))) - (fact "generates correct swagger-spec" - (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + (testing "generates correct swagger-spec" + (is (= "active-ping" (-> app get-spec :paths vals first :get :summary)))))) -(fact "multiple routes with same path & method over context" +(deftest multiple-routes-with-same-path-and-method-over-context-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (context "/api" [] (context "/ipa" [] @@ -805,16 +868,18 @@ :summary "passive-ping" (ok {:ping "passive"})))))] - (fact "first route matches with Compojure" + (testing "first route matches with Compojure" (let [[status body] (get* app "/api/ipa/ping" {})] - status => 200 - body => {:ping "active"})) + (is-200-status status) + (is (= {:ping "active"} body)))) - (fact "generates correct swagger-spec" - (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + (testing "generates correct swagger-spec" + (is (= "active-ping" (-> app get-spec :paths vals first :get :summary)))))) -(fact "multiple routes with same overall path (with different path sniplets & method over context" +;; multiple routes with same overall path (with different path sniplets & method over context) +(deftest multiple-routes-with-same-overall-path-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (context "/api/ipa" [] (GET "/ping" [] @@ -826,72 +891,82 @@ :summary "passive-ping" (ok {:ping "passive"})))))] - (fact "first route matches with Compojure" + (testing "first route matches with Compojure" (let [[status body] (get* app "/api/ipa/ping" {})] - status => 200 - body => {:ping "active"})) + (is-200-status status) + (is (= {:ping "active"} body)))) - (fact "generates correct swagger-spec" - (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + (testing "generates correct swagger-spec" + (is (= "active-ping" (-> app get-spec :paths vals first :get :summary)))))) ; https://github.com/metosin/compojure-api/issues/98 ; https://github.com/metosin/compojure-api/issues/134 -(fact "basePath" - (let [app (api (swagger-routes))] +(deftest basePath-test + (let [app (api + {:formatter :muuntaja} + (swagger-routes))] - (fact "no context" - (-> app get-spec :basePath) => "/") + (testing "no context" + (is (= "/" (-> app get-spec :basePath)))) - (fact "app-servers with given context" - (against-background (rsc/context anything) => "/v2") - (-> app get-spec :basePath) => "/v2")) + (testing "app-servers with given context" + (with-redefs [rsc/context (fn [& args] "/v2")] + (is (= "/v2" (-> app get-spec :basePath)))))) - (let [app (api (swagger-routes {:data {:basePath "/serve/from/here"}}))] - (fact "override it" - (-> app get-spec :basePath) => "/serve/from/here")) + (let [app (api + {:formatter :muuntaja} + (swagger-routes {:data {:basePath "/serve/from/here"}}))] + (testing "override it" + (is (= "/serve/from/here" (-> app get-spec :basePath))))) - (let [app (api (swagger-routes {:data {:basePath "/"}}))] - (fact "can set it to the default" - (-> app get-spec :basePath) => "/"))) + (let [app (api + {:formatter :muuntaja} + (swagger-routes {:data {:basePath "/"}}))] + (testing "can set it to the default" + (is (= "/" (-> app get-spec :basePath)))))) -(fact "multiple different models with same name" +(deftest multiple-different-models-with-same-name-test - (fact "schemas with same regexps are not equal" - {:d #"\D"} =not=> {:d #"\D"}) + (testing "schemas with same regexps are not equal" + (is (not= {:d #"\D"} {:d #"\D"}))) - (fact "api-spec with 2 schemas with non-equal contents" + (testing "api-spec with 2 schemas with non-equal contents" (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/" [] :responses {200 {:schema (s/schema-with-name {:a {:d #"\D"}} "Kikka")} 201 {:schema (s/schema-with-name {:a {:d #"\D"}} "Kikka")}} identity))] - (fact "api spec doesn't fail (#102)" - (get-spec app) => anything)))) + (testing "api spec doesn't fail (#102)" + (is (get-spec app)))))) (def over-the-hills-and-far-away (POST "/" [] :body-params [a :- s/Str] identity)) -(fact "anonymous body models over defined routes" +(deftest anonymous-body-models-over-defined-routes-test (let [app (api + {:formatter :muuntaja} (swagger-routes) over-the-hills-and-far-away)] - (fact "generated model doesn't have namespaced keys" - (-> app get-spec :definitions vals first :properties keys first) => :a))) + (testing "generated model doesn't have namespaced keys" + (is (= :a (-> app get-spec :definitions vals first :properties keys first)))))) (def foo (GET "/foo" [] (let [foo {:foo "bar"}] (ok foo)))) -(fact "defroutes with local symbol usage with same name (#123)" +;;defroutes with local symbol usage with same name (#123) +(deftest defroutes-with-local-symbol-usage-with-same-name-test (let [app (api + {:formatter :muuntaja} foo)] (let [[status body] (get* app "/foo")] - status => 200 - body => {:foo "bar"}))) + (is-200-status status) + (is (= {:foo "bar"} body))))) (def response-descriptions-routes (GET "/x" [] @@ -899,15 +974,17 @@ :description "Horror"}} identity)) -(fact "response descriptions" +(deftest response-descriptions-test (let [app (api + {:formatter :muuntaja} (swagger-routes) response-descriptions-routes)] - (-> app get-spec :paths vals first :get :responses :500 :description) => "Horror")) + (is (= "Horror" (-> app get-spec :paths vals first :get :responses :500 :description))))) -(fact "exceptions options with custom validation error handler" +(deftest exceptions-options-with-custom-validation-error-handler-test (let [app (api - {:exceptions {:handlers {::ex/request-validation custom-validation-error-handler + {:formatter :muuntaja + :exceptions {:handlers {::ex/request-validation custom-validation-error-handler ::ex/request-parsing custom-validation-error-handler ::ex/response-validation custom-validation-error-handler}}} (swagger-routes) @@ -918,120 +995,116 @@ 1 (ok 1) (ok "not a number"))))] - (fact "return case, valid request & valid model" + (testing "return case, valid request & valid model" (let [[status body] (post* app "/get-long" "{\"x\": 1}")] - status => 200 - body => 1)) + (is-200-status status) + (is (= 1 body)))) - (fact "return case, not schema valid request" + (testing "return case, not schema valid request" (let [[status body] (post* app "/get-long" "{\"x\": \"1\"}")] - status => 400 - body => (contains {:custom-error "/get-long"}))) + (is (= 400 status)) + (is (= "/get-long" (:custom-error body))))) - (fact "return case, invalid json request" + (testing "return case, invalid json request" (let [[status body] (post* app "/get-long" "{x: 1}")] - status => 400 - body => (contains {:custom-error "/get-long"}))) + (is (= 400 status)) + (is (= "/get-long" (:custom-error body))))) - (fact "return case, valid request & invalid model" + (testing "return case, valid request & invalid model" (let [[status body] (post* app "/get-long" "{\"x\": 2}")] - status => 501 - body => (contains {:custom-error "/get-long"}))))) + (is (= 501 status)) + (is (= "/get-long" (:custom-error body))))))) -(fact "exceptions options with custom exception and error handler" +(deftest exceptions-options-with-custom-exception-and-error-handler-test (let [app (api - {:exceptions {:handlers {::ex/default custom-exception-handler + {:formatter :muuntaja + :exceptions {:handlers {::ex/default (custom-exception-handler :custom-exception) + SQLException (custom-exception-handler :sql-exception) ::custom-error custom-error-handler}}} (swagger-routes) (GET "/some-exception" [] - (throw (new RuntimeException))) + (throw (RuntimeException.))) (GET "/some-error" [] (throw (ex-info "some ex info" {:data "some error" :type ::some-error}))) (GET "/specific-error" [] - (throw (ex-info "my ex info" {:data "my error" :type ::custom-error}))))] + (throw (ex-info "my ex info" {:data "my error" :type ::custom-error}))) + (GET "/class" [] + (throw (SQLException.))) + (GET "/sub-class" [] + (throw (SQLWarning.))))] - (fact "uses default exception handler for unknown exceptions" + (testing "uses default exception handler for unknown exceptions" (let [[status body] (get* app "/some-exception")] - status => 200 - body => {:custom-exception "java.lang.RuntimeException"})) + (is-200-status status) + (is (= {:custom-exception "java.lang.RuntimeException"} body)))) - (fact "uses default exception handler for unknown errors" + (testing "uses default exception handler for unknown errors" (let [[status body] (get* app "/some-error")] - status => 200 - (:custom-exception body) => (contains ":data \"some error\""))) + (is-200-status status) + (is (str/includes? (:custom-exception body) ":data \"some error\"" )))) + + (testing "uses specific error handler for ::custom-errors" + (let [[_ body] (get* app "/specific-error")] + (is (= {:custom-error "my error"} body)))) - (fact "uses specific error handler for ::custom-errors" - (let [[status body] (get* app "/specific-error")] - body => {:custom-error "my error"})))) + (testing "direct class" + (let [[_ body] (get* app "/class")] + (is (= "java.sql.SQLException" (:sql-exception body))))) -(fact "exception handling can be disabled" + (testing "sub-class" + (let [[_ body] (get* app "/sub-class")] + (is (= "java.sql.SQLWarning" (:sql-exception body))))))) + +(deftest exception-handling-can-be-disabled-test (let [app (api - {:exceptions nil} + {:formatter :muuntaja + :exceptions nil} (GET "/throw" [] (throw (new RuntimeException))))] - (get* app "/throw") => throws)) - -(defn old-ex-handler [e] - {:status 500 - :body {:type "unknown-exception" - :class (.getName (.getClass e))}}) - -(fact "Deprecated options" - (facts "Old options throw assertion error" - (api {:validation-errors {:error-handler identity}} nil) => (throws AssertionError) - (api {:validation-errors {:catch-core-errors? true}} nil) => (throws AssertionError) - (api {:exceptions {:exception-handler identity}} nil) => (throws AssertionError)) - (facts "Old handler functions work, with a warning" - (let [app (api - {:exceptions {:handlers {::ex/default old-ex-handler}}} - (GET "/" [] - (throw (RuntimeException.))))] - (with-out-str - (let [[status body] (get* app "/")] - status => 500 - body => {:type "unknown-exception" - :class "java.lang.RuntimeException"})) - (with-out-str - (get* app "/")) => "WARN Error-handler arity has been changed.\n"))) + (is (thrown? RuntimeException (get* app "/throw"))))) (s/defn schema-error [a :- s/Int] {:bar a}) -(fact "handling schema.core/error" +;; handling schema.core/error +(deftest handling-schema-core-error-test (let [app (api - {:exceptions {:handlers {:schema.core/error ex/schema-error-handler}}} + {:formatter :muuntaja + :exceptions {:handlers {:schema.core/error ex/schema-error-handler}}} (GET "/:a" [] :path-params [a :- s/Str] (ok (s/with-fn-validation (schema-error a)))))] (let [[status body] (get* app "/foo")] - status => 400 - body => (contains {:errors vector?})))) + (is (= 400 status)) + (is (-> body :errors vector?))))) -(fact "ring-swagger options" +(deftest ring-swagger-options-test (let [app (api - {:ring-swagger {:default-response-description-fn status/get-description}} + {:formatter :muuntaja + :ring-swagger {:default-response-description-fn status/get-description}} (swagger-routes) (GET "/ping" [] :responses {500 nil} identity))] - (-> app get-spec :paths vals first :get :responses :500 :description) - => "There was an internal server error.")) + (is (= "There was an internal server error." (-> app get-spec :paths vals first :get :responses :500 :description))))) -(fact "path-for" - (fact "simple case" +(deftest path-for-test + (testing "simple case" (let [app (api + {:formatter :muuntaja} (GET "/api/pong" [] :name :pong (ok {:pong "pong"})) (GET "/api/ping" [] (moved-permanently (path-for :pong))))] - (fact "path-for works" + (testing "path-for works" (let [[status body] (get* app "/api/ping" {})] - status => 200 - body => {:pong "pong"})))) + (is-200-status status) + (is (= {:pong "pong"} body)))))) - (fact "with path parameters" + (testing "with path parameters" (let [app (api + {:formatter :muuntaja} (GET "/lost-in/:country/:zip" [] :name :lost :path-params [country :- (s/enum :FI :EN), zip :- s/Int] @@ -1040,188 +1113,208 @@ (GET "/api/ping" [] (moved-permanently (path-for :lost {:country :FI, :zip 33200}))))] - (fact "path-for resolution" + (testing "path-for resolution" (let [[status body] (get* app "/api/ping" {})] - status => 200 - body => {:country "FI" - :zip 33200})))) + (is-200-status status) + (is (= {:country "FI" :zip 33200} body)))))) - (fact "https://github.com/metosin/compojure-api/issues/150" + (testing "https://github.com/metosin/compojure-api/issues/150" (let [app (api + {:formatter :muuntaja} (GET "/companies/:company-id/refresh" [] :path-params [company-id :- s/Int] :name :refresh-company :return String (ok (path-for :refresh-company {:company-id company-id}))))] - (fact "path-for resolution" + (testing "path-for resolution" (let [[status body] (get* app "/companies/4/refresh")] - status => 200 - body => "/companies/4/refresh")))) + (is-200-status status) + (is (= "/companies/4/refresh" body)))))) - (fact "multiple routes with same name fail at compile-time" + (testing "multiple routes with same name fail at compile-time" (let [app' `(api + {:formatter :muuntaja} (GET "/api/pong" [] :name :pong identity) (GET "/api/ping" [] :name :pong identity))] - (eval app') => (throws RuntimeException)))) + (is (thrown? RuntimeException (eval app'))))) + (testing "bindings with wrong syntax should fail nicely" + (let [app' `(api + {:formatter :muuntaja} + (GET "/api/:id/pong" [] + :path-params [id ::id] + :name :pong + identity))] + (is (thrown? RuntimeException (eval app')))))) -(fact "swagger-spec-path" - (fact "defaults to /swagger.json" - (let [app (api (swagger-routes))] - (swagger/swagger-spec-path app) => "/swagger.json")) - (fact "follows defined path" - (let [app (api (swagger-routes {:spec "/api/api-docs/swagger.json"}))] - (swagger/swagger-spec-path app) => "/api/api-docs/swagger.json"))) +(deftest swagger-spec-path-test + (testing "defaults to /swagger.json" + (let [app (api + {:formatter :muuntaja} + (swagger-routes))] + (is (= "/swagger.json" (swagger/swagger-spec-path app))))) + (testing "follows defined path" + (let [app (api + {:formatter :muuntaja} + (swagger-routes {:spec "/api/api-docs/swagger.json"}))] + (is (= "/api/api-docs/swagger.json" (swagger/swagger-spec-path app)))))) (defrecord NonSwaggerRecord [data]) -(fact "api validation" +(deftest api-validation-test - (fact "a swagger api with valid swagger records" + (testing "a swagger api with valid swagger records" (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/ping" [] :return {:data s/Str} (ok {:data "ping"})))] - (fact "works" + (testing "works" (let [[status body] (get* app "/ping")] - status => 200 - body => {:data "ping"})) + (is-200-status status) + (is (= {:data "ping"} body)))) - (fact "the api is valid" - (validator/validate app) => app))) + (testing "the api is valid" + (is (= app (validator/validate app)))))) - (fact "a swagger api with invalid swagger records" + (testing "a swagger api with invalid swagger records" (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/ping" [] :return NonSwaggerRecord (ok (->NonSwaggerRecord "ping"))))] - (fact "works" + (testing "works" (let [[status body] (get* app "/ping")] - status => 200 - body => {:data "ping"})) - - (fact "the api is invalid" - (validator/validate app) - => (throws - IllegalArgumentException - (str - "don't know how to convert class compojure.api.integration_test.NonSwaggerRecord " - "into a Swagger Schema. Check out ring-swagger docs for details."))))) - - (fact "a non-swagger api with invalid swagger records" + (is-200-status status) + (is (= {:data "ping"} body)))) + + (testing "the api is invalid" + (is (thrown-with-msg? + IllegalArgumentException + #"don't know how to convert class compojure.api.integration_test.NonSwaggerRecord into a Swagger Schema. Check out ring-swagger docs for details." + (validator/validate app)))))) + + (testing "a non-swagger api with invalid swagger records" (let [app (api + {:formatter :muuntaja} (GET "/ping" [] :return NonSwaggerRecord (ok (->NonSwaggerRecord "ping"))))] - (fact "works" + (testing "works" (let [[status body] (get* app "/ping")] - status => 200 - body => {:data "ping"})) + (is-200-status status) + (is (= {:data "ping"} body)))) - (fact "the api is valid" - (validator/validate app) => app)))) + (testing "the api is valid" + (is (= app (validator/validate app))))))) -(fact "component integration" +(deftest component-integration-test (let [system {:magic 42}] - (fact "via options" + (testing "via options" (let [app (api - {:components system} + {:formatter :muuntaja + :components system} (GET "/magic" [] :components [magic] (ok {:magic magic})))] (let [[status body] (get* app "/magic")] - status => 200 - body => {:magic 42}))) + (is-200-status status) + (is (= {:magic 42} body))))) - (fact "via middleware" + (testing "via middleware" (let [handler (api + {:formatter :muuntaja} (GET "/magic" [] :components [magic] (ok {:magic magic}))) app (mw/wrap-components handler system)] (let [[status body] (get* app "/magic")] - status => 200 - body => {:magic 42}))))) + (is-200-status status) + (is (= {:magic 42} body))))))) -(fact "sequential string parameters" +(deftest sequential-string-parameters-test (let [app (api + {:formatter :muuntaja} (GET "/ints" [] :query-params [i :- [s/Int]] (ok {:i i})))] - (fact "multiple values" + (testing "multiple values" (let [[status body] (get* app "/ints?i=1&i=2&i=3")] - status => 200 - body => {:i [1, 2, 3]})) - (fact "single value" + (is-200-status status) + (is (= {:i [1, 2, 3]} body)))) + (testing "single value" (let [[status body] (get* app "/ints?i=42")] - status => 200 - body => {:i [42]})))) + (is-200-status status) + (is (= {:i [42]} body)))))) -(fact ":swagger params just for ducumentation" - (fact "compile-time values" +(deftest swagger-params-just-for-documentation-test + (testing "compile-time values" (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/route" [q] :swagger {:x-name :boolean :operationId "echoBoolean" - :description "Ehcoes a boolean" + :description "Echoes a boolean" :parameters {:query {:q s/Bool}}} (ok {:q q})))] - (fact "there is no coercion" + (testing "there is no coercion" (let [[status body] (get* app "/route" {:q "kikka"})] - status => 200 - body => {:q "kikka"})) - - (fact "swagger-docs are generated" - (-> app get-spec :paths vals first :get) - => (contains - {:x-name "boolean" - :operationId "echoBoolean" - :description "Ehcoes a boolean" - :parameters [{:description "" - :in "query" - :name "q" - :required true - :type "boolean"}]})))) - (fact "run-time values" + (is-200-status status) + (is (= {:q "kikka"} body)))) + + (testing "swagger-docs are generated" + (is (= {:x-name "boolean" + :operationId "echoBoolean" + :description "Echoes a boolean" + :parameters [{:description "" + :in "query" + :name "q" + :required true + :type "boolean"}]} + (-> app get-spec :paths vals first :get + (select-keys [:x-name :operationId :description :parameters]))))))) + (testing "run-time values" (let [runtime-data {:x-name :boolean :operationId "echoBoolean" - :description "Ehcoes a boolean" + :description "Echoes a boolean" :parameters {:query {:q s/Bool}}} app (api + {:formatter :muuntaja} (swagger-routes) (GET "/route" [q] :swagger runtime-data (ok {:q q})))] - (fact "there is no coercion" + (testing "there is no coercion" (let [[status body] (get* app "/route" {:q "kikka"})] - status => 200 - body => {:q "kikka"})) - - (fact "swagger-docs are generated" - (-> app get-spec :paths vals first :get) - => (contains - {:x-name "boolean" - :operationId "echoBoolean" - :description "Ehcoes a boolean" - :parameters [{:description "" - :in "query" - :name "q" - :required true - :type "boolean"}]}))))) - -(fact "swagger-docs via api options, #218" + (is-200-status status) + (is (= {:q "kikka"} body)))) + + (testing "swagger-docs are generated" + (is (= {:x-name "boolean" + :operationId "echoBoolean" + :description "Echoes a boolean" + :parameters [{:description "" + :in "query" + :name "q" + :required true + :type "boolean"}]} + (-> app get-spec :paths vals first :get + (select-keys [:x-name :operationId :description :parameters])))))))) + +;; swagger-docs via api options, #218 +(deftest swagger-docs-via-api-options (let [routes (routes (context "/api" [] (GET "/ping" [] @@ -1232,123 +1325,56 @@ (ok {:message "ping"}))) (ANY "*" [] (ok {:message "404"}))) - api1 (api {:swagger {:spec "/swagger.json", :ui "/"}} routes) - api2 (api (swagger-routes) routes)] + api1 (api {:formatter :muuntaja + :swagger {:spec "/swagger.json", :ui "/"}} routes) + api2 (api {:formatter :muuntaja} (swagger-routes) routes)] - (fact "both generate same swagger-spec" - (get-spec api1) => (get-spec api2)) + (testing "both generate same swagger-spec" + (is (= (get-spec api1) (get-spec api2)))) - (fact "not-found handler works" - (second (get* api1 "/missed")) => {:message "404"} - (second (get* api2 "/missed")) => {:message "404"}))) + (testing "not-found handler works" + (is (= {:message "404"} (second (get* api1 "/missed")))) + (is (= {:message "404"} (second (get* api2 "/missed"))))))) -(fact "more swagger-data can be (deep-)merged in - either via swagger-docs at runtime via mws, fixes #170" +;; more swagger-data can be (deep-)merged in - either via swagger-docs at runtime via mws, fixes #170 +(deftest issue-170-test (let [app (api - (middleware [[rsm/wrap-swagger-data {:paths {"/runtime" {:get {}}}}]] + {:formatter :muuntaja} + (route-middleware [[rsm/wrap-swagger-data {:paths {"/runtime" {:get {}}}}]] (swagger-routes {:data {:info {:version "2.0.0"} :paths {"/extra" {:get {}}}}}) (GET "/normal" [] (ok))))] - (get-spec app) => (contains - {:paths (just - {"/normal" irrelevant - "/extra" irrelevant - "/runtime" irrelevant})}))) - - -(s/defschema Foo {:a [s/Keyword]}) - -(defapi with-defapi - (swagger-routes) - (GET "/foo" [] - :return Foo - (ok {:a "foo"}))) - -(defn with-api [] - (api - (swagger-routes) - (GET "/foo" [] - :return Foo - (ok {:a "foo"})))) - -(fact "defapi & api define same results, #159" - (get-spec with-defapi) => (get-spec (with-api))) + (is (= #{"/normal" "/extra" "/runtime"} + (-> (get-spec app) :paths keys set))))) -(fact "coercion api change in 1.0.0 migration test" - (fact "with defaults" - (let [app (api - (GET "/ping" [] - :return s/Bool - (ok 1)))] - (let [[status] (get* app "/ping")] - status => 500))) +(deftest handling-invalid-routes-with-api-test + (let [invalid-routes (routes (constantly nil))] - (fact "with pre 1.0.0 syntax, api can't be created (with a nice error message)" - (let [app' `(api - {:coercion (dissoc mw/default-coercion-matchers :response)} - (GET "/ping" [] - :return s/Bool - (ok 1)))] - (eval app') => (throws AssertionError))) + (testing "by default, logs the exception" + (let [a (atom [])] + (with-redefs [compojure.api.impl.logging/log! (fn [& args] (swap! a conj args))] + (is (api {:formatter :muuntaja} invalid-routes))) + (is (= [:warn] (map first @a))))) - (fact "with post 1.0.0 syntax, works ok" - (let [app (api - {:coercion (constantly (dissoc mw/default-coercion-matchers :response))} - (GET "/ping" [] - :return s/Bool - (ok 1)))] - (let [[status body] (get* app "/ping")] - status => 200 - body => 1)))) + (testing "ignoring invalid routes doesn't log" + (let [a (atom [])] + (with-redefs [compojure.api.impl.logging/log! (fn [& args] (swap! a conj args))] + (is (api {:formatter :muuntaja, :api {:invalid-routes-fn nil}} invalid-routes))) + (is (empty? @a)))) -(fact "handling invalid routes with api" - (let [invalid-routes (routes (constantly nil))] + (testing "throwing exceptions" + (is (thrown? Exception (api {:formatter :muuntaja + :api {:invalid-routes-fn routes/fail-on-invalid-child-routes}} + invalid-routes)))))) - (fact "by default, logs the exception" - (api invalid-routes) => truthy - (provided - (compojure.api.impl.logging/log! :warn irrelevant) => irrelevant :times 1)) - - (fact "ignoring invalid routes doesn't log" - (api {:api {:invalid-routes-fn nil}} invalid-routes) => truthy - (provided - (compojure.api.impl.logging/log! :warn irrelevant) => irrelevant :times 0)) - - (fact "throwing exceptions" - (api {:api {:invalid-routes-fn routes/fail-on-invalid-child-routes}} invalid-routes)) => throws)) - -(defmethod compojure.api.meta/restructure-param ::deprecated-middlewares-test [_ _ acc] - (assoc acc :middlewares [(constantly nil)])) - -(defmethod compojure.api.meta/restructure-param ::deprecated-parameters-test [_ _ acc] - (assoc-in acc [:parameters :parameters :query] {:a String})) - -(defn msg-or-cause-msg [msg-re] - (fn [e] - ;; In Clojure 1.10+, macroexpansion exceptions get wrapped in another exception. - ;; In that case we will look at the cause. - (boolean (or (re-find msg-re (.getMessage e)) - (re-find msg-re (.getMessage (.getCause e))))))) - -(fact "old middlewares restructuring" - - (fact ":middlewares" - (eval '(GET "/foo" [] - ::deprecated-middlewares-test true - (ok))) - => (throws (msg-or-cause-msg #":middlewares is deprecated with 1.0.0, use :middleware instead."))) - (fact ":parameters" - (eval '(GET "/foo" [] - ::deprecated-parameters-test true - (ok))) - => (throws (msg-or-cause-msg #":parameters is deprecated with 1.0.0, use :swagger instead.")))) - -(fact "using local symbols for restructuring params" +(deftest using-local-symbols-for-restructuring-params-test (let [responses {400 {:schema {:fail s/Str}}} app (api - {:swagger {:spec "/swagger.json" + {:formatter :muuntaja + :swagger {:spec "/swagger.json" :data {:info {:version "2.0.0"}}}} (GET "/a" [] :responses responses @@ -1358,32 +1384,39 @@ :responses (assoc responses 500 {:schema {:m s/Str}}) :return {:ok s/Str} (ok))) - paths (:paths (get-spec app))] - - (get-in paths ["/a" :get :responses]) - => (just {:400 (just {:schema anything :description ""}) - :200 (just {:schema anything :description ""})}) - - (get-in paths ["/b" :get :responses]) - => (just {:400 (just {:schema anything :description ""}) - :200 (just {:schema anything :description ""}) - :500 (just {:schema anything :description ""})}))) - -(fact "when functions are returned" + paths (:paths (get-spec app)) + a-resp (get-in paths ["/a" :get :responses]) + b-resp (get-in paths ["/b" :get :responses])] + + (is (= #{:200 :400} (-> a-resp keys set))) + (is (= #{:schema :description} (-> a-resp :400 keys set))) + (is (= #{:schema :description} (-> a-resp :200 keys set))) + (is (= "" (-> a-resp :400 :description))) + (is (= "" (-> a-resp :200 :description))) + + (is (= #{:200 :400 :500} (-> b-resp keys set))) + (is (= #{:schema :description} (-> b-resp :500 keys set))) + (is (= #{:schema :description} (-> b-resp :400 keys set))) + (is (= #{:schema :description} (-> b-resp :200 keys set))) + (is (= "" (-> b-resp :500 :description))) + (is (= "" (-> b-resp :400 :description))) + (is (= "" (-> b-resp :200 :description))))) + +(deftest when-functions-are-returned-test (let [wrap-mw-params (fn [handler value] (fn [request] (handler (update request ::mw #(str % value)))))] - (fact "from endpoint" + (testing "from endpoint" (let [app (GET "/ping" [] :middleware [[wrap-mw-params "1"]] :query-params [{a :- s/Str "a"}] (fn [req] (str (::mw req) a)))] - (app {:request-method :get, :uri "/ping", :query-params {}}) => (contains {:body "1a"}) - (app {:request-method :get, :uri "/ping", :query-params {:a "A"}}) => (contains {:body "1A"}))) + (is (= "1a" (:body (app {:request-method :get, :uri "/ping", :query-params {}})))) + (is (= "1A" (:body (app {:request-method :get, :uri "/ping", :query-params {:a "A"}})))))) - (fact "from endpoint under context" + (testing "from endpoint under context" (let [app (context "/api" [] :middleware [[wrap-mw-params "1"]] :query-params [{a :- s/Str "a"}] @@ -1392,9 +1425,9 @@ :query-params [{b :- s/Str "b"}] (fn [req] (str (::mw req) a b))))] - (app {:request-method :get, :uri "/api/ping", :query-params {}}) => (contains {:body "12ab"}) - (app {:request-method :get, :uri "/api/ping", :query-params {:a "A"}}) => (contains {:body "12Ab"}) - (app {:request-method :get, :uri "/api/ping", :query-params {:a "A", :b "B"}}) => (contains {:body "12AB"}))))) + (is (= "12ab" (:body (app {:request-method :get, :uri "/api/ping", :query-params {}})))) + (is (= "12Ab" (:body (app {:request-method :get, :uri "/api/ping", :query-params {:a "A"}})))) + (is (= "12AB" (:body (app {:request-method :get, :uri "/api/ping", :query-params {:a "A", :b "B"}})))))))) (defn check-for-response-handler "This response-validation handler checks for the existence of :response in its input. If it's there, it @@ -1404,108 +1437,530 @@ (ok {:message "Found :response in data!" :attempted-body (get-in data [:response :body])}) (not-found "No :response key present in data!"))) -(fact "response-validation handler has access to response value that failed coercion" +(deftest response-validation-handler-has-access-to-response-value-that-failed-coercion-test (let [incorrect-return-value {:incorrect "response"} app (api - {:exceptions {:handlers {::ex/response-validation check-for-response-handler}}} + {:formatter :muuntaja + :exceptions {:handlers {::ex/response-validation check-for-response-handler}}} (swagger-routes) (GET "/test-response" [] :return {:correct s/Str} ; This should fail and trigger our error handler (ok incorrect-return-value)))] - (fact "return case, valid request & valid model" + (testing "return case, valid request & valid model" (let [[status body] (get* app "/test-response")] - status => 200 - (:attempted-body body) => incorrect-return-value)))) + (is-200-status status) + (is (= incorrect-return-value (:attempted-body body))))))) -(fact "correct swagger parameter order with small number or parameters, #224" +;; "correct swagger parameter order with small number or parameters, #224" +(deftest issue-224-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (GET "/ping" [] :query-params [a b c d e] (ok {:a a, :b b, :c c, :d d, :e e})))] - (fact "api works" + (testing "api works" (let [[status body] (get* app "/ping" {:a "A" :b "B" :c "C" :d "D" :e "E"})] - status => 200 - body => {:a "A" :b "B" :c "C" :d "D" :e "E"})) - (fact "swagger parameters are in correct order" - (-> app get-spec :paths (get "/ping") :get :parameters (->> (map (comp keyword :name)))) => [:a :b :c :d :e]))) - -(fact "empty top-level route, #https://github.com/metosin/ring-swagger/issues/92" + (is-200-status status) + (is (= {:a "A" :b "B" :c "C" :d "D" :e "E"} body)))) + (testing "swagger parameters are in correct order" + (is (= [:a :b :c :d :e] + (-> app get-spec :paths (get "/ping") :get :parameters (->> (map (comp keyword :name))))))))) + +;; empty top-level route, #https://github.com/metosin/ring-swagger/issues/92 +(deftest issue-92-test (let [app (api - {:swagger {:spec "/swagger.json"}} + {:formatter :muuntaja + :swagger {:spec "/swagger.json"}} (GET "/" [] (ok {:kikka "kukka"})))] - (fact "api works" + (testing "api works" (let [[status body] (get* app "/")] - status => 200 - body => {:kikka "kukka"})) - (fact "swagger docs" - (-> app get-spec :paths keys) => ["/"]))) + (is-200-status status) + (is (= {:kikka "kukka"} body)))) + (testing "swagger docs" + (is (= ["/"] (-> app get-spec :paths keys)))))) -(fact "describe works on anonymous bodys, #168" +;; describe works on anonymous bodys, #168 +(deftest issue-168-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (POST "/" [] :body [body (describe {:kikka [{:kukka String}]} "kikkas")] (ok body)))] - (fact "description is in place" - (-> app get-spec :paths (get "/") :post :parameters first) - => (contains {:description "kikkas"})))) + (testing "description is in place" + (is (= "kikkas" (-> app get-spec :paths (get "/") :post :parameters first :description)))))) -(facts "swagger responses headers are mapped correctly, #232" +;; swagger responses headers are mapped correctly, #232 +(deftest issue-232-test (let [app (api + {:formatter :muuntaja} (swagger-routes) (context "/resource" [] (resource {:get {:responses {200 {:schema {:size s/Str} :description "size" :headers {"X-men" (describe s/Str "mutant")}}}}})))] - (-> app get-spec :paths vals first :get :responses :200 :headers) - => {:X-men {:description "mutant", :type "string"}})) + (is (= {:X-men {:description "mutant", :type "string"}} + (-> app get-spec :paths vals first :get :responses :200 :headers))))) -(facts "api-middleware can be disabled" +(deftest api-middleware-can-be-disabled-test (let [app (api - {:api {:disable-api-middleware? true}} + {:formatter :muuntaja + :api {:disable-api-middleware? true}} (swagger-routes) (GET "/params" [x] (ok {:x x})) (GET "/throw" [] (throw (RuntimeException. "kosh"))))] - (fact "json-parsing & wrap-params is off" + (testing "json-parsing & wrap-params is off" (let [[status body] (raw-get* app "/params" {:x 1})] - status => 200 - body => {:x nil})) + (is-200-status status) + (is (= {:x nil} body)))) - (fact "exceptions are not caught" - (raw-get* app "/throw") => throws))) + (testing "exceptions are not caught" + (is (thrown? Exception (raw-get* app "/throw")))))) -(facts "custom formats contribute to Swagger :consumes & :produces" - (let [custom-json (format-response/make-encoder json "application/vnd.vendor.v1+json") +;"custom formats contribute to Swagger :consumes & :produces" +(deftest custom-formats-contribute-to-Swagger-consumes-produces-test + (let [custom-type "application/vnd.vendor.v1+json" app (api {:swagger {:spec "/swagger.json"} - :format {:formats [custom-json :json]}} + :formats (-> m/default-options + (m/install muuntaja.format.json/format custom-type) + (m/select-formats ["application/json" custom-type]))} (POST "/echo" [] :body [data {:kikka s/Str}] (ok data)))] - (fact "it works" + (testing "it works" (let [response (app {:uri "/echo" :request-method :post :body (json-stream {:kikka "kukka"}) :headers {"content-type" "application/vnd.vendor.v1+json" "accept" "application/vnd.vendor.v1+json"}})] - (-> response :body slurp) => (json {:kikka "kukka"}) - (-> response :headers) => (contains {"Content-Type" "application/vnd.vendor.v1+json; charset=utf-8"}))) + (is (= (json-string {:kikka "kukka"}) (-> response :body slurp))) + (is (= "application/vnd.vendor.v1+json; charset=utf-8" + (-> response :headers (get "Content-Type")))))) - (fact "spec is correct" - (get-spec app) => (contains - {:produces ["application/vnd.vendor.v1+json" "application/json"] - :consumes ["application/vnd.vendor.v1+json" "application/json"]})))) + (testing "spec is correct" + (let [res (get-spec app)] + (is (= (sort ["application/vnd.vendor.v1+json" "application/json"]) + (-> res :produces sort))) + (is (= (sort ["application/vnd.vendor.v1+json" "application/json"]) + (-> res :consumes sort))))))) -(fact "static contexts work" +(deftest muuntaja-is-bound-in-request-test + (let [app (api + {:formatter :muuntaja} + (GET "/ping" {:keys [::request/muuntaja]} + (ok {:pong (slurp (m/encode muuntaja "application/json" {:is "json"}))})))] + + (let [[status body] (get* app "/ping")] + (is-200-status status) + (is (= {:pong "{\"is\":\"json\"}"} body))))) + +(deftest body-doesnt-keywordize-keys-test + (let [m (m/create) + data {:items {"kikka" 42}} + body* (atom nil) + app (api + {:formatter :muuntaja} + (swagger-routes) + (POST "/echo" [] + :body-params [items :- {:kikka Long}] + (reset! body* {:items items}) + (ok)) + (POST "/echo2" [] + :body [body {:items {(s/required-key "kikka") Long}}] + (reset! body* body) + (ok)))] + + (testing ":body-params keywordizes params" + (is (http/ok? (app {:uri "/echo" + :request-method :post + :body (m/encode m "application/transit+json" data) + :headers {"content-type" "application/transit+json" + "accept" "application/transit+json"}}))) + (is (= {:items {:kikka 42}} @body*))) + + (testing ":body does not keywordizes params" + (is (http/ok? (app {:uri "/echo2" + :request-method :post + :body (m/encode m "application/transit+json" data) + :headers {"content-type" "application/transit+json" + "accept" "application/transit+json"}}))) + (is (= {:items {"kikka" 42}} @body*))) + + (testing "swagger spec is generated both ways" + (let [spec (get-spec app) + echo-schema-name (-> (get-in spec [:paths "/echo" :post :parameters 0 :name]) + name (str "Items") keyword) + echo2-schema-name (-> (get-in spec [:paths "/echo2" :post :parameters 0 :name]) + name (str "Items") keyword) + echo-schema (get-in spec [:definitions echo-schema-name :properties]) + echo2-schema (get-in spec [:definitions echo2-schema-name :properties])] + (is (= {:kikka {:type "integer", :format "int64"}} echo-schema)) + (is (= {:kikka {:type "integer", :format "int64"}} echo2-schema)))))) + + +(def ^:dynamic *response* nil) + +(deftest format-based-body-and-response-coercion-test + (let [m (mw/create-muuntaja)] + + (testing "application/transit & application/edn validate request & response (no coercion)" + (let [valid-data {:items {"kikka" :kukka}} + invalid-data {"items" {"kikka" :kukka}} + Schema {:items {(s/required-key "kikka") s/Keyword}} + app (api + {:formatter :muuntaja} + (POST "/echo" [] + :body [_ Schema] + :return Schema + (ok *response*)))] + + (doseq [format ["application/transit+json" "application/edn"]] + (testing format + + (testing "fails with invalid body" + (is (http/bad-request? (app (ring-request m format invalid-data))))) + + (testing "fails with invalid response" + (binding [*response* invalid-data] + (is (http/internal-server-error? (app (ring-request m format valid-data)))))) + + (testing "succeeds with valid body & response" + (binding [*response* valid-data] + (let [response (app (ring-request m format valid-data))] + (is (http/ok? response)) + (is (= valid-data (m/decode m format (:body response))))))))))) + + (testing "application/json - coerce request, validate response" + (let [valid-data {:int 1, :keyword "kikka"} + valid-response-data {:int 1, :keyword :kikka} + invalid-data {:int "1", :keyword "kikka"} + Schema {:int s/Int, :keyword s/Keyword} + app (api + {:formatter :muuntaja} + (POST "/echo" [] + :body [_ Schema] + :return Schema + (ok *response*)))] + + (doseq [format ["application/json"]] + (testing format + + (testing "fails with invalid body" + (is (http/bad-request? (app (ring-request m format invalid-data))))) + + (testing "fails with invalid response" + (binding [*response* invalid-data] + (is (http/internal-server-error? (app (ring-request m format valid-data)))))) + + (testing "does not coerce response" + (binding [*response* valid-data] + (is (http/internal-server-error? (app (ring-request m format valid-data)))))) + + (testing "succeeds with valid body & response" + (binding [*response* valid-response-data] + (let [response (app (ring-request m format valid-data))] + (is (http/ok? response)) + (is (= valid-data (m/decode m format (:body response))))))))))))) + +(deftest static-contexts-just-work-test (let [app (context "/:a" [a] (GET "/:b" [b] (ok [a b])))] - (app {:request-method :get, :uri "/a/b"}) => (contains {:body ["a" "b"]}) - (app {:request-method :get, :uri "/a/c"}) => (contains {:body ["a" "c"]}))) + (is (= ["a" "b"] (:body (app {:request-method :get, :uri "/a/b"})))) + (is (= ["b" "c"] (:body (app {:request-method :get, :uri "/b/c"})))))) + +(deftest file-responses-dont-get-coerced-test + (let [app (api + {:formatter :muuntaja} + (swagger-routes) + (GET "/file" [] + :return File + (ok (io/file "project.clj"))))] + (let [{:keys [status body]} (app {:uri "/file", :request-method :get})] + (is-200-status status) + (is (instance? File body))))) + +(deftest nil-routes-are-ignored-test + (let [create-app (fn [{:keys [dev?]}] + (context "/api" [] + (GET "/ping" [] (ok)) + (context "/db" [] + (if dev? + (GET "/drop" [] (ok)))) + (if dev? + (context "/dev" [] + (GET "/tools" [] (ok))))))] + + (testing "with routes" + (let [app (create-app {:dev? true})] + (is (http/ok? (app {:request-method :get, :uri "/api/ping"}))) + (is (http/ok? (app {:request-method :get, :uri "/api/db/drop"}))) + (is (http/ok? (app {:request-method :get, :uri "/api/dev/tools"}))))) + + (testing "without routes" + (let [app (create-app {:dev? false})] + (is (http/ok? (app {:request-method :get, :uri "/api/ping"}))) + (is (nil? (app {:request-method :get, :uri "/api/db/drop"}))) + (is (nil? (app {:request-method :get, :uri "/api/dev/tools"}))))))) + +(deftest wrap-routes-test + (testing "simple middleware" + (let [called? (atom false) + app (api + {:formatter :muuntaja} + (route-middleware + [(fn [handler] + (fn [req] + (reset! called? true) + (handler req)))] + (GET "/a" [] + (ok {:ok true}))) + (GET "/b" [] + (ok {:ok true}))) + response (app {:uri "/a" + :request-method :get})] + (is (= (json-string {:ok true}) (-> response :body slurp))) + (testing "middleware is called" + (is @called?)) + + (reset! called? false) + (let [response (app {:uri "/b" + :request-method :get})] + (is (= (json-string {:ok true}) (-> response :body slurp))) + (is (not @called?))))) + + (testing "middleware with args" + (let [mw-value (atom nil) + app (api + {:formatter :muuntaja} + (route-middleware + [[(fn [handler value] + (fn [req] + (reset! mw-value value) + (handler req))) + :foo-bar]] + (GET "/a" [] + (ok {:ok true}))) + (GET "/b" [] + (ok {:ok true}))) + response (app {:uri "/a" + :request-method :get})] + (is (= (json-string {:ok true}) (-> response :body slurp))) + (testing "middleware is called" + (is (= :foo-bar @mw-value))) + + (reset! mw-value nil) + (let [response (app {:uri "/b" + :request-method :get})] + (is (= (json-string {:ok true}) (-> response :body slurp))) + (is (nil? @mw-value)))))) + +(deftest ring-handler-test + (let [app (api + {:formatter :muuntaja} + (GET "/ping" [] (ok))) + ring-app (c/ring-handler app)] + (testing "both work" + (is (some #{200} (get* app "/ping"))) + (is (some #{200} (get* ring-app "/ping")))) + (testing "ring-app is also a Fn" + (is (not (fn? app))) + (is (fn? ring-app))))) + +(deftest body-params-are-set-to-params-test + (let [app (api + {:formatter :muuntaja} + (POST "/echo" [x] (ok {:x x}))) + [status body] (post* app "/echo" (json-string {:x 1}))] + (is-200-status status) + (is (= {:x 1} body)))) + +;; #306 & #313" +(deftest body-in-error-handling-test + (let [app (api + {:formatter :muuntaja + :exceptions + {:handlers + {:compojure.api.exception/default + (fn [_ _ request] + (internal-server-error (:body-params request)))}}} + (POST "/error" [] + (throw (RuntimeException. "error")))) + [status body] (post* app "/error" (json-string {:kikka 6}))] + (is (= 500 status)) + (is (= {:kikka 6} body)))) + +(deftest sequential-routes-test + + (testing "context" + (let [app (api + {:formatter :muuntaja} + (context "/api" [] + (for [path ["/ping" "/pong"]] + (GET path [] (ok {:path path})))))] + + (testing "all routes can be invoked" + (let [[status body] (get* app "/api/ping")] + (is-200-status status) + (is (= {:path "/ping"} body))) + + (let [[status body] (get* app "/api/pong")] + (is-200-status status) + (is (= {:path "/pong"} body)))))) + + (testing "routes" + (let [app (api + {:formatter :muuntaja} + (routes + (for [path ["/ping" "/pong"]] + (GET path [] (ok {:path path})))))] + + (testing "all routes can be invoked" + (let [[status body] (get* app "/ping")] + (is-200-status status) + (is (= {:path "/ping"} body))) + + (let [[status body] (get* app "/pong")] + (is-200-status status) + (is (= {:path "/pong"} body))))))) + +(deftest wrap-format-issue-374-test + (let [data {:war "hammer"}] + + (testing "first api consumes the body" + (let [app (routes + (api + {:formatter :muuntaja} + (POST "/echo1" [] + :body [body s/Any] + (ok body))) + (api + {:formatter :muuntaja} + (POST "/echo2" [] + :body [body s/Any] + (ok body))))] + + (testing "first api sees the body" + (let [[status body] (post* app "/echo1" (json-string data))] + (is-200-status status) + (is (= data body)))) + + (testing "second api fails" + (let [[status] (post* app "/echo2" (json-string data))] + (is (= 400 status))))) + + (testing "wrap-format with defaults" + (let [app (-> (routes + (api + {:formatter :muuntaja} + (POST "/echo1" [] + :body [body s/Any] + (ok body))) + (api + {:formatter :muuntaja} + (POST "/echo2" [] + :body [body s/Any] + (ok body)))) + (mw/wrap-format))] + + (testing "first api sees the body" + (let [[status body] (post* app "/echo1" (json-string data))] + (is-200-status status) + (is (= data body)))) + + (testing "second api sees it too!" + (let [[status body] (post* app "/echo2" (json-string data))] + (is-200-status status) + (is (= data body)))))) + + (testing "wrap-format with configuration" + (let [muuntaja (m/create + (m/select-formats + m/default-options + ["application/json"])) + app (-> (routes + (api + {:formats nil + :swagger {:spec "/swagger1.json"}} + (POST "/echo1" [] + :body [body s/Any] + (ok body))) + (api + {:formats nil + :swagger {:spec "/swagger2.json"}} + (POST "/echo2" [] + :body [body s/Any] + (ok body)))) + (mw/wrap-format + {:formats muuntaja}))] + + (testing "first api sees the body" + (let [[status body] (post* app "/echo1" (json-string data))] + (is-200-status status) + (is (= data body)))) + + (testing "second api sees it too!" + (let [[status body] (post* app "/echo2" (json-string data))] + (is-200-status status) + (is (= data body)))) + + (testing "top-level muuntaja effect both" + (let [[status body] (get* app "/swagger1.json")] + (is-200-status status) + (is (= {:produces ["application/json"] + :consumes ["application/json"]} + (select-keys body [:produces :consumes])))) + (let [[status body] (get* app "/swagger2.json")] + (is-200-status status) + (is (= {:produces ["application/json"] + :consumes ["application/json"]} + (select-keys body [:produces :consumes])))))))))) + +;;"2.* will fail fast with :format" +(deftest compojure-2x-will-fail-fast-with-format-test + (let [app' `(api {:format (m/create)})] + (is (thrown? AssertionError (eval app'))))) + +(deftest Muuntaja-0-6-0-options-test + (testing "new formats" + (let [muuntaja (m/create + (-> m/default-options + (m/install muuntaja.format.msgpack/format) + (m/install muuntaja.format.yaml/format))) + data {:it "works!"} + app (api + {:formats muuntaja} + (POST "/echo" [] + :body [body s/Any] + (ok body)))] + (doseq [format ["application/json" + "application/edn" + "application/msgpack" + "application/x-yaml" + "application/transit+json" + "application/transit+msgpack"]] + (testing (str "format " (pr-str format)) + (let [{:keys [status body]} (app (ring-request muuntaja format data))] + (is-200-status status) + (is (= data (m/decode muuntaja format body)))))))) + + (testing "return types" + (doseq [[return type] {:input-stream ByteArrayInputStream + :bytes (class (make-array Byte/TYPE 0)) + :output-stream StreamableResponse}] + (let [app (api + {:formats (assoc m/default-options :return return)} + (GET "/" [] + (ok {:kikka "kukka"})))] + (testing (str "return " (pr-str return)) + (let [{:keys [status body]} (app {:uri "/", :request-method :get})] + (is-200-status status) + (is (instance? type body)))))))) diff --git a/test/compojure/api/meta_test.clj b/test/compojure/api/meta_test.clj index 68c1a03d..20332cac 100644 --- a/test/compojure/api/meta_test.clj +++ b/test/compojure/api/meta_test.clj @@ -1,7 +1,1702 @@ (ns compojure.api.meta-test - (:require [compojure.api.meta :refer :all] - [midje.sweet :refer :all])) + (:require [compojure.api.sweet :as sweet :refer :all] + [compojure.api.meta :as meta :refer [merge-parameters routing]] + [compojure.api.common :refer [merge-vector]] + [compojure.api.compojure-compat :refer [make-context]] + [clojure.data :as data] + [compojure.core :as cc :refer [let-request make-route wrap-routes]] + [clojure.walk :as walk] + [clojure.string :as str] + [clojure.pprint :as pp] + [clojure.test :refer [deftest is testing]] + [compojure.api.test-domain :refer [Pizza burger-routes]] + [compojure.api.test-utils :refer :all] + [compojure.api.exception :as ex] + [compojure.api.swagger :as swagger] + [ring.util.http-response :refer :all] + [ring.util.http-predicates :as http] + [schema.core :as s] + [ring.swagger.core :as rsc] + [ring.util.http-status :as status] + [compojure.api.middleware :as mw :refer [compose-middleware]] + [compojure.api.coercion :refer [coerce-request! wrap-coerce-response]] + [ring.swagger.middleware :as rsm] + [compojure.api.validator :as validator] + [compojure.api.request :as request] + [compojure.api.routes :as routes :refer [map->Route]] + [muuntaja.core :as m] + [compojure.api.core :as c] + [clojure.java.io :as io] + [muuntaja.format.msgpack] + [muuntaja.format.yaml] + [clojure.core.unify :as unify]) + (:import (java.sql SQLException SQLWarning) + (java.util.regex Pattern) + (muuntaja.protocols StreamableResponse) + (java.io File ByteArrayInputStream))) -(fact "src-coerce! with deprecated types" - (src-coerce! nil nil :query) => (throws AssertionError) - (src-coerce! nil nil :json) => (throws AssertionError)) +(set! *warn-on-reflection* true) + +(def macroexpand-2 (comp macroexpand-1 macroexpand-1)) + +(defn is-thrown-with-msg?* [is* ^Class cls re form f] + (try (f) + (is* false (str "Expected to throw: " form)) + (catch Throwable outer + (let [encountered-class-match (atom false)] + (loop [^Throwable e outer] + (let [matches-class (instance? cls e)] + (swap! encountered-class-match #(or % matches-class)) + (if (and matches-class + (some->> (.getMessage e) (re-find re))) + (is* true "") + (let [e' (some-> e .getCause)] + (if (identical? e' e) + (if @encountered-class-match + (is* false (str "Did not match exception message:\n" + (pr-str outer))) + (is* false (str "Did not find an exception of class " (.getName cls) ":\n" + (pr-str outer)))) + (recur e')))))))))) + +(defmacro ^:private is-thrown-with-msg?-with-is-fn [is* cls re e] + `(is-thrown-with-msg?* ~is* ~cls ~re '~e #(do ~e))) + +(defmacro is-thrown-with-msg? [cls re e] + `(is-thrown-with-msg?-with-is-fn (fn [v# msg#] (is v# msg#)) ~cls ~re ~e)) + +(defn subst-regex [^Pattern regex] + `(re-pattern ~(.pattern regex))) + +(defn- reify-records [form] + (walk/prewalk (fn [s] + (if (record? s) + (do (assert (not (contains? s :__record__))) + (into {:__record__ (.getName (class s))} s)) + s)) + form)) + +(defn- massage-expansion [form] + (walk/postwalk (fn [s] + (when (symbol? s) + (assert (not (str/starts-with? (name s) "?")) + "Form not allowed lvars")) + (if (instance? Pattern s) + (subst-regex s) + (if (= '& s) + :& ;; clojure.core.unify/wildcard? + s))) + (reify-records form))) + +(defn is-expands* [nsym form & expecteds] + (binding [*ns* (the-ns nsym)] + (let [expecteds (or expecteds [(list (gensym 'dummy))]) + ;; support `(let [?a 1] 1) => '(clojure.core/let [?a 1] 1) + ;; without having the lvars be qualified + expecteds (mapv (fn [expected] + (assert (and (seq? expected) (seq expected))) + (let [fexpected (first expected)] + (assert (symbol? fexpected) "First form of expected should be a symbol") + (assert (not (str/starts-with? (name fexpected) "?"))) + (->> expected + reify-records + (walk/postwalk (fn [s] + (if (and (symbol? s) + (or (str/starts-with? (name s) "?") + (= "+compojure-api-request+" (name s)))) + (symbol nil (name s)) + (if (instance? Pattern s) + (subst-regex s) + (if (= '& s) + :& ;; clojure.core.unify/wildcard? + s)))))))) + expecteds)] + (loop [form' (macroexpand-1 form) + seen [form form'] + [expected :as expecteds] expecteds] + (if-not (seq expecteds) + (is true) + (if-not (and (seq? form') (seq form')) + (is false) + (let [fform' (first form') + fexpected (first expected)] + (when (symbol? fform') + (assert (not (str/starts-with? (name fform') "?")))) + (if (= fform' fexpected) + (let [actual-form form' + form' (massage-expansion form') + unifies (unify/unify form' expected) + subst-expected (some->> unifies (unify/subst expected))] + (if (and unifies (= form' subst-expected)) + (if-some [expecteds (next expecteds)] + (let [actual-form' (macroexpand-1 actual-form)] + (if (identical? actual-form actual-form') + (is (empty? expecteds) + (str "No expansions matched pattern:\n" + (with-out-str (pp/pprint expected)) + "Seen:\n" + (with-out-str + (run! pp/pprint (interpose '=> (map massage-expansion seen)))))) + (recur actual-form' (conj seen actual-form') expecteds))) + (is true)) + (is false + (str "Did not match pattern:\n" + (with-out-str (pp/pprint expected)) + "\nExpansion:\n" + (with-out-str (pp/pprint form')) + (if unifies + (str "\nUnifies to:\n" + (with-out-str (pp/pprint subst-expected)) + "With substitution map:\n" + (with-out-str (pp/pprint unifies))) + (str "\nDoes not unify\n")))))) + (let [form'' (macroexpand-1 form')] + (if-not (identical? form' form'') + (recur form'' (conj seen form'') expecteds) + (is false + (str "No expansions matched pattern:\n" + (with-out-str (pp/pprint expected)) + "Seen:\n" + (with-out-str + (run! pp/pprint (interpose '=> (map massage-expansion seen)))))))))))))))) + +(defmacro is-expands [form & expected-exprs] + `(is-expands* '~(ns-name *ns*) '~form ~@expected-exprs)) + +(comment + (unify/unifier- + '(+ 1 2) + '(+ ?a ?b)) + + (unify/unifier- + '(+ 2) + '(+ ?a ?b)) + + (is-expands (+ 1 a) + (+ 1)) + + (unify/unify `(+ 1 a#) `(+ 1 ?a)) + (unify/unify `(+ 1) `(?a/+ 1)) + + (is-expands* *ns* `(+ 1 a#) `(+ 1 ?a)) + (expands `(+ 1 a#) `(+ 1 ?a)) + + (unify/subst + '(+ ?a ?b) + (unify/unify + `(~'+ a#) + '(+ ?a ?b) + )) + + + (let [orig ]) + + (-> `(GET "/ping" [] + :return String + (ok "kikka")) + macroexpand + pp/pprint + ) + + (-> (take 4 (iterate macroexpand-1 `(sweet/POST "/ping" []) )) + (nth 2) + second + :handler + (nth 2) + prn + ) + ) + +(deftest meta-expansion-test + (is-expands (sweet/GET "/ping" []) + `(c/GET "/ping" []) + `(map->Route + {:path "/ping", + :method :get, + :info (merge-parameters {}), + :handler + (make-route + :get + {:__record__ "clout.core.CompiledRoute" + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (do))))})) + (is-expands (sweet/POST "/ping" []) + `(c/POST "/ping" []) + `(map->Route + {:path "/ping", + :method :post, + :info (merge-parameters {}), + :handler + (make-route + :post + {:__record__ "clout.core.CompiledRoute" + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (do))))})) + (testing "static context" + (is-expands (context "/a" [] (POST "/ping" [])) + `(c/context "/a" [] (~'POST "/ping" [])) + `(map->Route + {:path "/a", + :childs + (delay + (flatten + ((fn [+compojure-api-request+] + (let-request + [[:as +compojure-api-request+] +compojure-api-request+] + [(~'POST "/ping" [])])) + {}))), + :method nil, + :info (merge-parameters {:static-context? true}), + :handler + (let [?form (routing [(~'POST "/ping" [])])] + (cc/context + "/a" + [:as +compojure-api-request+] + ?form))}))) + (testing "dynamic context" + (is-expands (context "/a" [] :dynamic true (POST "/ping" [])) + `(map->Route + {:path "/a", + :childs + (delay + (flatten + ((fn [+compojure-api-request+] + (let-request + [[:as +compojure-api-request+] +compojure-api-request+] + [(~'POST "/ping" [])])) + {}))), + :method nil, + :info (merge-parameters {:public {:dynamic true}}), + :handler + (cc/context "/a" + [:as +compojure-api-request+] + (routing [(~'POST "/ping" [])]))})))) + +(deftest is-thrown-with-msg?-test + (is-thrown-with-msg? Exception #"message" (throw (Exception. "message"))) + (is-thrown-with-msg? AssertionError #"Assert failed" (assert nil)) + (is-thrown-with-msg? Exception #"message" (throw (RuntimeException. (Exception. "message")))) + (let [a (atom []) + _ (is-thrown-with-msg?-with-is-fn + (fn [& args] (swap! a conj args)) + AssertionError + #"message" + (throw (RuntimeException. (Exception. "message"))))] + (let [[ret :as all] @a] + (when (is (= 1 (count all))) + (is (= false (first ret))) + (is (str/starts-with? (second ret) "Did not find an exception of class java.lang.AssertionError")))))) + +(deftest bad-body-test + (is-thrown-with-msg? + AssertionError + #"" + (macroexpand-2 + `(GET "/ping" [] + :body ~'[body :- EXPENSIVE] + (ok "kikka"))))) + +(deftest return-double-eval-test + (testing "no :return double expansion" + (is-expands (GET "/ping" [] + :return EXPENSIVE + (ok "kikka")) + `(let [?return {200 {:schema ~'EXPENSIVE, :description ""}}] + (map->Route + {:path "/ping", + :method :get, + :info (merge-parameters + {:public {:responses [?return]}}), + :handler + (wrap-routes + (make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (do ~'(ok "kikka"))))) + (compose-middleware + [[wrap-coerce-response + (merge-vector + [?return])]]))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :return (do (swap! times inc) String) + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :return (do (swap! times inc) String) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :return (do (swap! times inc) String) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :return (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req String])) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :return in static context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :return (do (swap! times update :outer inc) + String) + (GET "/ping" req + :return (do (swap! times update :inner inc) + String) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 1} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 1} @times)))) + (testing "bind :return in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :return (do (swap! times update :outer inc) + String) + (GET "/ping" req + :return (do (swap! times update :inner inc) + String) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(deftest coercion-double-eval-test + (testing "no :coercion double expansion" + (is-expands (GET "/ping" [] + :coercion EXPENSIVE + (ok "kikka")) + '(clojure.core/let + [?coercion172603 EXPENSIVE] + (compojure.api.routes/map->Route + {:path "/ping", + :method :get, + :info + (compojure.api.meta/merge-parameters {:coercion ?coercion172603}), + :handler + (compojure.core/wrap-routes + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re (clojure.core/re-pattern "/ping"), + :keys [], + :absolute? false} + (clojure.core/fn + [?request__3574__auto__] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request__3574__auto__] + (do (ok "kikka"))))) + (compojure.api.middleware/compose-middleware + [[compojure.api.middleware/wrap-coercion ?coercion172603]]))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :coercion (do (swap! times inc) identity) + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :coercion (do (swap! times inc) identity) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :coercion (do (swap! times inc) identity) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :coercion (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req identity])) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :coercion in static context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :coercion (do (swap! times update :outer inc) + String) + (GET "/ping" req + :coercion (do (swap! times update :inner inc) + identity) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 1} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 1} @times)))) + (testing "bind :coercion in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :coercion (do (swap! times update :outer inc) + identity) + (GET "/ping" req + :coercion (do (swap! times update :inner inc) + identity) + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(deftest body-double-eval-test + (testing "no :body double expansion" + (is-expands (GET "/ping" [] + :body [body EXPENSIVE] + (ok "kikka")) + `(let [?body-schema ~'EXPENSIVE] + (map->Route + {:path "/ping", + :method :get, + :info (merge-parameters + {:public {:parameters {:body ?body}}}) + :handler + (make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (let [~'body (compojure.api.coercion/coerce-request! + ?body + :body-params + :body + false + false + +compojure-api-request+)] + (do ~'(ok "kikka"))))))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :body [body (do (swap! times inc) s/Any)] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :body [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :body [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :body [body (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req s/Any]))] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :body in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :body [body s/Any])))) + (testing "bind :body in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :body [body (do (swap! times update :outer inc) + s/Any)] + (GET "/ping" req + :body [body (do (swap! times update :inner inc) + s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(deftest query-double-eval-test + (testing "no :query double expansion" + (is-expands (GET "/ping" [] + :query [query EXPENSIVE] + (ok "kikka")) + `(let [?query-schema ~'EXPENSIVE] + (map->Route + {:path "/ping", + :method :get, + :info (merge-parameters + {:public {:parameters {:query ?query-schema}}}) + :handler + (make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (let [~'query (compojure.api.coercion/coerce-request! + ?query-schema + :query-params + :string + true + false + +compojure-api-request+)] + (do ~'(ok "kikka"))))))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :query [body (do (swap! times inc) s/Any)] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :query [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :query [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :query [body (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req s/Any]))] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :query in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :query [body# s/Any])))) + (testing "bind :query in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :query [body (do (swap! times update :outer inc) + s/Any)] + (GET "/ping" req + :query [body (do (swap! times update :inner inc) + s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(deftest responses-double-eval-test + (testing "no :responses double expansion" + (is-expands (GET "/ping" [] + :responses {200 {:schema EXPENSIVE}} + (ok "kikka")) + `(let [?responses {200 {:schema ~'EXPENSIVE}}] + (map->Route + {:path "/ping", + :method :get, + :info (merge-parameters + {:public {:responses [?responses]}}), + :handler + (wrap-routes + (make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (do ~'(ok "kikka"))))) + (compose-middleware + [[wrap-coerce-response + (merge-vector + [?responses])]]))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :responses {200 (do (swap! times inc) String)} + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :responses {200 (do (swap! times inc) String)} + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :responses {200 (do (swap! times inc) String)} + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :responses {200 (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req String]))} + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :responses in static context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :responses {200 (do (swap! times update :outer inc) + String)} + (GET "/ping" req + :responses {200 (do (swap! times update :inner inc) + String)} + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 1} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 1} @times)))) + (testing "bind :responses in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :responses {200 (do (swap! times update :outer inc) + String)} + (GET "/ping" req + :responses {200 (do (swap! times update :inner inc) + String)} + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(deftest headers-double-eval-test + (testing "no :headers double expansion" + (is-expands (GET "/ping" [] + :headers [headers EXPENSIVE] + (ok "kikka")) + `(let [?headers-schema ~'EXPENSIVE] + (map->Route + {:path "/ping", + :method :get, + :info (merge-parameters + {:public {:parameters {:header ?headers-schema}}}) + :handler + (make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re #"/ping", + :keys [], + :absolute? false} + (fn [?request] + (let-request [[:as +compojure-api-request+] ?request] + (let [~'headers (compojure.api.coercion/coerce-request! + ?headers-schema + :headers + :string + true + false + +compojure-api-request+)] + (do ~'(ok "kikka"))))))})))) + (testing "no context" + (let [times (atom 0) + route (GET "/ping" [] + :headers [body (do (swap! times inc) s/Any)] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "inferred static context" + (let [times (atom 0) + route (context + "" [] + (GET "/ping" [] + :headers [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 1 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 1 @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom 0) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :headers [body (do (swap! times inc) s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom 0) + route (context + "" req + (GET "/ping" req + :headers [body (do (swap! times inc) + ;; should never lift this since it refers to request + (second [req s/Any]))] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= 0 @times)) + (exercise) + (is (= 1 @times)) + (dorun (repeatedly 10 exercise)) + (is (= 11 @times)))) + (testing "bind :headers in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :headers [body# s/Any])))) + (testing "bind :headers in dynamic context" + (let [times (atom {:outer 0 :inner 0}) + route (context + "" [] + :dynamic true + :headers [body (do (swap! times update :outer inc) + s/Any)] + (GET "/ping" req + :headers [body (do (swap! times update :inner inc) + s/Any)] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:request-method :get :uri "/ping"}))))] + (is (= {:outer 1 :inner 0} @times)) + (exercise) + (is (= {:outer 1 :inner 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:outer 1 :inner 11} @times))))) + +(comment + (let [EXPENSIVE 1] + (GET "/ping" [] + :body-params [field :- EXPENSIVE, field2, {default :- s/Int 42} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka"))) + (macroexpand-1 (GET "/ping" [] + :body-params [{field :-}] + (ok "kikka"))) + (macroexpand-1 (GET "/ping" [] + :body-params [:as b :- s/Int] + (ok "kikka"))) + (macroexpand-1 + '(plumbing.core/letk + [[field :- ?field-schema + field2 :- ?field2-schema + {default :- ?default-schema (inc 42)} + & foo :- {?extra-keys ?extra-vals} + :as all] + (coerce-request! ?body-schema :body-params :body true false +compojure-api-request+)] + nil)) +) + +(deftest body-params-double-eval-test + (testing "no :body-params double expansion" + (is-expands (GET "/ping" [] + :body-params [field :- EXPENSIVE, field2, {default :- s/Int (inc 42)} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka")) + '(clojure.core/let + [?body-params-schema {s/Keyword s/Keyword, + :field EXPENSIVE, + :field2 schema.core/Any, + (clojure.core/with-meta + (schema.core/optional-key :default) + {:default '(inc 42)}) + s/Int}] + (compojure.api.routes/map->Route + {:path "/ping", + :method :get, + :info + (compojure.api.meta/merge-parameters + {:public {:parameters {:body ?body-params-schema}}}), + :handler + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re (clojure.core/re-pattern "/ping"), + :keys [], + :absolute? false} + (clojure.core/fn + [?request] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request] + (plumbing.core/letk + ;; Note: these schemas are just cosmetic. if a future plumbing uses + ;; them, the runtime tests below will fail. + [[field :- EXPENSIVE + ;;Note: default is recalculated each time + field2 {default :- s/Int (inc 42)} + :& foo :- {s/Keyword s/Keyword} + :as all] + (compojure.api.coercion/coerce-request! + ?body-params-schema + :body-params + :body + true + false + +compojure-api-request+)] + (do (ok "kikka"))))))})))) + (testing "no context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (GET "/ping" [] + :body-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:body-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "inferred static context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + (GET "/ping" [] + :body-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:body-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :body-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:body-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" req + (GET "/ping" req + :body-params [field :- (record :field (second [req String])) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:body-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "bind :body-params in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :body-params [field# :- s/Str])))) + (testing "bind :body-params in dynamic context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" req + :body-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:body-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times))))) + +(deftest form-params-double-eval-test + (testing "no :form-params double expansion" + (is-expands (GET "/ping" [] + :form-params [field :- EXPENSIVE, field2, {default :- s/Int (inc 42)} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka")) + '(clojure.core/let + [?form-params-schema108882 + {s/Keyword s/Keyword, + :field EXPENSIVE, + :field2 schema.core/Any, + (clojure.core/with-meta + (schema.core/optional-key :default) + {:default '(inc 42)}) + s/Int}] + (compojure.api.routes/map->Route + {:path "/ping", + :method :get, + :info + (compojure.api.meta/merge-parameters + {:public + {:parameters {:formData ?form-params-schema108882}, + :consumes ["application/x-www-form-urlencoded"]}}), + :handler + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re (clojure.core/re-pattern "/ping"), + :keys [], + :absolute? false} + (clojure.core/fn + [?request__3574__auto__] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request__3574__auto__] + (plumbing.core/letk + [[field :- EXPENSIVE + field2 {default :-, s/Int (inc 42)} + :& foo :- {s/Keyword s/Keyword} + :as all] + (compojure.api.coercion/coerce-request! + ?form-params-schema108882 + :form-params + :string + true + false + +compojure-api-request+)] + (do (ok "kikka"))))))})))) + (testing "no context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (GET "/ping" [] + :form-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:form-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "inferred static context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + (GET "/ping" [] + :form-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:form-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :form-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:form-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" req + (GET "/ping" req + :form-params [field :- (record :field (second [req String])) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:form-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "bind :form-params in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :form-params [field# :- s/Str])))) + (testing "bind :form-params in dynamic context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" req + :form-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:form-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times))))) + +(deftest multipart-params-double-eval-test + (testing "no :multipart-params double expansion" + (is-expands (GET "/ping" [] + :multipart-params [field :- EXPENSIVE, field2, {default :- s/Int (inc 42)} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka")) + '(clojure.core/let + [?multipart-params-schema108882 + {s/Keyword s/Keyword, + :field EXPENSIVE, + :field2 schema.core/Any, + (clojure.core/with-meta + (schema.core/optional-key :default) + {:default '(inc 42)}) + s/Int}] + (compojure.api.routes/map->Route + {:path "/ping", + :method :get, + :info + (compojure.api.meta/merge-parameters + {:public + {:parameters {:formData ?multipart-params-schema108882}, + :consumes ["multipart/form-data"]}}), + :handler + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re (clojure.core/re-pattern "/ping"), + :keys [], + :absolute? false} + (clojure.core/fn + [?request__3574__auto__] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request__3574__auto__] + (plumbing.core/letk + [[field :- EXPENSIVE + field2 {default :-, s/Int (inc 42)} + :& foo :- {s/Keyword s/Keyword} + :as all] + (compojure.api.coercion/coerce-request! + ?multipart-params-schema108882 + :multipart-params + :string + true + false + +compojure-api-request+)] + (do (ok "kikka"))))))})))) + (testing "no context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (GET "/ping" [] + :multipart-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:multipart-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "inferred static context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + (GET "/ping" [] + :multipart-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:multipart-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :multipart-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:multipart-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" req + (GET "/ping" req + :multipart-params [field :- (record :field (second [req String])) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:multipart-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "bind :multipart-params in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :multipart-params [field# :- s/Str])))) + (testing "bind :multipart-params in dynamic context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" req + :multipart-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:multipart-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times))))) + +(deftest query-params-double-eval-test + (testing "no :query-params double expansion" + (is-expands (GET "/ping" [] + :query-params [field :- EXPENSIVE, field2, {default :- s/Int (inc 42)} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka")) + '(clojure.core/let + [?query-params-schema108882 + {s/Keyword s/Keyword, + :field EXPENSIVE, + :field2 schema.core/Any, + (clojure.core/with-meta + (schema.core/optional-key :default) + {:default '(inc 42)}) + s/Int}] + (compojure.api.routes/map->Route + {:path "/ping", + :method :get, + :info + (compojure.api.meta/merge-parameters + {:public {:parameters {:query ?query-params-schema108882}}}), + :handler + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping", + :re (clojure.core/re-pattern "/ping"), + :keys [], + :absolute? false} + (clojure.core/fn + [?request__3574__auto__] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request__3574__auto__] + (plumbing.core/letk + [[field :- EXPENSIVE + field2 {default :-, s/Int (inc 42)} + :& foo :- {s/Keyword s/Keyword} + :as all] + (compojure.api.coercion/coerce-request! + ?query-params-schema108882 + :query-params + :string + true + false + +compojure-api-request+)] + (do (ok "kikka"))))))})))) + (testing "no context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (GET "/ping" [] + :query-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:query-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "inferred static context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + (GET "/ping" [] + :query-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:query-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" [] + :query-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:query-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" req + (GET "/ping" req + :query-params [field :- (record :field (second [req String])) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:query-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "bind :query-params in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :query-params [field :- (record :field s/Str)])))) + (testing "bind :query-params in dynamic context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping" req + :query-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:query-params {:field "a" :field2 2} :request-method :get :uri "/ping"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times))))) + +(deftest path-params-double-eval-test + (testing "no :path-params double expansion" + (is-expands (GET "/ping/:field/:field2/:default" [] + :path-params [field :- EXPENSIVE, field2, {default :- s/Int (inc 42)} & foo :- {s/Keyword s/Keyword} :as all] + (ok "kikka")) + '(clojure.core/let + [?form-params-schema152468 + {s/Keyword s/Keyword, + :field EXPENSIVE, + :field2 schema.core/Any, + (clojure.core/with-meta + (schema.core/optional-key :default) + {:default '(inc 42)}) + s/Int}] + (compojure.api.routes/map->Route + {:path "/ping/:field/:field2/:default", + :method :get, + :info + (compojure.api.meta/merge-parameters + {:public {:parameters {:path ?form-params-schema152468}}}), + :handler + (compojure.core/make-route + :get + {:__record__ "clout.core.CompiledRoute", + :source "/ping/:field/:field2/:default", + :re + (clojure.core/re-pattern + "/ping/([^/,;?]+)/([^/,;?]+)/([^/,;?]+)"), + :keys [:field :field2 :default], + :absolute? false} + (clojure.core/fn + [?request__3574__auto__] + (compojure.core/let-request + [[:as +compojure-api-request+] ?request__3574__auto__] + (plumbing.core/letk + [[field + :- + EXPENSIVE + field2 + {default :-, s/Int (inc 42)} + :& + foo + :- + #:s{Keyword s/Keyword} + :as + all] + (compojure.api.coercion/coerce-request! + ?form-params-schema152468 + :route-params + :string + true + false + +compojure-api-request+)] + (do (ok "kikka"))))))})))) + (testing "no context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (GET "/ping/:field/:field2/:default" [] + :path-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka")) + exercise #(is (= "kikka" (:body (route {:path-params {:field "a" :field2 2} :request-method :get :uri "/ping/1/2/3"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "inferred static context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + (GET "/ping/:field/:field2/:default" [] + :path-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:path-params {:field "a" :field2 2} :request-method :get :uri "/ping/1/2/3"}))))] + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 11} @times)))) + (testing "dynamic context that doesn't bind variables" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping/:field/:field2/:default" [] + :path-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:path-params {:field "a" :field2 2} :request-method :get :uri "/ping/1/2/3"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "dynamic context that binds req and uses it in schema" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" req + (GET "/ping/:field/:field2/:default" req + :path-params [field :- (record :field (second [req String])) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:path-params {:field "a" :field2 2} :request-method :get :uri "/ping/1/2/3"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times)))) + (testing "bind :path-params in static context" + (is-thrown-with-msg? + AssertionError + #"cannot be :static" + (eval `(context + "" [] + :static true + :path-params [field :- (record :field s/Str)])))) + (testing "bind :path-params in dynamic context" + (let [times (atom {}) + record (fn [path schema] (swap! times update path (fnil inc 0)) schema) + route (context + "" [] + :dynamic true + (GET "/ping/:field/:field2/:default" req + :path-params [field :- (record :field s/Str) + field2 {default :- (record :default s/Int) (record :default-never (inc 42))} + & foo :- {(record :extra-keys s/Keyword) + (record :extra-vals s/Keyword)} :as all] + (ok "kikka"))) + exercise #(is (= "kikka" (:body (route {:path-params {:field "a" :field2 2} :request-method :get :uri "/ping/1/2/3"}))))] + (is (= {} @times)) + (exercise) + (is (= {:field 1 :default 1 :extra-keys 1 :extra-vals 1 :default-never 1} @times)) + (dorun (repeatedly 10 exercise)) + (is (= {:field 11 :default 11 :extra-keys 11 :extra-vals 11 :default-never 11} @times))))) diff --git a/test/compojure/api/middleware_test.clj b/test/compojure/api/middleware_test.clj index 5e1fc64e..8b0d6b49 100644 --- a/test/compojure/api/middleware_test.clj +++ b/test/compojure/api/middleware_test.clj @@ -1,12 +1,11 @@ (ns compojure.api.middleware-test (:require [compojure.api.middleware :refer :all] [compojure.api.exception :as ex] - [midje.sweet :refer :all] + [clojure.test :refer [deftest is testing]] [ring.util.http-response :refer [ok]] [ring.util.http-status :as status] - ring.util.test - [slingshot.slingshot :refer [throw+]]) - (:import [java.io PrintStream ByteArrayOutputStream])) + [ring.util.test]) + (:import (java.io PrintStream ByteArrayOutputStream))) (defmacro without-err "Evaluates exprs in a context in which *err* is bound to a fresh @@ -21,68 +20,86 @@ (finally (System/setErr err#))))) -(facts serializable? - (tabular - (fact - (serializable? nil - {:body ?body - :compojure.api.meta/serializable? ?serializable?}) => ?res) - ?body ?serializable? ?res - 5 true true - 5 false false - "foobar" true true - "foobar" false false +(deftest encode?-test + (doseq [[?body ?serializable? ?res :as test-case] + [[5 true true] + [5 false false] + ["foobar" true true] + ["foobar" false false] + [{:foobar "1"} false true] + [{:foobar "1"} true true] + [[1 2 3] false true] + [[1 2 3] true true] + [(ring.util.test/string-input-stream "foobar") false false]]] - {:foobar "1"} false true - {:foobar "1"} true true - [1 2 3] false true - [1 2 3] true true - - (ring.util.test/string-input-stream "foobar") false false)) + (testing (pr-str test-case) + (is (= (encode? nil + {:body ?body + :compojure.api.meta/serializable? ?serializable?}) + ?res))))) (def default-options (:exceptions api-middleware-defaults)) -(facts "wrap-exceptions" +(defn- call-async [handler request] + (let [result (promise)] + (handler request #(result [:ok %]) #(result [:fail %])) + (if-let [[status value] (deref result 1500 nil)] + (if (= status :ok) + value + (throw value)) + (throw (Exception. "Timeout while waiting for the request handler."))))) + +(deftest wrap-exceptions-test (with-out-str (without-err (let [exception (RuntimeException. "kosh") exception-class (.getName (.getClass exception)) handler (-> (fn [_] (throw exception)) - (wrap-exceptions default-options))] + (wrap-exceptions default-options)) + async-handler (-> (fn [_ _ raise] (raise exception)) + (wrap-exceptions default-options))] - (fact "converts exceptions into safe internal server errors" - (handler {}) => (contains {:status status/internal-server-error - :body (contains {:class exception-class - :type "unknown-exception"})}))))) + (testing "converts exceptions into safe internal server errors" + (is (= {:status status/internal-server-error + :body {:class exception-class + :type "unknown-exception"}} + (-> (handler {}) + (select-keys [:status :body])))) + (is (= {:status status/internal-server-error + :body {:class exception-class + :type "unknown-exception"}} + (-> (call-async async-handler {}) + (select-keys [:status :body])))))))) (with-out-str (without-err - (fact "Slingshot exception map type can be matched" - (let [handler (-> (fn [_] (throw+ {:type ::test} (RuntimeException. "kosh"))) + (testing "Thrown ex-info type can be matched" + (let [handler (-> (fn [_] (throw (ex-info "kosh" {:type ::test}))) (wrap-exceptions (assoc-in default-options [:handlers ::test] (fn [ex _ _] {:status 500 :body "hello"}))))] - (handler {}) => (contains {:status status/internal-server-error - :body "hello"}))))) + (is (= {:status status/internal-server-error + :body "hello"} + (select-keys (handler {}) [:status :body]))))))) (without-err - (fact "Default handler logs exceptions to console" + (testing "Default handler logs exceptions to console" (let [handler (-> (fn [_] (throw (RuntimeException. "kosh"))) (wrap-exceptions default-options))] - (with-out-str (handler {})) => "ERROR kosh\n"))) + (is (= "ERROR kosh\n" (with-out-str (handler {}))))))) (without-err - (fact "Default request-parsing handler does not log messages" + (testing "Default request-parsing handler does not log messages" (let [handler (-> (fn [_] (throw (ex-info "Error parsing request" {:type ::ex/request-parsing} (RuntimeException. "Kosh")))) (wrap-exceptions default-options))] - (with-out-str (handler {})) => ""))) + (is (= "" (with-out-str (handler {}))))))) (without-err - (fact "Logging can be added to a exception handler" + (testing "Logging can be added to a exception handler" (let [handler (-> (fn [_] (throw (ex-info "Error parsing request" {:type ::ex/request-parsing} (RuntimeException. "Kosh")))) (wrap-exceptions (assoc-in default-options [:handlers ::ex/request-parsing] (ex/with-logging ex/request-parsing-handler :info))))] - (with-out-str (handler {})) => "INFO Error parsing request\n")))) + (is (= "INFO Error parsing request\n" (with-out-str (handler {})))))))) -(facts "compose-middeleware strips nils aways. #228" +(deftest issue-228-test ; "compose-middeleware strips nils aways. #228" (let [times2-mw (fn [handler] (fn [request] (* 2 (handler request))))] - (((compose-middleware [nil times2-mw nil]) (constantly 3)) anything) => 6)) + (is (= 6 (((compose-middleware [nil times2-mw nil]) (constantly 3)) nil))))) diff --git a/test/compojure/api/perf_test.clj b/test/compojure/api/perf_test.clj index ce960b2a..30c204bd 100644 --- a/test/compojure/api/perf_test.clj +++ b/test/compojure/api/perf_test.clj @@ -4,9 +4,8 @@ [criterium.core :as cc] [ring.util.http-response :refer :all] [schema.core :as s] - [clojure.java.io :as io] - [cheshire.core :as json] - [cheshire.core :as cheshire]) + [muuntaja.core :as m] + [clojure.java.io :as io]) (:import (java.io ByteArrayInputStream))) ;; @@ -36,13 +35,11 @@ (-> (app {:uri uri :request-method :post - :content-type "application/json" + :headers {"content-type" "application/json"} :body (io/input-stream (.getBytes json))}) :body slurp)) -(defn parse [s] (json/parse-string s true)) - (s/defschema Order {:id s/Str :name s/Str (s/optional-key :description) s/Str @@ -52,38 +49,46 @@ :price s/Any :shipping s/Bool}]}) +;; slurps also the body, which is not needed in real life! (defn bench [] ; 27µs ; 27µs (-0%) ; 25µs (1.0.0) + ; 25µs (muuntaja) + ; 32µs (jsonista) (let [app (api (GET "/30" [] (ok {:result 30}))) call #(h/get* app "/30")] (title "GET JSON") + (println (call)) (assert (= {:result 30} (second (call)))) - (cc/bench (call))) + (cc/quick-bench (call))) ;; 73µs ;; 53µs (-27%) ;; 50µs (1.0.0) + ;; 38µs (muuntaja), -24% + ;; 34µs (muuntaja), -11% (let [app (api (POST "/plus" [] :return {:result s/Int} :body-params [x :- s/Int, y :- s/Int] (ok {:result (+ x y)}))) - data (h/json {:x 10, :y 20}) + data (h/json-string {:x 10, :y 20}) call #(post* app "/plus" data)] (title "JSON POST with 2-way coercion") - (assert (= {:result 30} (parse (call)))) - (cc/bench (call))) + (assert (= {:result 30} (h/parse (call)))) + (cc/quick-bench (call))) ;; 85µs ;; 67µs (-21%) ;; 66µs (1.0.0) + ;; 56µs (muuntaja), -15% + ;; 49µs (jsonista), -13% (let [app (api (context "/a" [] (context "/b" [] @@ -92,22 +97,24 @@ :return {:result s/Int} :body-params [x :- s/Int, y :- s/Int] (ok {:result (+ x y)})))))) - data (h/json {:x 10, :y 20}) + data (h/json-string {:x 10, :y 20}) call #(post* app "/a/b/c/plus" data)] (title "JSON POST with 2-way coercion + contexts") - (assert (= {:result 30} (parse (call)))) - (cc/bench (call))) + (assert (= {:result 30} (h/parse (call)))) + (cc/quick-bench (call))) ;; 266µs ;; 156µs (-41%) ;; 146µs (1.0.0) + ;; 74µs (muuntaja), -49% + ;; 51µs (jsonista), -30% (let [app (api (POST "/echo" [] :return Order :body [order Order] (ok order))) - data (h/json {:id "123" + data (h/json-string {:id "123" :name "Tommi's order" :description "Totally great order" :address {:street "Randomstreet 123" @@ -121,8 +128,8 @@ call #(post* app "/echo" data)] (title "JSON POST with nested data") - (s/validate Order (parse (call))) - (cc/bench (call)))) + (s/validate Order (h/parse (call))) + (cc/quick-bench (call)))) (defn resource-bench [] @@ -132,27 +139,29 @@ (ok {:result (+ x y)}))}}] ;; 62µs + ;; 44µs (muuntaja) (let [my-resource (resource resource-map) app (api (context "/plus" [] my-resource)) - data (h/json {:x 10, :y 20}) + data (h/json-string {:x 10, :y 20}) call #(post* app "/plus" data)] (title "JSON POST to pre-defined resource with 2-way coercion") - (assert (= {:result 30} (parse (call)))) - (cc/bench (call))) + (assert (= {:result 30} (h/parse (call)))) + (cc/quick-bench (call))) ;; 68µs + ;; 52µs (muuntaja) (let [app (api (context "/plus" [] (resource resource-map))) - data (h/json {:x 10, :y 20}) + data (h/json-string {:x 10, :y 20}) call #(post* app "/plus" data)] (title "JSON POST to inlined resource with 2-way coercion") - (assert (= {:result 30} (parse (call)))) - (cc/bench (call))) + (assert (= {:result 30} (h/parse (call)))) + (cc/quick-bench (call))) ;; 26µs (let [my-resource (resource resource-map) @@ -162,7 +171,7 @@ (title "direct POST to pre-defined resource with 2-way coercion") (assert (= {:result 30} (:body (call)))) - (cc/bench (call))) + (cc/quick-bench (call))) ;; 30µs (let [my-resource (resource resource-map) @@ -173,7 +182,7 @@ (title "POST to pre-defined resource with 2-way coercion") (assert (= {:result 30} (:body (call)))) - (cc/bench (call))) + (cc/quick-bench (call))) ;; 40µs (let [app (context "/plus" [] @@ -183,7 +192,7 @@ (title "POST to inlined resource with 2-way coercion") (assert (= {:result 30} (:body (call)))) - (cc/bench (call))))) + (cc/quick-bench (call))))) (defn e2e-json-comparison-different-payloads [] (let [json-request (fn [data] @@ -191,12 +200,13 @@ :request-method :post :headers {"content-type" "application/json" "accept" "application/json"} - :body (cheshire/generate-string data)}) + :body (h/json-string data)}) request-stream (fn [request] (let [b (.getBytes ^String (:body request))] (fn [] (assoc request :body (ByteArrayInputStream. b))))) app (api + {:formats (assoc m/default-options :return :bytes)} (POST "/echo" [] :body [body s/Any] (ok body)))] @@ -205,33 +215,125 @@ "dev-resources/json/json1k.json" "dev-resources/json/json10k.json" "dev-resources/json/json100k.json"] - :let [data (cheshire/parse-string (slurp file)) + :let [data (h/parse (slurp file)) request (json-request data) request! (request-stream request)]] "10b" ;; 42µs + ;; 24µs (muuntaja), -43% + ;; 18µs (muuntaja+jsonista), -43% "100b" ;; 79µs + ;; 39µs (muuntaja), -50% + ;; 20µs (muuntaja+jsonista), -44% "1k" ;; 367µs + ;; 92µs (muuntaja), -75% + ;; 29µs (muuntaja+jsonista), -65% "10k" ;; 2870µs + ;; 837µs (muuntaja), -70% + ;; 147µs (muuntaja+jsonista) -81% "100k" ;; 10800µs + ;; 8050µs (muuuntaja), -25% + ;; 1260µs (muuntaja+jsonista 0.5.0) -84% + + (title file) + (cc/quick-bench (-> (request!) app :body slurp))))) + +(defn e2e-json-comparison-different-payloads-no-slurp [] + (let [json-request (fn [data] + {:uri "/echo" + :request-method :post + :headers {"content-type" "application/json" + "accept" "application/json"} + :body (h/json-string data)}) + request-stream (fn [request] + (let [b (.getBytes ^String (:body request))] + (fn [] + (assoc request :body (ByteArrayInputStream. b))))) + app (api + {:formats (assoc m/default-options :return :bytes)} + (POST "/echo" [] + :body [body s/Any] + (ok body)))] + (doseq [file ["dev-resources/json/json10b.json" + "dev-resources/json/json100b.json" + "dev-resources/json/json1k.json" + "dev-resources/json/json10k.json" + "dev-resources/json/json100k.json"] + :let [data (h/parse (slurp file)) + request (json-request data) + request! (request-stream request)]] + + "10b" + ;; 38µs (1.x) + ;; 14µs (2.0.0-alpha21) + + "100b" + ;; 74µs (1.x) + ;; 16µs (2.0.0-alpha21) + + "1k" + ;; 322µs (1.x) + ;; 24µs (2.0.0-alpha21) + + "10k" + ;; 3300µs (1.x) + ;; 120µs (2.0.0-alpha21) + + "100k" + ;; 10600µs (1.x) + ;; 1000µs (2.0.0-alpha21) (title file) - (cc/bench (-> (request!) app :body slurp))))) + ;;(println (-> (request!) app :body slurp)) + (cc/quick-bench (app (request!)))))) (comment (bench) (resource-bench) - (e2e-json-comparison-different-payloads)) + (e2e-json-comparison-different-payloads) + (e2e-json-comparison-different-payloads-no-slurp)) (comment (bench) (resource-bench)) + +(comment + (let [api1 (api + (GET "/30" [] (ok))) + api2 (api + {:api {:disable-api-middleware? true}} + (GET "/30" [] (ok))) + app (GET "/30" [] (ok)) + + request {:request-method :get, :uri "/30"} + count 100000 + call1 #(api1 request) + call2 #(api2 request) + call3 #(app request)] + + (title "api1") + (time + (dotimes [_ count] + (call1))) + (cc/quick-bench (call1)) + + (title "api2") + (time + (dotimes [_ count] + (call2))) + #_(cc/quick-bench (call2)) + + (title "app") + (time + (dotimes [_ count] + (call3))) + #_(cc/quick-bench (call3)))) diff --git a/test/compojure/api/resource_test.clj b/test/compojure/api/resource_test.clj index fbe6ccb5..0b394fe4 100644 --- a/test/compojure/api/resource_test.clj +++ b/test/compojure/api/resource_test.clj @@ -2,58 +2,73 @@ (:require [compojure.api.sweet :refer :all] [compojure.api.test-utils :refer :all] [plumbing.core :refer [fnk]] - [midje.sweet :refer :all] + [clojure.test :refer [deftest is testing]] [ring.util.http-response :refer :all] - [schema.core :as s]) - (:import [clojure.lang ExceptionInfo])) + [clojure.core.async :as a] + [schema.core :as s] + [compojure.api.test-utils :refer [call]]) + (:import (clojure.lang ExceptionInfo))) -(defn has-body [expected] - (fn [{:keys [body]}] - (= body expected))) +(defn is-has-body [expected {:keys [body]}] + (is (= body expected))) -(def request-validation-failed? - (throws ExceptionInfo #"Request validation failed")) +(defmacro is-request-validation-failed? [form] + `(is (~'thrown? ExceptionInfo #"Request validation failed" ~form))) -(def response-validation-failed? - (throws ExceptionInfo #"Response validation failed")) +(defmacro is-response-validation-failed? [form] + `(is (~'thrown? ExceptionInfo #"Response validation failed" ~form))) -(facts "resource definitions" +(deftest resource-definitions-test - (fact "only top-level handler" + (testing "only top-level handler" (let [handler (resource {:handler (constantly (ok {:total 10}))})] - (fact "paths and methods don't matter" - (handler {:request-method :get, :uri "/"}) => (has-body {:total 10}) - (handler {:request-method :head, :uri "/kikka"}) => (has-body {:total 10})))) + (testing "paths and methods don't matter" + (is-has-body {:total 10} (call handler {:request-method :get, :uri "/"})) + (is-has-body {:total 10} (call handler {:request-method :head, :uri "/kikka"}))))) - (fact "top-level parameter coercions" + (testing "top-level parameter coercions" (let [handler (resource {:parameters {:query-params {:x Long}} :handler (fnk [[:query-params x]] (ok {:total x}))})] - (handler {:request-method :get}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "1"}}) => (has-body {:total 1}) - (handler {:request-method :get, :query-params {:x "1", :y "2"}}) => (has-body {:total 1}))) + (is-request-validation-failed? (call handler {:request-method :get})) + (is-has-body {:total 1} (call handler {:request-method :get, :query-params {:x "1"}})) + (is-has-body {:total 1} (call handler {:request-method :get, :query-params {:x "1", :y "2"}})))) - (fact "top-level and operation-level parameter coercions" + (testing "top-level and operation-level parameter coercions" (let [handler (resource {:parameters {:query-params {:x Long}} :get {:parameters {:query-params {(s/optional-key :y) Long}}} :handler (fnk [[:query-params x {y 0}]] (ok {:total (+ x y)}))})] - (handler {:request-method :get}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "1"}}) => (has-body {:total 1}) - (handler {:request-method :get, :query-params {:x "1", :y "a"}}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "1", :y "2"}}) => (has-body {:total 3}) - - (fact "non-matching operation level parameters are not used" - (handler {:request-method :post, :query-params {:x "1"}}) => (has-body {:total 1}) - (handler {:request-method :post, :query-params {:x "1", :y "2"}}) => (throws ClassCastException)))) - - (fact "operation-level handlers" + (is-request-validation-failed? (call handler {:request-method :get})) + (is-has-body {:total 1} (call handler {:request-method :get, :query-params {:x "1"}})) + (is-request-validation-failed? (call handler {:request-method :get, :query-params {:x "1", :y "a"}})) + (is-has-body {:total 3} (call handler {:request-method :get, :query-params {:x "1", :y "2"}})) + + (testing "non-matching operation level parameters are not used" + (is-has-body {:total 1} (call handler {:request-method :post, :query-params {:x "1"}})) + (is (thrown? ClassCastException (call handler {:request-method :post, :query-params {:x "1", :y "2"}})))))) + + (testing "middleware" + (let [mw (fn [handler k] (fn [req] (update-in (handler req) [:body :mw] (fnil conj '()) k))) + handler (resource + {:middleware [[mw :top1] [mw :top2]] + :get {:middleware [[mw :get1] [mw :get2]]} + :post {:middleware [[mw :post1] [mw :post2]]} + :handler (constantly (ok))})] + + (testing "top + method-level mw are applied if they are set" + (is-has-body {:mw [:top1 :top2 :get1 :get2]} (call handler {:request-method :get})) + (is-has-body {:mw [:top1 :top2 :post1 :post2]} (call handler {:request-method :post}))) + (testing "top-level mw are applied if method doesn't have mw" + (is-has-body {:mw [:top1 :top2]} (call handler {:request-method :put}))))) + + (testing "operation-level handlers" (let [handler (resource {:parameters {:query-params {:x Long}} :get {:parameters {:query-params {(s/optional-key :y) Long}} @@ -61,37 +76,37 @@ (ok {:total (+ x y)}))} :post {:parameters {:query-params {:z Long}}}})] - (handler {:request-method :get}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "1"}}) => (has-body {:total 1}) - (handler {:request-method :get, :query-params {:x "1", :y "a"}}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "1", :y "2"}}) => (has-body {:total 3}) + (is-request-validation-failed? (call handler {:request-method :get})) + (is-has-body {:total 1} (call handler {:request-method :get, :query-params {:x "1"}})) + (is-request-validation-failed? (call handler {:request-method :get, :query-params {:x "1", :y "a"}})) + (is-has-body {:total 3} (call handler {:request-method :get, :query-params {:x "1", :y "2"}})) - (fact "if no handler is found, nil is returned" - (handler {:request-method :post, :query-params {:x "1"}}) => nil))) + (testing "if no handler is found, nil is returned" + (is (nil? (call handler {:request-method :post, :query-params {:x "1"}})))))) - (fact "handler preference" + (testing "handler preference" (let [handler (resource {:get {:handler (constantly (ok {:from "get"}))} :handler (constantly (ok {:from "top"}))})] - (handler {:request-method :get}) => (has-body {:from "get"}) - (handler {:request-method :post}) => (has-body {:from "top"}))) + (is-has-body {:from "get"} (call handler {:request-method :get})) + (is-has-body {:from "top"} (call handler {:request-method :post})))) - (fact "resource without coercion" + (testing "resource without coercion" (let [handler (resource - {:get {:parameters {:query-params {(s/optional-key :y) Long + {:coercion nil + :get {:parameters {:query-params {(s/optional-key :y) Long (s/optional-key :x) Long}} :handler (fn [{{:keys [x y]} :query-params}] (ok {:x x - :y y}))}} - {:coercion (constantly nil)})] - - (handler {:request-method :get}) => (has-body {:x nil, :y nil}) - (handler {:request-method :get, :query-params {:x "1"}}) => (has-body {:x "1", :y nil}) - (handler {:request-method :get, :query-params {:x "1", :y "a"}}) => (has-body {:x "1", :y "a"}) - (handler {:request-method :get, :query-params {:x 1, :y 2}}) => (has-body {:x 1, :y 2}))) - - (fact "parameter mappings" + :y y}))}})] + + (is-has-body {:x nil, :y nil} (call handler {:request-method :get})) + (is-has-body {:x "1", :y nil} (call handler {:request-method :get, :query-params {:x "1"}})) + (is-has-body {:x "1", :y "a"} (call handler {:request-method :get, :query-params {:x "1", :y "a"}})) + (is-has-body {:x 1, :y 2} (call handler {:request-method :get, :query-params {:x 1, :y 2}})))) + + (testing "parameter mappings" (let [handler (resource {:get {:parameters {:query-params {:q s/Str} :body-params {:b s/Str} @@ -105,20 +120,21 @@ :header-params :path-params])))}})] - (handler {:request-method :get - :query-params {:q "q"} - :body-params {:b "b"} - :form-params {:f "f"} - ;; the ring headers - :headers {"h" "h"} - ;; compojure routing - :route-params {:p "p"}}) => (has-body {:query-params {:q "q"} - :body-params {:b "b"} - :form-params {:f "f"} - :header-params {:h "h"} - :path-params {:p "p"}}))) - - (fact "response coercion" + (is-has-body {:query-params {:q "q"} + :body-params {:b "b"} + :form-params {:f "f"} + :header-params {:h "h"} + :path-params {:p "p"}} + (call handler {:request-method :get + :query-params {:q "q"} + :body-params {:b "b"} + :form-params {:f "f"} + ;; the ring headers + :headers {"h" "h"} + ;; compojure routing + :route-params {:p "p"}})))) + + (testing "response coercion" (let [handler (resource {:responses {200 {:schema {:total (s/constrained Long pos? 'pos)}}} :parameters {:query-params {:x Long}} @@ -128,79 +144,168 @@ :handler (fnk [[:query-params x]] (ok {:total x}))})] - (handler {:request-method :get}) => request-validation-failed? - (handler {:request-method :get, :query-params {:x "-1"}}) => response-validation-failed? - (handler {:request-method :get, :query-params {:x "1"}}) => response-validation-failed? - (handler {:request-method :get, :query-params {:x "10"}}) => (has-body {:total 10}) - (handler {:request-method :post, :query-params {:x "1"}}) => (has-body {:total 1})))) - -(fact "compojure-api routing integration" - (let [handler (context "/rest" [] - - (GET "/no" request - (ok (select-keys request [:uri :path-info]))) + (is-request-validation-failed? (call handler {:request-method :get})) + (is-response-validation-failed? (call handler {:request-method :get, :query-params {:x "-1"}})) + (is-response-validation-failed? (call handler {:request-method :get, :query-params {:x "1"}})) + (is-has-body {:total 10} (call handler {:request-method :get, :query-params {:x "10"}})) + (is-has-body {:total 1} (call handler {:request-method :post, :query-params {:x "1"}}))))) - (context "/context" [] - (resource - {:handler (constantly (ok "CONTEXT"))})) - - ;; does not work - (ANY "/any" [] - (resource - {:handler (constantly (ok "ANY"))})) +(deftest explicit-async-tests-test + (let [handler (resource + {:parameters {:query-params {:x Long}} + :responses {200 {:schema {:total (s/constrained Long pos? 'pos)}}} + :summary "top-level async handler" + :async-handler (fn [{{x :x} :query-params} res _] + (future + (res (ok {:total x}))) + nil) + :get {:summary "operation-level async handler" + :async-handler (fn [{{x :x} :query-params} respond _] + (future + (respond (ok {:total (inc x)}))) + nil)} + :post {:summary "operation-level sync handler" + :handler (fn [{{x :x} :query-params}] + (ok {:total (* x 10)}))} + :put {:summary "operation-level async send" + :handler (fn [{{x :x} :query-params}] + (a/go + (a/ (has-body {:uri "/rest/no", :path-info "/no"})) - - (fact "wrapped in ANY fails at runtime" - (handler {:request-method :get, :uri "/rest/any"}) => throws) - - (fact "wrapped in context works" - (handler {:request-method :get, :uri "/rest/context"}) => (has-body "CONTEXT")) - - (fact "path-parameters work: route-params are left untoucehed, path-params are coerced" - (handler {:request-method :get, :uri "/rest/path/12"}) => (has-body {:path-params {:id 12} - :route-params {:id "12"}})) - - (fact "top-level GET works" - (handler {:request-method :get, :uri "/rest/in-peaces"}) => (has-body {:uri "/rest/in-peaces" - :path-info "/in-peaces"})) - - (fact "top-level POST misses" - (handler {:request-method :post, :uri "/rest/in-peaces"}) => nil))) + {:parameters {:query-params {:x Long}} + :responses {400 {:schema (s/schema-with-name {:code s/Str} "Error")}} + :get {:parameters {:query-params {:y Long}} + :responses {200 {:schema (s/schema-with-name {:total Long} "Total")}}} + :post {} + :handler (constantly (ok {:total 1}))}))) + spec (get-spec app)] + + (is (= {:definitions #{:Error :Total} + :paths {"/rest" {:get {:parameters 2 + :responses #{:200 :400}} + :post {:parameters 1 + :responses #{:400}}}}} + (-> spec + (select-keys [:definitions :paths]) + (update :definitions (comp set keys)) + (update-in [:paths "/rest" :get :parameters] count) + (update-in [:paths "/rest" :get :responses] (comp set keys)) + (update-in [:paths "/rest" :post :parameters] count) + (update-in [:paths "/rest" :post :responses] (comp set keys))))))) + (testing "top-level handler doesn't contribute to docs" + (let [app (api + {:formatter :muuntaja} + (swagger-routes) + (context "/rest" [] + (resource + {:handler (constantly (ok {:total 1}))}))) + spec (get-spec app)] -(fact "swagger-integration" - (let [app (api - (swagger-routes) - (context "/rest" [] - (resource - {:parameters {:query-params {:x Long}} - :responses {400 {:schema (s/schema-with-name {:code s/Str} "Error")}} - :get {:parameters {:query-params {:y Long}} - :responses {200 {:schema (s/schema-with-name {:total Long} "Total")}}} - :post {} - :handler (constantly (ok {:total 1}))}))) - spec (get-spec app)] - - spec => (contains - {:definitions (just - {:Error irrelevant - :Total irrelevant}) - :paths (just - {"/rest" (just - {:get (just - {:parameters (two-of irrelevant) - :responses (just {:200 irrelevant, :400 irrelevant})}) - :post (just - {:parameters (one-of irrelevant) - :responses (just {:400 irrelevant})})})})}))) + (is (= {} (:paths spec)))))) diff --git a/test/compojure/api/routes_test.clj b/test/compojure/api/routes_test.clj index b44e100b..3c963142 100644 --- a/test/compojure/api/routes_test.clj +++ b/test/compojure/api/routes_test.clj @@ -1,36 +1,38 @@ (ns compojure.api.routes-test - (:require [midje.sweet :refer :all] + (:require [clojure.test :refer [deftest is testing]] [compojure.api.sweet :refer :all] [compojure.api.routes :as routes] [ring.util.http-response :refer :all] [ring.util.http-predicates :refer :all] [compojure.api.test-utils :refer :all] - [schema.core :as s]) - (:import [java.security SecureRandom] - [org.joda.time LocalDate] - [com.fasterxml.jackson.core JsonGenerationException])) - -(facts "path-string" - - (fact "missing path parameter" - (#'routes/path-string "/api/:kikka" {}) - => (throws IllegalArgumentException)) - - (fact "missing serialization" - (#'routes/path-string "/api/:kikka" {:kikka (SecureRandom.)}) - => (throws JsonGenerationException)) - - (fact "happy path" - (#'routes/path-string "/a/:b/:c/d/:e/f" {:b (LocalDate/parse "2015-05-22") - :c 12345 - :e :kikka}) - => "/a/2015-05-22/12345/d/kikka/f")) - -(fact "string-path-parameters" - (#'routes/string-path-parameters "/:foo.json") => {:foo String}) - -(facts "nested routes" - (let [mw (fn [handler] (fn [request] (handler request))) + [schema.core :as s] + [jsonista.core :as j]) + (:import (org.joda.time LocalDate) + (clojure.lang ExceptionInfo))) + +(deftest path-string-test + + (testing "missing path parameter" + (is (thrown? IllegalArgumentException (#'routes/path-string muuntaja "/api/:kikka" {})))) + + (testing "missing serialization" + (is (thrown-with-msg? + ExceptionInfo #"Malformed application/json" + (#'routes/path-string muuntaja "/api/:kikka" {:kikka (reify Comparable)})))) + + (testing "happy path" + (is (= "/a/2015-05-22/12345/d/kikka/f" + (#'routes/path-string muuntaja "/a/:b/:c/d/:e/f" {:b (LocalDate/parse "2015-05-22") + :c 12345 + :e :kikka}))))) + +(deftest string-path-parameters-test + (is (= {:foo String} (#'routes/string-path-parameters "/:foo.json")))) + +(deftest nested-routes-test + (let [mw (fn [handler] + (fn ([request] (handler request)) + ([request raise respond] (handler request raise respond)))) more-routes (fn [version] (routes (GET "/more" [] @@ -41,7 +43,9 @@ (ok {:message (str "pong - " version)})) (POST "/ping" [] (ok {:message (str "pong - " version)})) - (middleware [mw] + (ANY "/foo" [] + (ok {:message (str "bar - " version)})) + (route-middleware [mw] (GET "/hello" [] :return {:message String} :summary "cool ping" @@ -49,54 +53,78 @@ (ok {:message (str "Hello, " name)})) (more-routes version))) app (api + {:formatter :muuntaja} (swagger-routes) routes)] - (fact "all routes can be invoked" + (testing "all routes can be invoked" (let [[status body] (get* app "/api/v1/hello" {:name "Tommi"})] - status = 200 - body => {:message "Hello, Tommi"}) + (is (= 200 status)) + (is (= body {:message "Hello, Tommi"}))) (let [[status body] (get* app "/api/v1/ping")] - status = 200 - body => {:message "pong - v1"}) + (is (= status 200)) + (is (= body {:message "pong - v1"}))) (let [[status body] (get* app "/api/v2/ping")] - status = 200 - body => {:message "pong - v2"}) + (is (= status 200)) + (is (= body {:message "pong - v2"}))) (let [[status body] (get* app "/api/v3/more")] - status => 200 - body => {:message "v3"})) - - (fact "routes can be extracted at runtime" - (routes/get-routes app) - => [["/swagger.json" :get {:x-no-doc true, :x-name :compojure.api.swagger/swagger}] - ["/api/:version/ping" :get {:parameters {:path {:version String, s/Keyword s/Any}}}] - ["/api/:version/ping" :post {:parameters {:path {:version String, s/Keyword s/Any}}}] - ["/api/:version/hello" :get {:parameters {:query {:name String, s/Keyword s/Any} - :path {:version String, s/Keyword s/Any}} - :responses {200 {:description "", :schema {:message String}}} - :summary "cool ping"}] - ["/api/:version/more" :get {:parameters {:path {:version String, s/Keyword s/Any}}}]]) - - (fact "swagger-docs can be generated" - (-> app get-spec :paths keys) - => ["/api/{version}/ping" - "/api/{version}/hello" - "/api/{version}/more"]))) + (is (= status 200)) + (is (= body {:message "v3"})))) + + (testing "routes can be extracted at runtime" + (is (= [["/swagger.json" :get {:no-doc true + :coercion :schema + :name :compojure.api.swagger/swagger + :public {:x-name :compojure.api.swagger/swagger}}] + ["/api/:version/ping" :get {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/ping" :post {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ;; 'ANY' expansion + ["/api/:version/foo" :get {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :patch {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :delete {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :head {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :post {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :options {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ["/api/:version/foo" :put {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}] + ;; + ["/api/:version/hello" :get {:coercion :schema + :public {:parameters {:query {:name String, s/Keyword s/Any} + :path {:version String, s/Keyword s/Any}} + :responses {200 {:description "", :schema {:message String}}} + :summary "cool ping"}}] + ["/api/:version/more" :get {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}]] + (routes/get-routes app)))) + + (testing "swagger-docs can be generated" + (is (= (sort ["/api/{version}/ping" + "/api/{version}/foo" + "/api/{version}/hello" + "/api/{version}/more"]) + (-> app get-spec :paths keys sort)))))) (def more-routes (routes (GET "/more" [] (ok {:gary "moore"})))) -(facts "following var-routes, #219" +(deftest issue-219-test ;"following var-routes, #219" (let [routes (context "/api" [] #'more-routes)] - (routes/get-routes routes) => [["/api/more" :get {}]])) + (is (= (routes/get-routes routes) [["/api/more" :get {:static-context? true}]])))) -;; TODO: should this do something different? -(facts "dynamic routes" +(deftest dynamic-routes-test (let [more-routes (fn [version] (GET (str "/" version) [] (ok {:message version}))) @@ -104,49 +132,78 @@ :path-params [version :- String] (more-routes version)) app (api + {:formatter :muuntaja} (swagger-routes) routes)] - (fact "all routes can be invoked" + (testing "all routes can be invoked" (let [[status body] (get* app "/api/v3/v3")] - status => 200 - body => {:message "v3"}) + (is (= status 200)) + (is (= body {:message "v3"}))) (let [[status body] (get* app "/api/v6/v6")] - status => 200 - body => {:message "v6"})) - - (fact "routes can be extracted at runtime" - (routes/get-routes app) - => [["/swagger.json" :get {:x-no-doc true, :x-name :compojure.api.swagger/swagger}] - ["/api/:version/[]" :get {:parameters {:path {:version String, s/Keyword s/Any}}}]]) - - (fact "swagger-docs can be generated" - (-> app get-spec :paths keys) - => ["/api/{version}/[]"]))) - -(fact "route merging" - (routes/get-routes (routes (routes))) => [] - (routes/get-routes (routes (swagger-routes {:spec nil}))) => [] - (routes/get-routes (routes (routes (GET "/ping" [] "pong")))) => [["/ping" :get {}]]) - -(fact "invalid route options" + (is (= status 200)) + (is (= body {:message "v6"})))) + + (testing "routes can be extracted at runtime" + (is (= (routes/get-routes app) + [["/swagger.json" :get {:no-doc true, + :coercion :schema + :name :compojure.api.swagger/swagger + :public {:x-name :compojure.api.swagger/swagger}}] + ["/api/:version/[]" :get {:coercion :schema + :public {:parameters {:path {:version String, s/Keyword s/Any}}}}]]))) + + (testing "swagger-docs can be generated" + (is (= (-> app get-spec :paths keys) + ["/api/{version}/[]"]))))) + +(deftest route-merging-test + (is (= (routes/get-routes (routes (routes))) [])) + (is (= (routes/get-routes (routes (swagger-routes {:spec nil}))) [])) + (is (= (routes/get-routes (routes (routes (GET "/ping" [] "pong")))) [["/ping" :get {}]]))) + +(deftest invalid-route-options-test (let [r (routes (constantly nil))] - (fact "ignore 'em all" - (routes/get-routes r) => [] - (routes/get-routes r nil) => [] - (routes/get-routes r {:invalid-routes-fn nil}) => []) + (testing "ignore 'em all" + (is (= (routes/get-routes r) [])) + (is (= (routes/get-routes r nil) [])) + (is (= (routes/get-routes r {:invalid-routes-fn nil}) []))) - (fact "log warnings" - (routes/get-routes r {:invalid-routes-fn routes/log-invalid-child-routes}) => [] - (provided - (compojure.api.impl.logging/log! :warn irrelevant) => irrelevant :times 1)) + (testing "log warnings" + (let [a (atom [])] + (with-redefs [compojure.api.impl.logging/log! (fn [& args] (swap! a conj args))] + (is (= [] (routes/get-routes r {:invalid-routes-fn routes/log-invalid-child-routes})))) + (is (= 1 (count @a))))) - (fact "throw exception" - (routes/get-routes r {:invalid-routes-fn routes/fail-on-invalid-child-routes})) => throws)) + (testing "throw exception" + (is (thrown? Exception (routes/get-routes r {:invalid-routes-fn routes/fail-on-invalid-child-routes})))))) -(fact "context routes with compojure destructuring" +(deftest context-routes-with-compojure-destructuring-test (let [app (context "/api" req (GET "/ping" [] (ok (:magic req))))] - (app {:request-method :get :uri "/api/ping" :magic {:just "works"}}) => (contains {:body {:just "works"}}))) + (is (= {:just "works"} + (:body (app {:request-method :get :uri "/api/ping" :magic {:just "works"}})))))) + +(deftest dynamic-context-routes-test + (let [endpoint? (atom true) + app (context "/api" [] + :dynamic true + (when @endpoint? + (GET "/ping" [] (ok "pong"))))] + (testing "the endpoint exists" + (is (= (:body (app {:request-method :get :uri "/api/ping"})) "pong"))) + + (reset! endpoint? false) + (testing "the endpoint does not exist" + (is (= (app {:request-method :get :uri "/api/ping"}) nil))))) + +(deftest listing-static-context-routes-test + (let [app (routes + (context "/static" [] + (GET "/ping" [] (ok "pong"))) + (context "/dynamic" req + (GET "/ping" [] (ok "pong"))))] + (is (= (routes/get-static-context-routes app) + [["/static/ping" :get {:static-context? true}]])))) diff --git a/test/compojure/api/swagger_ordering_test.clj b/test/compojure/api/swagger_ordering_test.clj index 55112ecf..12d7f352 100644 --- a/test/compojure/api/swagger_ordering_test.clj +++ b/test/compojure/api/swagger_ordering_test.clj @@ -1,5 +1,5 @@ (ns compojure.api.swagger-ordering-test - (:require [midje.sweet :refer :all] + (:require [clojure.test :refer [deftest is testing]] [compojure.api.sweet :refer :all] [compojure.api.test-utils :refer :all])) @@ -9,8 +9,9 @@ (GET "/7" [] identity) (GET "/8" [] identity))) -(facts "with 10+ routes" +(deftest with-10+-routes-test (let [app (api + {:formatter :muuntaja} (context "/a" [] (GET "/1" [] identity) (GET "/2" [] identity) @@ -23,14 +24,15 @@ (GET "/9" [] identity) (GET "/10" [] identity))))] - (fact "swagger-api order is maintained" - (keys (extract-paths app)) => ["/a/1" - "/a/2" - "/a/3" - "/a/b/4" - "/a/b/5" - "/a/c/6" - "/a/c/7" - "/a/c/8" - "/a/c/9" - "/a/c/10"]))) + (testing "swagger-api order is maintained" + (is (= (keys (extract-paths app)) + ["/a/1" + "/a/2" + "/a/3" + "/a/b/4" + "/a/b/5" + "/a/c/6" + "/a/c/7" + "/a/c/8" + "/a/c/9" + "/a/c/10"]))))) diff --git a/test/compojure/api/swagger_test.clj b/test/compojure/api/swagger_test.clj index e47cfd63..5814e65e 100644 --- a/test/compojure/api/swagger_test.clj +++ b/test/compojure/api/swagger_test.clj @@ -1,19 +1,20 @@ (ns compojure.api.swagger-test (:require [schema.core :as s] [compojure.api.sweet :refer :all] + [compojure.api.swagger :as swagger] compojure.core [compojure.api.test-utils :refer :all] - [midje.sweet :refer :all])) + [clojure.test :refer [deftest is testing]])) (defmacro optional-routes [p & body] (when p `(routes ~@body))) (defmacro GET+ [p & body] `(GET ~(str "/xxx" p) ~@body)) -(fact "extracting compojure paths" +(deftest extracting-compojure-paths-test - (fact "all compojure.api.core macros are interpreted" + (testing "all compojure.api.core macros are interpreted" (let [app (context "/a" [] (routes - (context "/b" [] + (context "/b" a (let-routes [] (GET "/c" [] identity) (POST "/d" [] identity) @@ -24,90 +25,94 @@ (context "/:i/:j" [] (GET "/k/:l/m/:n" [] identity))))] - (extract-paths app) - => {"/a/b/c" {:get {}} - "/a/b/d" {:post {}} - "/a/b/e" {:put {}} - "/a/b/f" {:delete {}} - "/a/b/g" {:options {}} - "/a/b/h" {:patch {}} - "/a/:i/:j/k/:l/m/:n" {:get {:parameters {:path {:i String - :j String - :l String - :n String}}}}})) - - (fact "runtime code in route is NOT ignored" - (extract-paths - (context "/api" [] - (if false - (GET "/true" [] identity) - (PUT "/false" [] identity)))) => {"/api/false" {:put {}}}) - - (fact "route-macros are expanded" - (extract-paths - (context "/api" [] - (optional-routes true (GET "/true" [] identity)) - (optional-routes false (PUT "/false" [] identity)))) => {"/api/true" {:get {}}}) - - (fact "endpoint-macros are expanded" - (extract-paths - (context "/api" [] - (GET+ "/true" [] identity))) => {"/api/xxx/true" {:get {}}}) - - (fact "Vanilla Compojure defroutes are NOT followed" + (is (= (extract-paths app) + {"/a/b/c" {:get {}} + "/a/b/d" {:post {}} + "/a/b/e" {:put {}} + "/a/b/f" {:delete {}} + "/a/b/g" {:options {}} + "/a/b/h" {:patch {}} + "/a/:i/:j/k/:l/m/:n" {:get {:parameters {:path {:i String + :j String + :l String + :n String}}}}})))) + + (testing "runtime code in route is NOT ignored" + (is (= (extract-paths + (context "/api" [] + (if false + (GET "/true" [] identity) + (PUT "/false" [] identity)))) + {"/api/false" {:put {}}}))) + + (testing "route-macros are expanded" + (is (= (extract-paths + (context "/api" [] + (optional-routes true (GET "/true" [] identity)) + (optional-routes false (PUT "/false" [] identity)))) + {"/api/true" {:get {}}}))) + + (testing "endpoint-macros are expanded" + (is (= (extract-paths + (context "/api" [] + (GET+ "/true" [] identity))) + {"/api/xxx/true" {:get {}}}))) + + (testing "Vanilla Compojure defroutes are NOT followed" (compojure.core/defroutes even-more-routes (GET "/even" [] identity)) (compojure.core/defroutes more-routes (context "/more" [] even-more-routes)) - (extract-paths - (context "/api" [] - (GET "/true" [] identity) - more-routes)) => {"/api/true" {:get {}}}) + (is (= (extract-paths + (context "/api" [] + (GET "/true" [] identity) + more-routes)) + {"/api/true" {:get {}}}))) - (fact "Compojure Api defroutes and def routes are followed" + (testing "Compojure Api defroutes and def routes are followed" (def even-more-routes (GET "/even" [] identity)) (defroutes more-routes (context "/more" [] even-more-routes)) - (extract-paths - (context "/api" [] - (GET "/true" [] identity) - more-routes)) => {"/api/true" {:get {}} - "/api/more/even" {:get {}}}) - - (fact "Parameter regular expressions are discarded" - (extract-paths - (context "/api" [] - (GET ["/:param" :param #"[a-z]+"] [] identity))) - - => {"/api/:param" {:get {:parameters {:path {:param String}}}}})) - -(fact "context meta-data" - (extract-paths - (context "/api/:id" [] - :summary "top-summary" - :path-params [id :- String] - :tags [:kiss] - (GET "/kikka" [] - identity) - (context "/ipa" [] - :summary "mid-summary" - :tags [:wasp] - (GET "/kukka/:kukka" [] - :summary "bottom-summary" - :path-params [kukka :- String] - :tags [:venom]) - (GET "/kakka" [] - identity)))) - - => {"/api/:id/kikka" {:get {:summary "top-summary" - :tags #{:kiss} - :parameters {:path {:id String}}}} - "/api/:id/ipa/kukka/:kukka" {:get {:summary "bottom-summary" - :tags #{:venom} - :parameters {:path {:id String - :kukka String}}}} - "/api/:id/ipa/kakka" {:get {:summary "mid-summary" - :tags #{:wasp} - :parameters {:path {:id String}}}}}) - -(facts "duplicate context merge" + (is (= (extract-paths + (context "/api" [] + (GET "/true" [] identity) + more-routes)) + {"/api/true" {:get {}} + "/api/more/even" {:get {}}}))) + + (testing "Parameter regular expressions are discarded" + (is (= (extract-paths + (context "/api" [] + (GET ["/:param" :param #"[a-z]+"] [] identity))) + {"/api/:param" {:get {:parameters {:path {:param String}}}}})))) + +(deftest context-meta-data-test-1 + (is (= (extract-paths + (context "/api/:id" [] + :summary "top-summary" + :path-params [id :- String] + :tags [:kiss] + (GET "/kikka" [] + identity) + (context "/ipa" [] + :summary "mid-summary" + :tags [:wasp] + (GET "/kukka/:kukka" [] + :summary "bottom-summary" + :path-params [kukka :- String] + :tags [:venom]) + (GET "/kakka" [] + identity)))) + + {"/api/:id/kikka" {:get {:summary "top-summary" + :tags #{:kiss} + :parameters {:path {:id String}}}} + "/api/:id/ipa/kukka/:kukka" {:get {:summary "bottom-summary" + :tags #{:venom} + :parameters {:path {:id String + :kukka String}}}} + "/api/:id/ipa/kakka" {:get {:summary "mid-summary" + :tags #{:wasp} + :parameters {:path {:id String}}}}}))) + +(deftest duplicate-context-merge-test (let [app (routes (context "/api" [] :tags [:kiss] @@ -117,9 +122,9 @@ :tags [:kiss] (GET "/kukka" [] identity)))] - (extract-paths app) - => {"/api/kukka" {:get {:tags #{:kiss}}} - "/api/kakka" {:get {:tags #{:kiss}}}})) + (is (= (extract-paths app) + {"/api/kukka" {:get {:tags #{:kiss}}} + "/api/kakka" {:get {:tags #{:kiss}}}})))) (def r1 (GET "/:id" [] @@ -130,58 +135,66 @@ :path-params [id :- Long] identity)) -(facts "defined routes path-params" - (extract-paths (routes r1 r2)) - => {"/:id" {:get {:parameters {:path {:id String}}}} - "/kukka/:id" {:get {:parameters {:path {:id Long}}}}}) - -(fact "context meta-data" - (extract-paths - (context "/api/:id" [] - :summary "top-summary" - :path-params [id :- String] - :tags [:kiss] - (GET "/kikka" [] - identity) - (context "/ipa" [] - :summary "mid-summary" - :tags [:wasp] - (GET "/kukka/:kukka" [] - :summary "bottom-summary" - :path-params [kukka :- String] - :tags [:venom]) - (GET "/kakka" [] - identity)))) - - => {"/api/:id/kikka" {:get {:summary "top-summary" - :tags #{:kiss} - :parameters {:path {:id String}}}} - "/api/:id/ipa/kukka/:kukka" {:get {:summary "bottom-summary" - :tags #{:venom} - :parameters {:path {:id String - :kukka String}}}} - "/api/:id/ipa/kakka" {:get {:summary "mid-summary" - :tags #{:wasp} - :parameters {:path {:id String}}}}}) - -(fact "path params followed by an extension" - (extract-paths - (GET "/:foo.json" [] - :path-params [foo :- String] - identity)) - => {"/:foo.json" {:get {:parameters {:path {:foo String}}}}}) - -(facts - (tabular - (fact "swagger-routes basePath can be changed" - (let [app (api (swagger-routes ?given-options))] - (-> - (get* app "/swagger.json") - (nth 1) - :basePath) - => ?expected-base-path - (nth (raw-get* app "/conf.js") 1) => (str "window.API_CONF = {\"url\":\"" ?expected-swagger-docs-path "\"};"))) - ?given-options ?expected-swagger-docs-path ?expected-base-path - {} "/swagger.json" "/" - {:data {:basePath "/app"}} "/app/swagger.json" "/app" - {:data {:basePath "/app"} :options {:ui {:swagger-docs "/imaginary.json"}}} "/imaginary.json" "/app")) +(deftest defined-routes-path-params-test + (is (= (extract-paths (routes r1 r2)) + {"/:id" {:get {:parameters {:path {:id String}}}} + "/kukka/:id" {:get {:parameters {:path {:id Long}}}}}))) + +;;FIXME is this a duplicate of context-meta-data-test-1? +(deftest context-meta-data-test-2 + (is (= (extract-paths + (context "/api/:id" [] + :summary "top-summary" + :path-params [id :- String] + :tags [:kiss] + (GET "/kikka" [] + identity) + (context "/ipa" [] + :summary "mid-summary" + :tags [:wasp] + (GET "/kukka/:kukka" [] + :summary "bottom-summary" + :path-params [kukka :- String] + :tags [:venom]) + (GET "/kakka" [] + identity)))) + + {"/api/:id/kikka" {:get {:summary "top-summary" + :tags #{:kiss} + :parameters {:path {:id String}}}} + "/api/:id/ipa/kukka/:kukka" {:get {:summary "bottom-summary" + :tags #{:venom} + :parameters {:path {:id String + :kukka String}}}} + "/api/:id/ipa/kakka" {:get {:summary "mid-summary" + :tags #{:wasp} + :parameters {:path {:id String}}}}}))) + +(deftest path-params-followed-by-an-extension-test + (is (= (extract-paths + (GET "/:foo.json" [] + :path-params [foo :- String] + identity)) + {"/:foo.json" {:get {:parameters {:path {:foo String}}}}}))) + +(deftest swagger-routes-basePath-test + (testing "swagger-routes basePath can be changed" + (doseq [[?given-options ?expected-swagger-docs-path ?expected-base-path :as test-case] + [[{} "/swagger.json" "/" {:data {:basePath "/app"}} "/app/swagger.json" "/app"] + [{:data {:basePath "/app"} :options {:ui {:swagger-docs "/imaginary.json"}}} "/imaginary.json" "/app"]]] + (testing (pr-str test-case) + (let [app (api {:formatter :muuntaja} (swagger-routes ?given-options))] + (is (= (-> + (get* app "/swagger.json") + (nth 1) + :basePath) + ?expected-base-path)) + (is (= (nth (raw-get* app "/conf.js") 1) + (str "window.API_CONF = {\"url\":\"" ?expected-swagger-docs-path "\"};")))))))) + +;;"change of contract in 1.2.0 with swagger-docs % swagger-ui" +(deftest change-1-2-0-swagger-docs-ui-test + (testing "swagger-ui" + (is (thrown? AssertionError (swagger/swagger-ui "/path")))) + (testing "swagger-docs" + (is (thrown? AssertionError (swagger/swagger-docs "/path"))))) diff --git a/test/compojure/api/sweet_test.clj b/test/compojure/api/sweet_test.clj index c9605bc3..3dd8c0ef 100644 --- a/test/compojure/api/sweet_test.clj +++ b/test/compojure/api/sweet_test.clj @@ -1,7 +1,7 @@ (ns compojure.api.sweet-test (:require [compojure.api.sweet :refer :all] [compojure.api.test-utils :refer :all] - [midje.sweet :refer :all] + [clojure.test :refer [deftest is testing]] [ring.mock.request :refer :all] [schema.core :as s] [ring.swagger.validator :as v])) @@ -18,7 +18,8 @@ (def app (api - {:swagger {:spec "/swagger.json" + {:formatter :muuntaja + :swagger {:spec "/swagger.json" :data {:info {:version "1.0.0" :title "Sausages" :description "Sausage description" @@ -65,145 +66,163 @@ :return [String] identity)))) -(facts "api documentation" - (fact "details are generated" +(deftest api-documentation-test + (testing "details are generated" - (extract-paths app) + (is (= (extract-paths app) - => {"/swagger.json" {:get {:x-name :compojure.api.swagger/swagger, - :x-no-doc true}} - "/ping" {:get {}} - "/api/ping" {:get {}} - "/api/bands" {:get {:x-name :bands - :operationId "getBands" - :description "bands bands bands" - :responses {200 {:schema [Band] - :description ""}} - :summary "Gets all Bands"} - :post {:operationId "addBand" - :parameters {:body [NewBand]} - :responses {200 {:schema Band - :description ""}} - :summary "Adds a Band"}} - "/api/bands/:id" {:get {:operationId "getBand" - :responses {200 {:schema Band + {"/swagger.json" {:get {:x-name :compojure.api.swagger/swagger}} + "/ping" {:get {}} + "/api/ping" {:get {}} + "/api/bands" {:get {:x-name :bands + :operationId "getBands" + :description "bands bands bands" + :responses {200 {:schema [Band] :description ""}} - :summary "Gets a Band" - :parameters {:path {:id String}}}} - "/api/query" {:get {:parameters {:query {:qp Boolean - s/Keyword s/Any}}}} - "/api/header" {:get {:parameters {:header {:hp Boolean - s/Keyword s/Any}}}} - "/api/form" {:post {:parameters {:formData {:fp Boolean}} - :consumes ["application/x-www-form-urlencoded"]}} - "/api/primitive" {:get {:responses {200 {:schema String - :description ""}}}} - "/api/primitiveArray" {:get {:responses {200 {:schema [String] - :description ""}}}}}) + :summary "Gets all Bands"} + :post {:operationId "addBand" + :parameters {:body [NewBand]} + :responses {200 {:schema Band + :description ""}} + :summary "Adds a Band"}} + "/api/bands/:id" {:get {:operationId "getBand" + :responses {200 {:schema Band + :description ""}} + :summary "Gets a Band" + :parameters {:path {:id String}}}} + "/api/query" {:get {:parameters {:query {:qp Boolean + s/Keyword s/Any}}}} + "/api/header" {:get {:parameters {:header {:hp Boolean + s/Keyword s/Any}}}} + "/api/form" {:post {:parameters {:formData {:fp Boolean}} + :consumes ["application/x-www-form-urlencoded"]}} + "/api/primitive" {:get {:responses {200 {:schema String + :description ""}}}} + "/api/primitiveArray" {:get {:responses {200 {:schema [String] + :description ""}}}}}))) - (fact "api-listing works" + (testing "api-listing works" (let [spec (get-spec app)] - spec => {:swagger "2.0" - :info {:version "1.0.0" - :title "Sausages" - :description "Sausage description" - :termsOfService "http://helloreverb.com/terms/" - :contact {:name "My API Team" - :email "foo@example.com" - :url "http://www.metosin.fi"} - :license {:name "Eclipse Public License" - :url "http://www.eclipse.org/legal/epl-v10.html"}} - :basePath "/" - :consumes ["application/json" - "application/x-yaml" - "application/edn" - "application/transit+json" - "application/transit+msgpack"], - :produces ["application/json" - "application/x-yaml" - "application/edn" - "application/transit+json" - "application/transit+msgpack"] - :paths {"/api/bands" {:get {:x-name "bands" - :operationId "getBands" - :description "bands bands bands" + (is (= (-> spec + (update :consumes sort) + (update :produces sort)) + {:swagger "2.0" + :info {:version "1.0.0" + :title "Sausages" + :description "Sausage description" + :termsOfService "http://helloreverb.com/terms/" + :contact {:name "My API Team" + :email "foo@example.com" + :url "http://www.metosin.fi"} + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"}} + :basePath "/" + :consumes (sort ["application/json" + "application/edn" + "application/transit+json" + "application/transit+msgpack"]), + :produces (sort ["application/json" + "application/edn" + "application/transit+json" + "application/transit+msgpack"]) + :paths {"/api/bands" {:get {:x-name "bands" + :operationId "getBands" + :description "bands bands bands" + :responses {:200 {:description "" + :schema {:items {:$ref "#/definitions/Band"} + :type "array"}}} + :summary "Gets all Bands"} + :post {:operationId "addBand" + :parameters [{:description "" + :in "body" + :name "NewBand" + :required true + :schema {:items {:$ref "#/definitions/NewBand"} + :type "array"}}] :responses {:200 {:description "" - :schema {:items {:$ref "#/definitions/Band"} - :type "array"}}} - :summary "Gets all Bands"} - :post {:operationId "addBand" - :parameters [{:description "" - :in "body" - :name "NewBand" - :required true - :schema {:items {:$ref "#/definitions/NewBand"} - :type "array"}}] - :responses {:200 {:description "" - :schema {:$ref "#/definitions/Band"}}} - :summary "Adds a Band"}} - "/api/bands/{id}" {:get {:operationId "getBand" - :parameters [{:description "" - :in "path" - :name "id" - :required true - :type "string"}] - :responses {:200 {:description "" - :schema {:$ref "#/definitions/Band"}}} - :summary "Gets a Band"}} - "/api/query" {:get {:parameters [{:in "query" - :name "qp" + :schema {:$ref "#/definitions/Band"}}} + :summary "Adds a Band"}} + "/api/bands/{id}" {:get {:operationId "getBand" + :parameters [{:description "" + :in "path" + :name "id" + :required true + :type "string"}] + :responses {:200 {:description "" + :schema {:$ref "#/definitions/Band"}}} + :summary "Gets a Band"}} + "/api/query" {:get {:parameters [{:in "query" + :name "qp" + :description "" + :required true + :type "boolean"}] + :responses {:default {:description ""}}}} + "/api/header" {:get {:parameters [{:in "header" + :name "hp" :description "" :required true :type "boolean"}] :responses {:default {:description ""}}}} - "/api/header" {:get {:parameters [{:in "header" - :name "hp" - :description "" - :required true - :type "boolean"}] - :responses {:default {:description ""}}}} - "/api/form" {:post {:parameters [{:in "formData" - :name "fp" - :description "" - :required true - :type "boolean"}] - :responses {:default {:description ""}} - :consumes ["application/x-www-form-urlencoded"]}} - "/api/ping" {:get {:responses {:default {:description ""}}}} - "/api/primitive" {:get {:responses {:200 {:description "" - :schema {:type "string"}}}}} - "/api/primitiveArray" {:get {:responses {:200 {:description "" - :schema {:items {:type "string"} - :type "array"}}}}} - "/ping" {:get {:responses {:default {:description ""}}}}} - :definitions {:Band {:type "object" - :properties {:description {:type "string" - :x-nullable true} - :id {:format "int64", :type "integer"} - :name {:type "string"} - :toppings {:items {:enum ["olives" - "pepperoni" - "ham" - "cheese" - "habanero"] - :type "string"} - :type "array"}} - :required ["id" "name" "toppings"] - :additionalProperties false} - :NewBand {:type "object" - :properties {:description {:type "string" - :x-nullable true} - :name {:type "string"} - :toppings {:items {:enum ["olives" - "pepperoni" - "ham" - "cheese" - "habanero"] - :type "string"} - :type "array"}} - :required ["name" "toppings"] - :additionalProperties false}}} + "/api/form" {:post {:parameters [{:in "formData" + :name "fp" + :description "" + :required true + :type "boolean"}] + :responses {:default {:description ""}} + :consumes ["application/x-www-form-urlencoded"]}} + "/api/ping" {:get {:responses {:default {:description ""}}}} + "/api/primitive" {:get {:responses {:200 {:description "" + :schema {:type "string"}}}}} + "/api/primitiveArray" {:get {:responses {:200 {:description "" + :schema {:items {:type "string"} + :type "array"}}}}} + "/ping" {:get {:responses {:default {:description ""}}}}} + :definitions {:Band {:type "object" + :properties {:description {:type "string" + :x-nullable true} + :id {:format "int64", :type "integer"} + :name {:type "string"} + :toppings {:items {:enum ["olives" + "pepperoni" + "ham" + "cheese" + "habanero"] + :type "string"} + :type "array"}} + :required ["id" "name" "toppings"] + :additionalProperties false} + :NewBand {:type "object" + :properties {:description {:type "string" + :x-nullable true} + :name {:type "string"} + :toppings {:items {:enum ["olives" + "pepperoni" + "ham" + "cheese" + "habanero"] + :type "string"} + :type "array"}} + :required ["name" "toppings"] + :additionalProperties false}}})) + + (testing "spec is valid" + (is (= (v/validate spec) nil)))))) - (fact "spec is valid" - (v/validate spec) => nil)))) +(deftest produces-and-consumes-test + (let [app (api + {:formatter :muuntaja + :swagger {:spec "/swagger.json" + :data {:produces ["application/json" "application/edn"] + :consumes ["application/json" "application/edn"]}}} + ping-route)] + (is (= (-> (get-spec app) + (select-keys [:consumes :produces]) + (update :consumes sort) + (update :produces sort)) + {:consumes (sort + ["application/json" + "application/edn"]) + :produces (sort + ["application/json" + "application/edn"])})))) diff --git a/test/compojure/api/test_utils.clj b/test/compojure/api/test_utils.clj index 60d24a87..08e34ce2 100644 --- a/test/compojure/api/test_utils.clj +++ b/test/compojure/api/test_utils.clj @@ -1,21 +1,21 @@ (ns compojure.api.test-utils - (:require [cheshire.core :as cheshire] - [clojure.string :as str] + (:require [clojure.string :as str] [peridot.core :as p] - [clojure.java.io :as io] - [compojure.api.routes :as routes]) - (:import [java.io InputStream])) + [muuntaja.core :as m] + [compojure.api.routes :as routes] + [compojure.api.middleware :as mw]) + (:import (java.io InputStream))) -(defn read-body [body] - (if (instance? InputStream body) - (slurp body) +(def muuntaja (mw/create-muuntaja)) + +(defn slurp-body [body] + (if (satisfies? muuntaja.protocols/IntoInputStream body) + (m/slurp body) body)) (defn parse-body [body] - (let [body (read-body body) - body (if (instance? String body) - (cheshire/parse-string body true) - body)] + (if (or (string? body) (instance? InputStream body)) + (m/decode muuntaja "application/json" (slurp-body body)) body)) (defn extract-schema-name [ref-str] @@ -25,32 +25,48 @@ (let [schema-name (keyword (extract-schema-name ref))] (get-in spec [:definitions schema-name]))) -;; -;; integration tests -;; +(defn json-stream [x] + (m/encode muuntaja "application/json" x)) -;; -;; common -;; +(def json-string (comp slurp json-stream)) -(defn json [x] (cheshire/generate-string x)) - -(defn json-stream [x] (io/input-stream (.getBytes (json x)))) +(defn parse [x] + (m/decode muuntaja "application/json" x)) (defn follow-redirect [state] (if (some-> state :response :headers (get "Location")) (p/follow-redirect state) state)) +(def ^:dynamic *async?* (= "true" (System/getProperty "compojure-api.test.async"))) + +(defn- call-async [handler request] + (let [result (promise)] + (handler request #(result [:ok %]) #(result [:fail %])) + (if-let [[status value] (deref result 1500 nil)] + (if (= status :ok) + value + (throw value)) + (throw (Exception. (str "Timeout while waiting for the request handler. " + request)))))) + +(defn call + "Call handler synchronously or asynchronously depending on *async?*." + [handler request] + (if *async?* + (call-async handler request) + (handler request))) + (defn raw-get* [app uri & [params headers]] (let [{{:keys [status body headers]} :response} - (-> (p/session app) + (-> (cond->> app *async?* (partial call-async)) + (p/session) (p/request uri :request-method :get :params (or params {}) :headers (or headers {})) follow-redirect)] - [status (read-body body) headers])) + [status (slurp-body body) headers])) (defn get* [app uri & [params headers]] (let [[status body headers] @@ -65,30 +81,48 @@ :params params))] [status (parse-body body)])) -(defn raw-post* [app uri & [data content-type headers]] +(defn raw-put-or-post* [app uri method data content-type headers] (let [{{:keys [status body]} :response} (-> (p/session app) (p/request uri - :request-method :post + :request-method method :headers (or headers {}) :content-type (or content-type "application/json") :body (.getBytes data)))] - [status (read-body body)])) + [status (slurp-body body)])) + +(defn raw-post* [app uri & [data content-type headers]] + (raw-put-or-post* app uri :post data content-type headers)) (defn post* [app uri & [data]] (let [[status body] (raw-post* app uri data)] [status (parse-body body)])) +(defn put* [app uri & [data]] + (let [[status body] (raw-put-or-post* app uri :put data nil nil)] + [status (parse-body body)])) + (defn headers-post* [app uri headers] (let [[status body] (raw-post* app uri "" nil headers)] [status (parse-body body)])) +;; +;; ring-request +;; + +(defn ring-request [m format data] + {:uri "/echo" + :request-method :post + :body (m/encode m format data) + :headers {"content-type" format + "accept" format}}) + ;; ;; get-spec ;; (defn extract-paths [app] - (-> app routes/get-routes routes/ring-swagger-paths :paths)) + (-> app routes/get-routes routes/all-paths)) (defn get-spec [app] (let [[status spec] (get* app "/swagger.json" {})] diff --git a/test19/compojure/api/coercion/issue336_test.clj b/test19/compojure/api/coercion/issue336_test.clj new file mode 100644 index 00000000..20a3da81 --- /dev/null +++ b/test19/compojure/api/coercion/issue336_test.clj @@ -0,0 +1,59 @@ +(ns compojure.api.coercion.issue336-test + (:require [clojure.test :refer [deftest is testing]] + [compojure.api.test-utils :refer :all] + [ring.util.http-response :refer :all] + [compojure.api.sweet :refer :all] + [clojure.spec.alpha :as s] + [spec-tools.spec :as spec] + [spec-tools.core :as st])) + +(s/def ::customer-id spec/string?) +(s/def ::requestor-id spec/string?) +(s/def ::requestor-email spec/string?) +(s/def ::requestor-name spec/string?) +(s/def ::endpoint spec/string?) +(s/def ::from-year spec/int?) +(s/def ::from-month spec/int?) +(s/def ::to-year spec/int?) +(s/def ::to-month spec/int?) + +(s/def ::input-settings (s/and (s/keys :req-un [::endpoint + ::customer-id + ::requestor-id] + :opt-un [::from-year + ::from-month + ::to-year + ::to-month + ::requestor-email + ::requestor-name]))) + +(def app + (api + {:formatter :muuntaja + :swagger + {:ui "/" + :spec "/swagger.json" + :data {:info {:title "Futomaki" + :description "API for counter stats over the Sushi protocol"} + :tags [{:name "Reports", :description "Retrieve information per report definition"}]}}} + + (context "/api" [] + :tags ["api"] + :coercion :spec + + (context "/jr1" [] + (resource + {:get + {:summary "Number of successful full-text article requests by month and journal" + :parameters {:query-params ::input-settings} + :response {200 {:schema ::input-settings}} + :handler (fn [{:keys [query-params]}] + (ok query-params))}}))))) + +(deftest coercion-works-with-s-and-test + (let [data {:endpoint "http://sushi.cambridge.org/GetReport" + :customer-id "abc" + :requestor-id "abc"} + [status body] (get* app "/api/jr1" data)] + (is (= status 200)) + (is (= body data)))) diff --git a/test19/compojure/api/coercion/spec_coercion_explain_test.clj b/test19/compojure/api/coercion/spec_coercion_explain_test.clj new file mode 100644 index 00000000..680d40cd --- /dev/null +++ b/test19/compojure/api/coercion/spec_coercion_explain_test.clj @@ -0,0 +1,55 @@ +(ns compojure.api.coercion.spec-coercion-explain-test + (:require [clojure.test :refer [deftest is testing]] + [clojure.spec.alpha :as s] + [spec-tools.spec :as spec] + [compojure.api.test-utils :refer :all] + [compojure.api.sweet :refer :all] + [compojure.api.request :as request] + [compojure.api.coercion :as coercion])) + +(s/def ::birthdate spec/inst?) + +(s/def ::name string?) + +(s/def ::languages + (s/coll-of + (s/and spec/keyword? #{:clj :cljs}) + :into #{})) + +(s/def ::spec + (s/keys + :req-un [::name ::languages ::age] + :opt-un [::birthdate])) + +(def valid-value {:name "foo" :age "24" :languages ["clj"] :birthdate "1968-01-02T15:04:05Z"}) +(def coerced-value {:name "foo" :age "24" :languages #{:clj} :birthdate #inst "1968-01-02T15:04:05Z"}) +(def invalid-value {:name "foo" :age "24" :lanxguages ["clj"] :birthdate "1968-01-02T15:04:05Z"}) + +(deftest request-coercion-test + (let [c! #(coercion/coerce-request! ::spec :body-params :body false false %)] + + (testing "default coercion" + (is (= (c! {:body-params valid-value + :muuntaja/request {:format "application/json"} + ::request/coercion :spec}) + coerced-value)) + (is (thrown? Exception + (c! {:body-params invalid-value + :muuntaja/request {:format "application/json"} + ::request/coercion :spec}))) + (try + (c! {:body-params invalid-value + :muuntaja/request {:format "application/json"} + ::request/coercion :spec}) + (catch Exception e + (let [data (ex-data e) + spec-problems (get-in data [:problems ::s/problems])] + (is (= (count spec-problems) 1)) + (is (= (select-keys (first spec-problems) + [:in :path :val :via]) + {:in [] + :path [] + :val {:age "24" + :birthdate #inst "1968-01-02T15:04:05Z" + :name "foo"} + :via [::spec]})))))))) diff --git a/test19/compojure/api/coercion/spec_coercion_test.clj b/test19/compojure/api/coercion/spec_coercion_test.clj new file mode 100644 index 00000000..b19c0762 --- /dev/null +++ b/test19/compojure/api/coercion/spec_coercion_test.clj @@ -0,0 +1,575 @@ +(ns compojure.api.coercion.spec-coercion-test + (:require [clojure.test :refer [deftest is testing]] + expound.alpha + [clojure.spec.alpha :as s] + [compojure.api.test-utils :refer :all] + [compojure.api.sweet :refer :all] + [compojure.api.request :as request] + [compojure.api.coercion :as coercion] + [compojure.api.coercion.spec :as cs] + [spec-tools.data-spec :as ds] + [spec-tools.core :as st] + [compojure.api.validator :as validator] + [spec-tools.transform :as stt] + [compojure.api.exception :as ex]) + (:import (org.joda.time DateTime))) + +(s/def ::kikka keyword?) +(s/def ::spec (s/keys :req-un [::kikka])) + +(s/def ::date (st/spec + {:spec (partial instance? DateTime) + :type :date-time + :reason "FAIL" + :json-schema/default "2017-10-12T05:04:57.585Z"})) + +(defn str->date-time [_ value] + (try + (DateTime. value) + (catch Exception _ + ::s/invalid))) + +(def custom-coercion + (-> compojure.api.coercion.spec/default-options + (assoc-in + [:body :formats "application/json"] + (st/type-transformer + {:decoders (merge + stt/json-type-decoders + {:date-time str->date-time} + stt/strip-extra-keys-type-decoders)})) + compojure.api.coercion.spec/create-coercion)) + +(def valid-value {:kikka :kukka}) +(def invalid-value {:kikka "kukka"}) + +(deftest request-coercion-test + (let [c! #(coercion/coerce-request! ::spec :body-params :body false false %)] + + (testing "default coercion" + (is (= (c! {:body-params valid-value + ::request/coercion :spec}) + valid-value)) + (is (thrown? Exception + (c! {:body-params invalid-value + ::request/coercion :spec}))) + (try + (c! {:body-params invalid-value + ::request/coercion :spec}) + (catch Exception e + (is (= (-> (ex-data e) + (select-keys [:type :coercion :in :spec :value :problems :request]) + (update :request select-keys [:body-params]) + (update :spec (comp boolean st/spec?)) + (update-in [:problems ::s/spec] (comp boolean st/spec?))) + {:type :compojure.api.exception/request-validation + :coercion (coercion/resolve-coercion :spec) + :in [:request :body-params] + :spec true + :value invalid-value + :problems {::s/problems [{:in [:kikka] + :path [:kikka] + :pred `keyword? + :val "kukka" + :via [::spec ::kikka]}] + ::s/spec true + ::s/value invalid-value} + :request {:body-params {:kikka "kukka"}}}))))) + + (testing "coercion also unforms" + (let [spec (s/or :int int? :keyword keyword?) + c! #(coercion/coerce-request! spec :body-params :body false false %)] + (is (= (c! {:body-params 1 + ::request/coercion :spec}) + 1)) + (is (= (c! {:body-params :kikka + ::request/coercion :spec}) + :kikka)))) + + (testing "format-based coercion" + (is (= (c! {:body-params valid-value + ::request/coercion :spec + :muuntaja/request {:format "application/json"}}) + valid-value)) + (is (= (c! {:body-params invalid-value + ::request/coercion :spec + :muuntaja/request {:format "application/json"}}) + valid-value))) + + (testing "no coercion" + (is (= (c! {:body-params valid-value + ::request/coercion nil + :muuntaja/request {:format "application/json"}}) + valid-value)) + (is (= (c! {:body-params invalid-value + ::request/coercion nil + :muuntaja/request {:format "application/json"}}) + invalid-value))))) + +(defn ok [body] + {:status 200, :body body}) + +(defn is-ok? [expected value] + (is (= (ok expected) (select-keys value [:status :body])))) + +(def responses {200 {:schema ::spec}}) + +(def custom-coercion + (cs/->SpecCoercion + :custom + (-> cs/default-options + (assoc-in [:response :formats "application/json"] cs/json-transformer)))) + +(deftest response-coercion-test + (let [c! coercion/coerce-response!] + + (testing "default coercion" + (is-ok? valid-value + (c! {::request/coercion :spec} + (ok valid-value) + responses)) + (is (thrown? Exception + (c! {::request/coercion :spec} + (ok invalid-value) + responses))) + (try + (c! {::request/coercion :spec} (ok invalid-value) responses) + (catch Exception e + (is (= (-> (ex-data e) + (select-keys [:type :coercion :in :spec :value :problems :request]) + (update :spec (comp boolean st/spec?)) + (update :problems some?)) + {:type :compojure.api.exception/response-validation + :coercion (coercion/resolve-coercion :spec) + :in [:response :body] + :spec true + :value invalid-value + :problems true + :request {::request/coercion :spec}}))))) + + (testing "format-based custom coercion" + (testing "request-negotiated response format" + (is (thrown? Exception + (c! nil + (ok invalid-value) + responses))) + (is-ok? valid-value + (c! {:muuntaja/response {:format "application/json"} + ::request/coercion custom-coercion} + (ok invalid-value) + responses)))) + + (testing "no coercion" + (is-ok? valid-value + (c! {::request/coercion nil} + (ok valid-value) + responses)) + (is-ok? invalid-value + (c! {::request/coercion nil} + (ok invalid-value) + responses))))) + +(s/def ::x int?) +(s/def ::y int?) +(s/def ::xy (s/keys :req-un [::x ::y])) +(s/def ::total pos-int?) + +(deftest apis-test + (let [app (api + {:formatter :muuntaja + :swagger {:spec "/swagger.json"} + :coercion :spec} + + (POST "/body" [] + :body [{:keys [x y]} ::xy] + (ok {:total (+ x y)})) + + (PUT "/body" [] + :body [body ::xy] + (ok body)) + + (POST "/body-map" [] + :body [{:keys [x y]} {:x int?, (ds/opt :y) ::y}] + (ok {:total (+ x (or y 0))})) + + (GET "/query" [] + :query [{:keys [x y]} ::xy] + (ok {:total (+ x y)})) + + (GET "/query-params" [] + :query-params [x :- ::x, y :- ::y] + (ok {:total (+ x y)})) + + (POST "/body-params" [] + :body-params [x :- int?, {y :- ::y 0}] + (ok {:total (+ x y)})) + + (POST "/body-string" [] + :body [body string?] + (ok {:body body})) + + (GET "/response" [] + :query-params [x :- ::x, y :- ::y] + :return (s/keys :req-un [::total]) + (ok {:total (+ x y)})) + + (context "/date" [] + :coercion custom-coercion + + (GET "/pass" [] + :return {:date ::date} + (ok {:date (DateTime.)})) + + (GET "/fail" [] + :return {:date ::date} + (ok {:date "fail"}))) + + (context "/resource" [] + (resource + {:get {:parameters {:query-params ::xy} + :responses {200 {:schema (s/keys :req-un [::total])}} + :handler (fn [{{:keys [x y]} :query-params}] + (ok {:total (+ x y)}))} + :post {:parameters {:body-params {:x int? (ds/opt :y) int?}} + :responses {200 {:schema (s/keys :req-un [::total])}} + :handler (fn [{{:keys [x y]} :body-params}] + (ok {:total (+ x (or y 0))}))} + :put {:parameters {:body-params ::xy} + :handler (fn [{body-params :body-params}] + (ok body-params))}})))] + + (testing "query" + (let [[status body] (get* app "/query" {:x "1", :y 2})] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (get* app "/query" {:x "1", :y "kaks"})] + (is (= status 400)) + (is (= (-> body + (select-keys [:coercion :in :problems :spec :type :value]) + (update :problems count) + (update :spec string?)) + {:coercion "spec" + :in ["request" "query-params"] + :problems 1 + :spec true + :type "compojure.api.exception/request-validation" + :value {:x "1", :y "kaks"}})))) + + (testing "body" + (let [[status body] (post* app "/body" (json-string {:x 1, :y 2, :z 3}))] + (is (= status 200)) + (is (= body {:total 3})))) + + (testing "body-map" + (let [[status body] (post* app "/body-map" (json-string {:x 1, :y 2}))] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (post* app "/body-map" (json-string {:x 1}))] + (is (= status 200)) + (is (= body {:total 1})))) + + (testing "body-string" + (let [[status body] (post* app "/body-string" (json-string "kikka"))] + (is (= status 200)) + (is (= body {:body "kikka"})))) + + (testing "query-params" + (let [[status body] (get* app "/query-params" {:x "1", :y 2})] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (get* app "/query-params" {:x "1", :y "a"})] + (is (= status 400)) + (is (= (select-keys body [:coercion :in]) + {:coercion "spec" + :in ["request" "query-params"]})))) + + (testing "body-params" + (let [[status body] (post* app "/body-params" (json-string {:x 1, :y 2}))] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (post* app "/body-params" (json-string {:x 1}))] + (is (= status 200)) + (is (= body {:total 1}))) + (let [[status body] (post* app "/body-params" (json-string {:x "1"}))] + (is (= status 400)) + (is (= (select-keys body [:coercion :in]) + {:coercion "spec" + :in ["request" "body-params"]})))) + + (testing "response" + (let [[status body] (get* app "/response" {:x 1, :y 2})] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (get* app "/response" {:x -1, :y -2})] + (is (= status 500)) + (is (= (select-keys body [:coercion :in]) + {:coercion "spec" + :in ["response" "body"]})))) + + (testing "customer coercion & custom predicate" + (let [[status body] (get* app "/date/pass")] + (is (= status 200))) + (let [[status body] (get* app "/date/fail")] + (is (= status 500)) + (is (= (select-keys body [:coercion :in]) + {:coercion "custom" + :in ["response" "body"]})))) + (testing "resource" + (testing "parameters as specs" + (let [[status body] (get* app "/resource" {:x 1, :y 2})] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (get* app "/resource" {:x -1, :y -2})] + (is (= status 500)) + (is (= (select-keys body [:coercion :in]) + {:coercion "spec" + :in ["response" "body"]})))) + + (testing "parameters as data-specs" + (let [[status body] (post* app "/resource" (json-string {:x 1, :y 2}))] + (is (= status 200)) + (is (= body {:total 3}))) + (let [[status body] (post* app "/resource" (json-string {:x 1}))] + (is (= status 200)) + (is (= body {:total 1}))) + (let [[status body] (post* app "/resource" (json-string {:x -1, :y -2}))] + (is (= status 500)) + (is (= (select-keys body [:coercion :in]) + {:coercion "spec" + :in ["response" "body"]}))))) + + (testing "extra keys are stripped from body-params before validation" + (testing "for resources" + (let [[status body] (put* app "/resource" (json-string {:x 1, :y 2 ::kikka "kakka"}))] + (is (= status 200)) + (is (= body {:x 1, :y 2})))) + (testing "for endpoints" + (let [[status body] (put* app "/body" (json-string {:x 1, :y 2 ::kikka "kakka"}))] + (is (= status 200)) + (is (= body {:x 1, :y 2}))))) + + (testing "generates valid swagger spec" + (is (do (validator/validate app) true))) + + (testing "swagger spec has all things" + (let [total-schema {:description "", + :schema {:properties + {:total {:format "int64", + :minimum 1, + :type "integer"}}, + :required ["total"], + :type "object"}}] + (is (= (get-spec app) + {:basePath "/" + :consumes ["application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"] + :definitions {} + :info {:title "Swagger API" :version "0.0.1"} + :paths {"/body" {:post {:parameters [{:description "" + :in "body" + :name "compojure.api.coercion.spec-coercion-test/xy" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x" "y"] + :title "compojure.api.coercion.spec-coercion-test/xy" + :type "object"}}] + :responses {:default {:description ""}}} + :put {:parameters [{:description "" + :in "body" + :name "compojure.api.coercion.spec-coercion-test/xy" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x" "y"] + :title "compojure.api.coercion.spec-coercion-test/xy" + :type "object"}}] + :responses {:default {:description ""}}}} + "/body-map" {:post {:parameters [{:description "" + :in "body" + :name "body" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x"] + :type "object"}}] + :responses {:default {:description ""}}}} + "/body-params" {:post {:parameters [{:description "" + :in "body" + :name "body" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x"] + :type "object"}}] + :responses {:default {:description ""}}}} + "/body-string" {:post {:parameters [{:description "" + :in "body" + :name "body" + :required true + :schema {:type "string"}}] + :responses {:default {:description ""}}}} + "/date/fail" {:get {:responses {:200 {:description "" + :schema {:properties {:date {:default "2017-10-12T05:04:57.585Z"}} + :required ["date"] + :type "object"}} + :default {:description ""}}}} + "/date/pass" {:get {:responses {:200 {:description "" + :schema {:properties {:date {:default "2017-10-12T05:04:57.585Z"}} + :required ["date"] + :type "object"}} + :default {:description ""}}}} + "/query" {:get {:parameters [{:description "" + :format "int64" + :in "query" + :name "x" + :required true + :type "integer"} + {:description "" + :format "int64" + :in "query" + :name "y" + :required true + :type "integer"}] + :responses {:default {:description ""}}}} + "/query-params" {:get {:parameters [{:description "" + :format "int64" + :in "query" + :name "x" + :required true + :type "integer"} + {:description "" + :format "int64" + :in "query" + :name "y" + :required true + :type "integer"}] + :responses {:default {:description ""}}}} + "/resource" {:get {:parameters [{:description "" + :format "int64" + :in "query" + :name "x" + :required true + :type "integer"} + {:description "" + :format "int64" + :in "query" + :name "y" + :required true + :type "integer"}] + :responses {:200 {:description "" + :schema {:properties {:total {:format "int64" + :minimum 1 + :type "integer"}} + :required ["total"] + :type "object"}} + :default {:description ""}}} + :post {:parameters [{:description "" + :in "body" + :name "body" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x"] + :type "object"}}] + :responses {:200 {:description "" + :schema {:properties {:total {:format "int64" + :minimum 1 + :type "integer"}} + :required ["total"] + :type "object"}} + :default {:description ""}}} + :put {:parameters [{:description "" + :in "body" + :name "compojure.api.coercion.spec-coercion-test/xy" + :required true + :schema {:properties {:x {:format "int64" + :type "integer"} + :y {:format "int64" + :type "integer"}} + :required ["x" "y"] + :title "compojure.api.coercion.spec-coercion-test/xy" + :type "object"}}] + :responses {:default {:description ""}}}} +"/response" {:get {:parameters [{:description "" + :format "int64" + :in "query" + :name "x" + :required true + :type "integer"} + {:description "" + :format "int64" + :in "query" + :name "y" + :required true + :type "integer"}] + :responses {:200 {:description "" + :schema {:properties {:total {:format "int64" + :minimum 1 + :type "integer"}} + :required ["total"] + :type "object"}} + :default {:description ""}}}}} + :produces ["application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"] + :swagger "2.0"})))))) + +(s/def ::id pos-int?) + +(deftest spec-coercion-in-context-test + (let [app (context "/product/:id" [] + :coercion :spec + :path-params [id :- ::id] + (GET "/foo" [] + :return ::id + (ok id))) + [status body] (get* app "/product/1/foo")] + (is (= status 200)) + (is (= body 1)))) + +(deftest expound-test + + (testing "custom spec printer" + (let [printer (expound.alpha/custom-printer {:theme :figwheel-theme, :print-specs? false}) + app (api + {:formatter :muuntaja + :coercion :spec + :exceptions {:handlers + {::ex/request-validation + (fn [e data request] + (printer (:problems data)) + (ex/request-validation-handler e data request)) + ::ex/response-validation + (fn [e data request] + (printer (:problems data)) + (ex/response-validation-handler e data request))}}} + (POST "/math" [] + :body-params [x :- int?, y :- int?] + :return {:total pos-int?} + (ok {:total (+ x y)})))] + (testing "success" + (let [[status body] (post* app "/math" (json-string {:x 1, :y 2}))] + (is (= status 200)) + (is (= body {:total 3})))) + + (testing "request failure" + (let [[status] (post* app "/math" (json-string {:x 1, :y "2"}))] + (is (= status 400)))) + + (testing "response failure" + (let [[status] (post* app "/math" (json-string {:x 1, :y -2}))] + (is (= status 500)))))))