diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 946cce48..4665c66a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: test: strategy: matrix: - jdk: [8, 11, 17, 21] + jdk: [8, 11, 17, 21, 22] name: Java ${{ matrix.jdk }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e93d4be7..bfd87589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ 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 diff --git a/project.clj b/project.clj index e77fe64e..e08a7716 100644 --- a/project.clj +++ b/project.clj @@ -12,7 +12,7 @@ [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.0"] + [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"] @@ -37,7 +37,6 @@ [org.clojure/core.async "0.6.532"] [javax.servlet/javax.servlet-api "4.0.1"] [peridot "0.5.2"] - [com.rpl/specter "1.1.3"] [com.stuartsierra/component "0.4.0"] [expound "0.8.2"] [metosin/jsonista "0.2.5"] @@ -61,6 +60,9 @@ [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"]]} :async {:jvm-opts ["-Dcompojure-api.test.async=true"] :dependencies [[manifold "0.1.8" :exclusions [org.clojure/tools.logging]]]}} :eastwood {:namespaces [:source-paths] @@ -86,7 +88,7 @@ ["change" "version" "leiningen.release/bump-version"] ["vcs" "commit"] ["vcs" "push"]] - :aliases {"all" ["with-profile" "dev:dev,async"] + :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" ["test"] diff --git a/src/compojure/api/coerce.clj b/src/compojure/api/coerce.clj new file mode 100644 index 00000000..5a147a14 --- /dev/null +++ b/src/compojure/api/coerce.clj @@ -0,0 +1,67 @@ +;; 1.1.x +(ns compojure.api.coerce + (:require [schema.coerce :as sc] + [compojure.api.middleware :as mw] + [compojure.api.exception :as ex] + [clojure.walk :as walk] + [schema.utils :as su] + [linked.core :as linked])) + +(defn memoized-coercer + "Returns a memoized version of a referentially transparent coercer fn. The + memoized version of the function keeps a cache of the mapping from arguments + to results and, when calls with the same arguments are repeated often, has + higher performance at the expense of higher memory use. FIFO with 10000 entries. + Cache will be filled if anonymous coercers are used (does not match the cache)" + [] + (let [cache (atom (linked/map)) + cache-size 10000] + (fn [& args] + (or (@cache args) + (let [coercer (apply sc/coercer args)] + (swap! cache (fn [mem] + (let [mem (assoc mem args coercer)] + (if (>= (count mem) cache-size) + (dissoc mem (-> mem first first)) + mem)))) + coercer))))) + +(defn cached-coercer [request] + (or (-> request mw/get-options :coercer) sc/coercer)) + +(defn coerce-response! [request {:keys [status] :as response} responses] + (-> (when-let [schema (or (:schema (get responses status)) + (:schema (get responses :default)))] + (when-let [matchers (mw/coercion-matchers request)] + (when-let [matcher (matchers :response)] + (let [coercer (cached-coercer request) + coerce (coercer schema matcher) + body (coerce (:body response))] + (if (su/error? body) + (throw (ex-info + (str "Response validation failed: " (su/error-val body)) + (assoc body :type ::ex/response-validation + :response response))) + (assoc response + :compojure.api.meta/serializable? true + :body body)))))) + (or response))) + +(defn body-coercer-middleware [handler responses] + (fn [request] + (coerce-response! request (handler request) responses))) + +(defn coerce! [schema key type request] + (let [value (walk/keywordize-keys (key request))] + (if-let [matchers (mw/coercion-matchers request)] + (if-let [matcher (matchers type)] + (let [coercer (cached-coercer request) + coerce (coercer schema matcher) + result (coerce value)] + (if (su/error? result) + (throw (ex-info + (str "Request validation failed: " (su/error-val result)) + (assoc result :type ::ex/request-validation))) + result)) + value) + value))) diff --git a/src/compojure/api/coercion.clj b/src/compojure/api/coercion.clj index a83a7082..0dd26b04 100644 --- a/src/compojure/api/coercion.clj +++ b/src/compojure/api/coercion.clj @@ -3,8 +3,9 @@ [compojure.api.exception :as ex] [compojure.api.request :as request] [compojure.api.coercion.core :as cc] - [compojure.api.coercion.schema] - [compojure.api.coercion.spec]) + ;; side effects + compojure.api.coercion.register-schema + compojure.api.coercion.register-spec) (:import (compojure.api.coercion.core CoercionError))) (def default-coercion :schema) diff --git a/src/compojure/api/coercion/register_schema.clj b/src/compojure/api/coercion/register_schema.clj new file mode 100644 index 00000000..e1e8f993 --- /dev/null +++ b/src/compojure/api/coercion/register_schema.clj @@ -0,0 +1,8 @@ +(ns compojure.api.coercion.register-schema + (:require [compojure.api.coercion.core :as cc])) + +(defmethod cc/named-coercion :schema [_] + (deref + (or (resolve 'compojure.api.coercion.schema/default-coercion) + (do (require 'compojure.api.coercion.schema) + (resolve 'compojure.api.coercion.schema/default-coercion))))) diff --git a/src/compojure/api/coercion/register_spec.clj b/src/compojure/api/coercion/register_spec.clj new file mode 100644 index 00000000..143320fb --- /dev/null +++ b/src/compojure/api/coercion/register_spec.clj @@ -0,0 +1,8 @@ +(ns compojure.api.coercion.register-spec + (:require [compojure.api.coercion.core :as cc])) + +(defmethod cc/named-coercion :spec [_] + (deref + (or (resolve 'compojure.api.coercion.spec/default-coercion) + (do (require 'compojure.api.coercion.spec) + (resolve 'compojure.api.coercion.spec/default-coercion))))) diff --git a/src/compojure/api/coercion/schema.clj b/src/compojure/api/coercion/schema.clj index b308d0c2..9a7e01b0 100644 --- a/src/compojure/api/coercion/schema.clj +++ b/src/compojure/api/coercion/schema.clj @@ -5,7 +5,9 @@ [compojure.api.coercion.core :as cc] [clojure.walk :as walk] [schema.core :as s] - [compojure.api.common :as common]) + [compojure.api.common :as common] + ;; side effects + compojure.api.coercion.register-schema) (:import (java.io File) (schema.core OptionalKey RequiredKey) (schema.utils ValidationError NamedError))) @@ -84,5 +86,3 @@ (->SchemaCoercion :schema options)) (def default-coercion (create-coercion default-options)) - -(defmethod cc/named-coercion :schema [_] default-coercion) diff --git a/src/compojure/api/coercion/spec.clj b/src/compojure/api/coercion/spec.clj index 9b20481a..b5d6ad31 100644 --- a/src/compojure/api/coercion/spec.clj +++ b/src/compojure/api/coercion/spec.clj @@ -6,7 +6,9 @@ [clojure.walk :as walk] [compojure.api.coercion.core :as cc] [spec-tools.swagger.core :as swagger] - [compojure.api.common :as common]) + [compojure.api.common :as common] + ;; side effects + compojure.api.coercion.register-spec) (:import (clojure.lang IPersistentMap) (schema.core RequiredKey OptionalKey) (spec_tools.core Spec) @@ -149,5 +151,3 @@ (->SpecCoercion :spec options)) (def default-coercion (create-coercion default-options)) - -(defmethod cc/named-coercion :spec [_] default-coercion) diff --git a/src/compojure/api/middleware.clj b/src/compojure/api/middleware.clj index ebc603b2..3d6ba644 100644 --- a/src/compojure/api/middleware.clj +++ b/src/compojure/api/middleware.clj @@ -8,6 +8,7 @@ [ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.nested-params :refer [wrap-nested-params]] [ring.middleware.params :refer [wrap-params]] + [ring.swagger.coerce :as coerce] [muuntaja.middleware] [muuntaja.core :as m] @@ -88,6 +89,12 @@ ;; 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] @@ -108,6 +115,20 @@ ([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}) + +;; 1.1.x +(defn coercion-matchers [request] + (let [options (get-options request)] + (if (contains? options :coercion) + (if-let [provider (:coercion options)] + (provider request)) + default-coercion-matchers))) + ;; ;; Muuntaja ;; diff --git a/test-suites/compojure1/.gitignore b/test-suites/compojure1/.gitignore new file mode 100644 index 00000000..eb5a316c --- /dev/null +++ b/test-suites/compojure1/.gitignore @@ -0,0 +1 @@ +target diff --git a/test-suites/compojure1/.lein-repl-history b/test-suites/compojure1/.lein-repl-history new file mode 100644 index 00000000..e69de29b diff --git a/test-suites/compojure1/project.clj b/test-suites/compojure1/project.clj new file mode 100644 index 00000000..ca1d8bd7 --- /dev/null +++ b/test-suites/compojure1/project.clj @@ -0,0 +1,93 @@ +(defproject metosin/compojure-api "1.1.14-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"} + :source-paths ["../../src"] + :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" ] + [org.clojure/core.memoize "0.8.2"] + [clj-commons/clj-yaml "0.7.0"] + [org.yaml/snakeyaml "1.24"] + [ring-middleware-format "0.7.4"] + [metosin/spec-tools "0.10.0"] + [metosin/ring-http-response "0.9.1"] + [metosin/ring-swagger-ui "3.24.3"] + [metosin/ring-swagger "0.26.2"] + + ;; Fix dependency conflicts + [clj-time "0.15.2"] + [joda-time "2.10.5"] + [riddley "0.2.0"]] + :profiles {:uberjar {:aot :all + :ring {:handler examples.thingie/app} + :source-paths ["examples/thingie/src"] + :dependencies [[org.clojure/clojure "1.10.1"] + [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"] + [funcool/codeina "0.5.0"]] + :dependencies [[org.clojure/clojure "1.10.1"] + [slingshot "0.12.2"] + [peridot "0.5.1"] + [javax.servlet/servlet-api "2.5"] + [midje "1.9.9"] + [com.stuartsierra/component "0.4.0"] + [reloaded.repl "0.2.4"] + [http-kit "2.3.0"] + [criterium "0.4.5"]] + :ring {:handler examples.thingie/app + :reload-paths ["src" "examples/thingie/src"]} + :source-paths ["examples/thingie/src" "examples/thingie/dev-src"] + :main examples.server} + :perf {:jvm-opts ^:replace ["-server" + "-Xmx4096m" + "-Dclojure.compiler.direct-linking=true"]} + :logging {:dependencies [[org.clojure/tools.logging "0.5.0"]]} + :1.8 {:dependencies [[org.clojure/clojure "1.8.0"]]} + :1.9 {:dependencies [[org.clojure/clojure "1.9.0"]]} + :1.10 {:dependencies [[org.clojure/clojure "1.10.1"]]}} + :eastwood {:namespaces [:source-paths] + :add-linters [:unused-namespaces]} + :codeina {:sources ["src"] + :target "gh-pages/doc" + :src-uri "http://github.com/metosin/compojure-api/blob/master/" + :src-uri-prefix "#L"} + :deploy-repositories [["snapshot" {:url "https://clojars.org/repo" + :username [:gpg :env/clojars_user] + :password [:gpg :env/clojars_token] + :sign-releases false}] + ["releases" {:url "https://clojars.org/repo" + :username [:gpg :env/clojars_user] + :password [:gpg :env/clojars_token] + :sign-releases false}]] + :release-tasks [["clean"] + ["vcs" "assert-committed"] + ["change" "version" "leiningen.release/bump-version" "release"] + ["vcs" "commit"] + ["vcs" "tag" "--no-sign"] + ["deploy" "release"] + ["change" "version" "leiningen.release/bump-version"] + ["vcs" "commit"] + ["vcs" "push"]] + :aliases {"all" ["with-profile" "dev:dev,logging:dev,1.10"] + "start-thingie" ["run"] + "aot-uberjar" ["with-profile" "uberjar" "do" "clean," "ring" "uberjar"] + "test-ancient" ["midje"] + "perf" ["with-profile" "default,dev,perf"] + "deploy!" ^{:doc "Recompile sources, then deploy if tests succeed."} + ["do" ["clean"] ["midje"] ["deploy" "clojars"]]}) diff --git a/test-suites/compojure1/test/compojure/api/coercion_test.clj b/test-suites/compojure1/test/compojure/api/coercion_test.clj new file mode 100644 index 00000000..7a16f523 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/coercion_test.clj @@ -0,0 +1,200 @@ +(ns compojure.api.coercion-test + (:require [compojure.api.sweet :refer :all] + [compojure.api.test-utils :refer :all] + [midje.sweet :refer :all] + [ring.util.http-response :refer :all] + [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" + (let [app (api + {:coercion mw/no-response-coercion} + ping-route)] + (let [[status body] (get* app "/ping")] + status => 200 + body => {:pong 123}))) + (fact "all coercion" + (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" [] + :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"})))) diff --git a/test-suites/compojure1/test/compojure/api/common_test.clj b/test-suites/compojure1/test/compojure/api/common_test.clj new file mode 100644 index 00000000..04a1a411 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/common_test.clj @@ -0,0 +1,28 @@ +(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}) diff --git a/test-suites/compojure1/test/compojure/api/compojure_perf_test.clj b/test-suites/compojure1/test/compojure/api/compojure_perf_test.clj new file mode 100644 index 00000000..06fa9475 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/compojure_perf_test.clj @@ -0,0 +1,126 @@ +(ns compojure.api.compojure-perf-test + (:require [compojure.core :as c] + [compojure.api.sweet :as s] + [criterium.core :as cc] + [ring.util.http-response :refer :all])) + +;; +;; start repl with `lein perf repl` +;; perf measured with the following setup: +;; +;; Model Name: MacBook Pro +;; Model Identifier: MacBookPro11,3 +;; Processor Name: Intel Core i7 +;; Processor Speed: 2,5 GHz +;; Number of Processors: 1 +;; Total Number of Cores: 4 +;; L2 Cache (per Core): 256 KB +;; L3 Cache: 6 MB +;; Memory: 16 GB +;; + +(defn title [s] + (println + (str "\n\u001B[35m" + (apply str (repeat (+ 6 (count s)) "#")) + "\n## " s " ##\n" + (apply str (repeat (+ 6 (count s)) "#")) + "\u001B[0m\n"))) + +(defn compojure-bench [] + + (let [app (c/routes + (c/GET "/a/b/c/1" [] "ok") + (c/GET "/a/b/c/2" [] "ok") + (c/GET "/a/b/c/3" [] "ok") + (c/GET "/a/b/c/4" [] "ok") + (c/GET "/a/b/c/5" [] "ok")) + + call #(app {:request-method :get :uri "/a/b/c/5"})] + + (title "Compojure - GET flattened") + (assert (-> (call) :body (= "ok"))) + (cc/quick-bench (call))) + + ;; 3.8µs + + (let [app (c/context "/a" [] + (c/context "/b" [] + (c/context "/c" [] + (c/GET "/1" [] "ok") + (c/GET "/2" [] "ok") + (c/GET "/3" [] "ok") + (c/GET "/4" [] "ok") + (c/GET "/5" [] "ok")) + (c/GET "/1" [] "ok") + (c/GET "/2" [] "ok") + (c/GET "/3" [] "ok") + (c/GET "/4" [] "ok") + (c/GET "/5" [] "ok")) + (c/GET "/1" [] "ok") + (c/GET "/2" [] "ok") + (c/GET "/3" [] "ok") + (c/GET "/4" [] "ok") + (c/GET "/5" [] "ok")) + + call #(app {:request-method :get :uri "/a/b/c/5"})] + + (title "Compojure - GET with context") + (assert (-> (call) :body (= "ok"))) + (cc/quick-bench (call))) + + ;; 15.9µs + + ) + +(defn compojure-api-bench [] + + (let [app (s/routes + (s/GET "/a/b/c/1" [] "ok") + (s/GET "/a/b/c/2" [] "ok") + (s/GET "/a/b/c/3" [] "ok") + (s/GET "/a/b/c/4" [] "ok") + (s/GET "/a/b/c/5" [] "ok")) + + call #(app {:request-method :get :uri "/a/b/c/5"})] + + (title "Compojure API - GET flattened") + (assert (-> (call) :body (= "ok"))) + (cc/quick-bench (call))) + + ;; 3.8µs + + (let [app (s/context "/a" [] + (s/context "/b" [] + (s/context "/c" [] + (s/GET "/1" [] "ok") + (s/GET "/2" [] "ok") + (s/GET "/3" [] "ok") + (s/GET "/4" [] "ok") + (s/GET "/5" [] "ok")) + (s/GET "/1" [] "ok") + (s/GET "/2" [] "ok") + (s/GET "/3" [] "ok") + (s/GET "/4" [] "ok") + (s/GET "/5" [] "ok")) + (s/GET "/1" [] "ok") + (s/GET "/2" [] "ok") + (s/GET "/3" [] "ok") + (s/GET "/4" [] "ok") + (s/GET "/5" [] "ok")) + + call #(app {:request-method :get :uri "/a/b/c/5"})] + + (title "Compojure API - GET with context") + (assert (-> (call) :body (= "ok"))) + (cc/quick-bench (call))) + + ;; 20.0µs + ) + +(defn bench [] + (compojure-bench) + (compojure-api-bench)) + +(comment + (bench)) diff --git a/test-suites/compojure1/test/compojure/api/exception_test.clj b/test-suites/compojure1/test/compojure/api/exception_test.clj new file mode 100644 index 00000000..1432ddc4 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/exception_test.clj @@ -0,0 +1,14 @@ +(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-suites/compojure1/test/compojure/api/integration_test.clj b/test-suites/compojure1/test/compojure/api/integration_test.clj new file mode 100644 index 00000000..1c907ded --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/integration_test.clj @@ -0,0 +1,1511 @@ +(ns compojure.api.integration-test + (:require [compojure.api.sweet :refer :all] + [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] + [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.routes :as routes] + + [ring.middleware.format-response :as format-response] + [cheshire.core :as json])) + +;; +;; Data +;; + +(s/defschema User {:id Long + :name String}) + +(def pertti {:id 1 :name "Pertti"}) + +(def invalid-user {:id 1 :name "Jorma" :age 50}) + +; Headers contain extra keys, so make the schema open +(s/defschema UserHeaders + (assoc User + s/Keyword s/Any)) + +;; +;; Middleware setup +;; + +(def mw* "mw") + +(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))))) + +(defn constant-middleware + "This middleware rewrites all responses with a constant response." + [_ res] + (constantly res)) + +(defn reply-mw* + "Handler which replies with response where a header contains copy + of the headers value from request and 7" + [request] + (-> (ok "true") + (header mw* (str (get-in request [:headers mw*]) "/")))) + +(defn middleware-x + "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))))) + +(defn custom-validation-error-handler [ex data request] + (let [error-body {:custom-error (:uri request)}] + (case (:type data) + ::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-error-handler [ex data request] + (ok {:custom-error (:data data)})) + +;; +;; Facts +;; + +(facts "core routes" + + (fact "keyword options" + (let [route (GET "/ping" [] + :return String + (ok "kikka"))] + (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"}))) + + (fact "map options" + (let [route (GET "/ping" [] + {:return String} + (ok "kikka"))] + (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"}))) + + (fact "map return" + (let [route (GET "/ping" [] + {:body "kikka"})] + (route {:request-method :get :uri "/ping"}) => (contains {:body "kikka"})))) + +(facts "middleware ordering" + (let [app (api + (middleware [middleware* [middleware* 2]] + (context "/middlewares" [] + :middleware [(fn [handler] (middleware* handler 3)) [middleware* 4]] + (GET "/simple" req (reply-mw* req)) + (middleware [#(middleware* % 5) [middleware* 6]] + (GET "/nested" req (reply-mw* req)) + (GET "/nested-declared" req + :middleware [(fn [handler] (middleware* handler 7)) [middleware* 8]] + (reply-mw* req))))))] + + (fact "are applied left-to-right" + (let [[status _ headers] (get* app "/middlewares/simple" {})] + status => 200 + (get headers mw*) => "1234/4321")) + + (fact "are applied left-to-right closest one first" + (let [[status _ headers] (get* app "/middlewares/nested" {})] + status => 200 + (get headers mw*) => "123456/654321")) + + (fact "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")))) + +(facts "middleware - multiple routes" + (let [app (api + (GET "/first" [] + (ok {:value "first"})) + (GET "/second" [] + :middleware [[constant-middleware (ok {:value "foo"})]] + (ok {:value "second"})) + (GET "/third" [] + (ok {:value "third"})))] + (fact "first returns first" + (let [[status body] (get* app "/first" {})] + status => 200 + body => {:value "first"})) + (fact "second returns foo" + (let [[status body] (get* app "/second" {})] + status => 200 + body => {:value "foo"})) + (fact "third returns third" + (let [[status body] (get* app "/third" {})] + status => 200 + body => {:value "third"})))) + +(facts "middleware - editing request" + (let [app (api + (GET "/first" [] + :query-params [x :- Long] + :middleware [middleware-x] + (ok {:value x})))] + (fact "middleware edits the parameter before route body" + (let [[status body] (get* app "/first?x=5" {})] + status => 200 + body => {:value 10})))) + +(fact ":body, :query, :headers and :return" + (let [app (api + (context "/models" [] + (GET "/pertti" [] + :return User + (ok pertti)) + (GET "/user" [] + :return User + :query [user User] + (ok user)) + (GET "/invalid-user" [] + :return User + (ok invalid-user)) + (GET "/not-validated" [] + (ok invalid-user)) + (POST "/user" [] + :return User + :body [user User] + (ok user)) + (POST "/user_list" [] + :return [User] + :body [users [User]] + (ok users)) + (POST "/user_set" [] + :return #{User} + :body [users #{User}] + (ok users)) + (POST "/user_headers" [] + :return User + :headers [user UserHeaders] + (ok (select-keys user [:id :name]))) + (POST "/user_legacy" {user :body-params} + :return User + (ok user))))] + + (fact "GET" + (let [[status body] (get* app "/models/pertti")] + status => 200 + body => pertti)) + + (fact "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" + (let [[status body] (headers-post* app "/models/user_headers" pertti)] + status => 200 + body => pertti)) + + (fact "Validation of returned data" + (let [[status] (get* app "/models/invalid-user")] + status => 500)) + + (fact "Routes without a :return parameter aren't validated" + (let [[status body] (get* app "/models/not-validated")] + status => 200 + body => invalid-user)) + + (fact "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"))))) + +(fact ":responses" + (fact "normal cases" + (let [app (api + (swagger-routes) + (GET "/lotto/:x" [] + :path-params [x :- Long] + :responses {403 {:schema [String]} + 440 {:schema [String]}} + :return [Long] + (case x + 1 (ok [1]) + 2 (ok ["two"]) + 3 (forbidden ["error"]) + 4 (forbidden [1]) + (not-found {:message "not-found"}))))] + + (fact "return case" + (let [[status body] (get* app "/lotto/1")] + status => 200 + body => [1])) + + (fact "return case, non-matching model" + (let [[status body] (get* app "/lotto/2")] + status => 500 + body => (contains {:errors vector?}))) + + (fact "error case" + (let [[status body] (get* app "/lotto/3")] + status => 403 + body => ["error"])) + + (fact "error case, non-matching model" + (let [[status body] (get* app "/lotto/4")] + status => 500 + body => (contains {:errors vector?}))) + + (fact "returning non-predefined http-status code works" + (let [[status body] (get* app "/lotto/5")] + body => {:message "not-found"} + status => 404)) + + (fact "swagger-docs for multiple returns" + (-> app get-spec :paths vals first :get :responses keys set)))) + + (fact ":responses 200 and :return" + (let [app (api + (GET "/lotto/:x" [] + :path-params [x :- Long] + :return {:return String} + :responses {200 {:schema {:value String}}} + (case x + 1 (ok {:return "ok"}) + 2 (ok {:value "ok"}))))] + + (fact "return case" + (let [[status body] (get* app "/lotto/1")] + status => 500 + body => (contains {:errors {:return "disallowed-key" + :value "missing-required-key"}}))) + + (fact "return case" + (let [[status body] (get* app "/lotto/2")] + status => 200 + body => {:value "ok"})))) + + (fact ":responses 200 and :return - other way around" + (let [app (api + (GET "/lotto/:x" [] + :path-params [x :- Long] + :responses {200 {:schema {:value String}}} + :return {:return String} + (case x + 1 (ok {:return "ok"}) + 2 (ok {:value "ok"}))))] + + (fact "return case" + (let [[status body] (get* app "/lotto/1")] + status => 200 + body => {:return "ok"})) + + (fact "return case" + (let [[status body] (get* app "/lotto/2")] + status => 500 + body => (contains {:errors {:return "missing-required-key" + :value "disallowed-key"}})))))) + +(fact ":query-params, :path-params, :header-params , :body-params and :form-params" + (let [app (api + (context "/smart" [] + (GET "/plus" [] + :query-params [x :- Long y :- Long] + (ok {:total (+ x y)})) + (GET "/multiply/:x/:y" [] + :path-params [x :- Long y :- Long] + (ok {:total (* x y)})) + (GET "/power" [] + :header-params [x :- Long y :- Long] + (ok {:total (long (Math/pow x y))})) + (POST "/minus" [] + :body-params [x :- Long {y :- Long 1}] + (ok {:total (- x y)})) + (POST "/divide" [] + :form-params [x :- Long y :- Long] + (ok {:total (/ x y)}))))] + + (fact "query-parameters" + (let [[status body] (get* app "/smart/plus" {:x 2 :y 3})] + status => 200 + body => {:total 5})) + + (fact "path-parameters" + (let [[status body] (get* app "/smart/multiply/2/3")] + status => 200 + body => {:total 6})) + + (fact "header-parameters" + (let [[status body] (get* app "/smart/power" {} {:x 2 :y 3})] + status => 200 + body => {:total 8})) + + (fact "form-parameters" + (let [[status body] (form-post* app "/smart/divide" {:x 6 :y 3})] + status => 200 + body => {:total 2})) + + (fact "body-parameters" + (let [[status body] (post* app "/smart/minus" (json {:x 2 :y 3}))] + status => 200 + body => {:total -1})) + + (fact "default parameters" + (let [[status body] (post* app "/smart/minus" (json {:x 2}))] + status => 200 + body => {:total 1})))) + +(fact "primitive support" + (let [app (api + {:swagger {:spec "/swagger.json"}} + (context "/primitives" [] + (GET "/return-long" [] + :return Long + (ok 1)) + (GET "/long" [] + (ok 1)) + (GET "/return-string" [] + :return String + (ok "kikka")) + (POST "/arrays" [] + :return [Long] + :body [longs [Long]] + (ok longs))))] + + (fact "when :return is set, longs can be returned" + (let [[status body] (raw-get* app "/primitives/return-long")] + status => 200 + body => "1")) + + (fact "when :return is not set, longs won't be encoded" + (let [[status body] (raw-get* app "/primitives/long")] + status => 200 + body => number?)) + + (fact "when :return is set, raw strings can be returned" + (let [[status body] (raw-get* app "/primitives/return-string")] + status => 200 + body => "\"kikka\"")) + + (fact "primitive arrays work" + (let [[status body] (raw-post* app "/primitives/arrays" (json/generate-string [1 2 3]))] + status => 200 + body => "[1,2,3]")) + + (fact "swagger-spec is valid" + (validator/validate app)) + + (fact "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"}}] + + (-> app get-spec :paths (get "/primitives/arrays") :post :responses :200 :schema) + => {:items {:format "int64", + :type "integer"}, + :type "array"}))) + +(fact "compojure destructuring support" + (let [app (api + (context "/destructuring" [] + (GET "/regular" {{:keys [a]} :params} + (ok {:a a + :b (-> +compojure-api-request+ :params :b)})) + (GET "/regular2" {:as req} + (ok {:a (-> req :params :a) + :b (-> +compojure-api-request+ :params :b)})) + (GET "/vector" [a] + (ok {:a a + :b (-> +compojure-api-request+ :params :b)})) + (GET "/vector2" [:as req] + (ok {:a (-> req :params :a) + :b (-> +compojure-api-request+ :params :b)})) + (GET "/symbol" req + (ok {:a (-> req :params :a) + :b (-> +compojure-api-request+ :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} + (let [[status body] (get* app (str "/destructuring/" uri) {:a "a" :b "b"})] + status => 200 + body => {:a "a" :b "b"}))))) + +(fact "counting execution times, issue #19" + (let [execution-times (atom 0) + app (api + (GET "/user" [] + :return User + :query [user User] + (swap! execution-times inc) + (ok user)))] + + (fact "body is executed one" + @execution-times => 0 + (let [[status body] (get* app "/user" pertti)] + status => 200 + body => pertti) + @execution-times => 1))) + +(fact "swagger-docs" + (let [app (api + {:format {:formats [:json-kw :edn :UNKNOWN]}} + (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 ""}}}}}})) + + (fact "swagger-routes" + + (fact "with defaults" + (let [app (api (swagger-routes))] + + (fact "api-docs are mounted to /" + (let [[status body] (raw-get* app "/")] + status => 200 + body => #"<title>Swagger UI</title>")) + + (fact "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + status => 200 + body => (contains {:swagger "2.0"}))))) + + (fact "with partial overridden values" + (let [app (api (swagger-routes {:ui "/api-docs" + :data {:info {:title "Kikka"} + :paths {"/ping" {:get {}}}}}))] + + (fact "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + status => 200 + body => #"<title>Swagger UI</title>")) + + (fact "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})})))))) + + (fact "swagger via api-options" + + (fact "with defaults" + (let [app (api)] + + (fact "api-docs are not mounted" + (let [[status body] (raw-get* app "/")] + status => nil)) + + (fact "spec is not mounted" + (let [[status body] (get* app "/swagger.json")] + status => nil)))) + + (fact "with spec" + (let [app (api {:swagger {:spec "/swagger.json"}})] + + (fact "api-docs are not mounted" + (let [[status body] (raw-get* app "/")] + status => nil)) + + (fact "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + status => 200 + body => (contains {:swagger "2.0"})))))) + + (fact "with ui" + (let [app (api {:swagger {:ui "/api-docs"}})] + + (fact "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + status => 200 + body => #"<title>Swagger UI</title>")) + + (fact "spec is not mounted" + (let [[status body] (get* app "/swagger.json")] + status => nil)))) + + (fact "with ui and spec" + (let [app (api {:swagger {:spec "/swagger.json", :ui "/api-docs"}})] + + (fact "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + status => 200 + body => #"<title>Swagger UI</title>")) + + (fact "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + status => 200 + body => (contains {:swagger "2.0"})))))) + +(facts "swagger-docs with anonymous Return and Body models" + (let [app (api + (swagger-routes) + (POST "/echo" [] + :return (s/either {:a String}) + :body [_ (s/maybe {:a String})] + identity))] + + (fact "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) + + (fact "generated return-param is found in Definitions" + return-ref => truthy + (find-definition spec body-ref) => truthy)))))) + +(def Boundary + {:type (s/enum "MultiPolygon" "Polygon" "MultiPoint" "Point") + :coordinates [s/Any]}) + +(def ReturnValue + {:boundary (s/maybe Boundary)}) + +(facts "https://github.com/metosin/compojure-api/issues/53" + (let [app (api + (swagger-routes) + (POST "/" [] + :return ReturnValue + :body [_ Boundary] + identity))] + + (fact "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) + + (fact "generated return-param is found in Definitions" + return-ref => truthy + (find-definition spec body-ref) => truthy)))))) + +(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" + (let [app (api + (swagger-routes) + (POST "/" [] + :return Urho + :body [_ Olipa] + identity))] + + (fact "api-docs" + (let [spec (get-spec app)] + + (fact "nested models are discovered correctly" + (-> spec :definitions keys set) + + => #{:Urho :UrhoKaleva :UrhoKalevaKekkonen + :Olipa :OlipaKerran :OlipaKerranAvaruus}))))) + +(fact "swagger-docs works with the :middleware" + (let [app (api + (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 ""}}}}))) + +(fact "sub-context paths" + (let [response {:ping "pong"} + ok (ok response) + ok? (fn [[status body]] + (and (= status 200) + (= body response))) + not-ok? (comp not ok?) + app (api + (swagger-routes {:ui nil}) + (GET "/" [] ok) + (GET "/a" [] ok) + (context "/b" [] + (context "/b1" [] + (GET "/" [] ok)) + (context "/" [] + (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?) + + (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?)) + + (fact "swagger-docs have trailing slashes removed" + (->> app get-spec :paths keys) + => ["/" "/a" "/b/b1" "/b" "/b/b2"]))) + +(fact "formats supported by ring-middleware-format" + (let [app (api + (POST "/echo" [] + :body-params [foo :- String] + (ok {:foo foo})))] + + (tabular + (facts + (fact {:midje/description (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)} + (let [[status body] + (raw-post* app "/echo" "{\"foo\":\"bar\"}" "application/json" {:accept ?content-type})] + status => 200 + 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" + (let [app (api + (context "/foo" [] + (GET "/bar" [] (ok ["bar"])) + (GET "/baz" [] (ok ["baz"]))))] + + (fact "first route works" + (let [[status body] (get* app "/foo/bar")] + status => 200 + body => ["bar"])) + (fact "second route works" + (let [[status body] (get* app "/foo/baz")] + status => 200 + body => ["baz"])))) + +(require '[compojure.api.test-domain :refer [Pizza burger-routes]]) + +(fact "external deep schemas" + (let [app (api + (swagger-routes) + burger-routes + (POST "/pizza" [] + :return Pizza + :body [body Pizza] + (ok body)))] + + (fact "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)) + + (fact "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)) + + (fact "generates correct swagger-spec" + (-> app get-spec :definitions keys set) => #{:Topping :Pizza :Burger :Beef}))) + +(fact "multiple routes with same path & method in same file" + (let [app (api + (swagger-routes) + (GET "/ping" [] + :summary "active-ping" + (ok {:ping "active"})) + (GET "/ping" [] + :summary "passive-ping" + (ok {:ping "passive"})))] + + (fact "first route matches with Compojure" + (let [[status body] (get* app "/ping" {})] + status => 200 + body => {:ping "active"})) + + (fact "generates correct swagger-spec" + (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + +(fact "multiple routes with same path & method over context" + (let [app (api + (swagger-routes) + (context "/api" [] + (context "/ipa" [] + (GET "/ping" [] + :summary "active-ping" + (ok {:ping "active"})))) + (context "/api" [] + (context "/ipa" [] + (GET "/ping" [] + :summary "passive-ping" + (ok {:ping "passive"})))))] + + (fact "first route matches with Compojure" + (let [[status body] (get* app "/api/ipa/ping" {})] + status => 200 + body => {:ping "active"})) + + (fact "generates correct swagger-spec" + (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + +(fact "multiple routes with same overall path (with different path sniplets & method over context" + (let [app (api + (swagger-routes) + (context "/api/ipa" [] + (GET "/ping" [] + :summary "active-ping" + (ok {:ping "active"}))) + (context "/api" [] + (context "/ipa" [] + (GET "/ping" [] + :summary "passive-ping" + (ok {:ping "passive"})))))] + + (fact "first route matches with Compojure" + (let [[status body] (get* app "/api/ipa/ping" {})] + status => 200 + body => {:ping "active"})) + + (fact "generates correct swagger-spec" + (-> app get-spec :paths vals first :get :summary) => "active-ping"))) + +; https://github.com/metosin/compojure-api/issues/98 +; https://github.com/metosin/compojure-api/issues/134 +(fact "basePath" + (let [app (api (swagger-routes))] + + (fact "no context" + (-> app get-spec :basePath) => "/") + + (fact "app-servers with given context" + (against-background (rsc/context anything) => "/v2") + (-> app get-spec :basePath) => "/v2")) + + (let [app (api (swagger-routes {:data {:basePath "/serve/from/here"}}))] + (fact "override it" + (-> app get-spec :basePath) => "/serve/from/here")) + + (let [app (api (swagger-routes {:data {:basePath "/"}}))] + (fact "can set it to the default" + (-> app get-spec :basePath) => "/"))) + +(fact "multiple different models with same name" + + (fact "schemas with same regexps are not equal" + {:d #"\D"} =not=> {:d #"\D"}) + + (fact "api-spec with 2 schemas with non-equal contents" + (let [app (api + (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)))) + +(def over-the-hills-and-far-away + (POST "/" [] + :body-params [a :- s/Str] + identity)) + +(fact "anonymous body models over defined routes" + (let [app (api + (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))) + +(def foo + (GET "/foo" [] + (let [foo {:foo "bar"}] + (ok foo)))) + +(fact "defroutes with local symbol usage with same name (#123)" + (let [app (api + foo)] + (let [[status body] (get* app "/foo")] + status => 200 + body => {:foo "bar"}))) + +(def response-descriptions-routes + (GET "/x" [] + :responses {500 {:schema {:code String} + :description "Horror"}} + identity)) + +(fact "response descriptions" + (let [app (api + (swagger-routes) + response-descriptions-routes)] + (-> app get-spec :paths vals first :get :responses :500 :description) => "Horror")) + +(fact "exceptions options with custom validation error handler" + (let [app (api + {: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) + (POST "/get-long" [] + :body [body {:x Long}] + :return Long + (case (:x body) + 1 (ok 1) + (ok "not a number"))))] + + (fact "return case, valid request & valid model" + (let [[status body] (post* app "/get-long" "{\"x\": 1}")] + status => 200 + body => 1)) + + (fact "return case, not schema valid request" + (let [[status body] (post* app "/get-long" "{\"x\": \"1\"}")] + status => 400 + body => (contains {:custom-error "/get-long"}))) + + (fact "return case, invalid json request" + (let [[status body] (post* app "/get-long" "{x: 1}")] + status => 400 + body => (contains {:custom-error "/get-long"}))) + + (fact "return case, valid request & invalid model" + (let [[status body] (post* app "/get-long" "{\"x\": 2}")] + status => 501 + body => (contains {:custom-error "/get-long"}))))) + +(fact "exceptions options with custom exception and error handler" + (let [app (api + {:exceptions {:handlers {::ex/default custom-exception-handler + ::custom-error custom-error-handler}}} + (swagger-routes) + (GET "/some-exception" [] + (throw (new 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}))))] + + (fact "uses default exception handler for unknown exceptions" + (let [[status body] (get* app "/some-exception")] + status => 200 + body => {:custom-exception "java.lang.RuntimeException"})) + + (fact "uses default exception handler for unknown errors" + (let [[status body] (get* app "/some-error")] + status => 200 + (:custom-exception body) => (contains ":data \"some error\""))) + + (fact "uses specific error handler for ::custom-errors" + (let [[status body] (get* app "/specific-error")] + body => {:custom-error "my error"})))) + +(fact "exception handling can be disabled" + (let [app (api + {: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"))) + +(s/defn schema-error [a :- s/Int] + {:bar a}) + +(fact "handling schema.core/error" + (let [app (api + {: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?})))) + +(fact "ring-swagger options" + (let [app (api + {: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.")) + +(fact "path-for" + (fact "simple case" + (let [app (api + (GET "/api/pong" [] + :name :pong + (ok {:pong "pong"})) + (GET "/api/ping" [] + (moved-permanently (path-for :pong))))] + (fact "path-for works" + (let [[status body] (get* app "/api/ping" {})] + status => 200 + body => {:pong "pong"})))) + + (fact "with path parameters" + (let [app (api + (GET "/lost-in/:country/:zip" [] + :name :lost + :path-params [country :- (s/enum :FI :EN), zip :- s/Int] + (ok {:country country + :zip zip})) + (GET "/api/ping" [] + (moved-permanently + (path-for :lost {:country :FI, :zip 33200}))))] + (fact "path-for resolution" + (let [[status body] (get* app "/api/ping" {})] + status => 200 + body => {:country "FI" + :zip 33200})))) + + (fact "https://github.com/metosin/compojure-api/issues/150" + (let [app (api + (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" + (let [[status body] (get* app "/companies/4/refresh")] + status => 200 + body => "/companies/4/refresh")))) + + (fact "multiple routes with same name fail at compile-time" + (let [app' `(api + (GET "/api/pong" [] + :name :pong + identity) + (GET "/api/ping" [] + :name :pong + identity))] + (eval app') => (throws RuntimeException)))) + + +(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"))) + +(defrecord NonSwaggerRecord [data]) + +(fact "api validation" + + (fact "a swagger api with valid swagger records" + (let [app (api + (swagger-routes) + (GET "/ping" [] + :return {:data s/Str} + (ok {:data "ping"})))] + + (fact "works" + (let [[status body] (get* app "/ping")] + status => 200 + body => {:data "ping"})) + + (fact "the api is valid" + (validator/validate app) => app))) + + (fact "a swagger api with invalid swagger records" + (let [app (api + (swagger-routes) + (GET "/ping" [] + :return NonSwaggerRecord + (ok (->NonSwaggerRecord "ping"))))] + + (fact "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" + (let [app (api + (GET "/ping" [] + :return NonSwaggerRecord + (ok (->NonSwaggerRecord "ping"))))] + + (fact "works" + (let [[status body] (get* app "/ping")] + status => 200 + body => {:data "ping"})) + + (fact "the api is valid" + (validator/validate app) => app)))) + +(fact "component integration" + (let [system {:magic 42}] + (fact "via options" + (let [app (api + {:components system} + (GET "/magic" [] + :components [magic] + (ok {:magic magic})))] + (let [[status body] (get* app "/magic")] + status => 200 + body => {:magic 42}))) + + (fact "via middleware" + (let [handler (api + (GET "/magic" [] + :components [magic] + (ok {:magic magic}))) + app (mw/wrap-components handler system)] + (let [[status body] (get* app "/magic")] + status => 200 + body => {:magic 42}))))) + +(fact "sequential string parameters" + (let [app (api + (GET "/ints" [] + :query-params [i :- [s/Int]] + (ok {:i i})))] + (fact "multiple values" + (let [[status body] (get* app "/ints?i=1&i=2&i=3")] + status => 200 + body => {:i [1, 2, 3]})) + (fact "single value" + (let [[status body] (get* app "/ints?i=42")] + status => 200 + body => {:i [42]})))) + +(fact ":swagger params just for ducumentation" + (fact "compile-time values" + (let [app (api + (swagger-routes) + (GET "/route" [q] + :swagger {:x-name :boolean + :operationId "echoBoolean" + :description "Ehcoes a boolean" + :parameters {:query {:q s/Bool}}} + (ok {:q q})))] + + (fact "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" + (let [runtime-data {:x-name :boolean + :operationId "echoBoolean" + :description "Ehcoes a boolean" + :parameters {:query {:q s/Bool}}} + app (api + (swagger-routes) + (GET "/route" [q] + :swagger runtime-data + (ok {:q q})))] + + (fact "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" + (let [routes (routes + (context "/api" [] + (GET "/ping" [] + :summary "ping" + (ok {:message "pong"})) + (POST "/pong" [] + :summary "pong" + (ok {:message "ping"}))) + (ANY "*" [] + (ok {:message "404"}))) + api1 (api {:swagger {:spec "/swagger.json", :ui "/"}} routes) + api2 (api (swagger-routes) routes)] + + (fact "both generate same swagger-spec" + (get-spec api1) => (get-spec api2)) + + (fact "not-found handler works" + (second (get* api1 "/missed")) => {:message "404"} + (second (get* api2 "/missed")) => {:message "404"}))) + +(fact "more swagger-data can be (deep-)merged in - either via swagger-docs at runtime via mws, fixes #170" + (let [app (api + (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))) + +(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))) + + (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))) + + (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)))) + +(fact "handling invalid routes with api" + (let [invalid-routes (routes (constantly nil))] + + (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" + (let [responses {400 {:schema {:fail s/Str}}} + app (api + {:swagger {:spec "/swagger.json" + :data {:info {:version "2.0.0"}}}} + (GET "/a" [] + :responses responses + :return {:ok s/Str} + (ok)) + (GET "/b" [] + :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" + (let [wrap-mw-params (fn [handler value] + (fn [request] + (handler + (update request ::mw #(str % value)))))] + (fact "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"}))) + + (fact "from endpoint under context" + (let [app (context "/api" [] + :middleware [[wrap-mw-params "1"]] + :query-params [{a :- s/Str "a"}] + (GET "/ping" [] + :middleware [[wrap-mw-params "2"]] + :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"}))))) + +(defn check-for-response-handler + "This response-validation handler checks for the existence of :response in its input. If it's there, it + returns status 200, including the value that was origingally returned. Otherwise it returns 404." + [^Exception e data request] + (if (:response data) + (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" + (let [incorrect-return-value {:incorrect "response"} + app (api + {: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" + (let [[status body] (get* app "/test-response")] + status => 200 + (:attempted-body body) => incorrect-return-value)))) + +(fact "correct swagger parameter order with small number or parameters, #224" + (let [app (api + (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" + (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" + (let [app (api + {:swagger {:spec "/swagger.json"}} + (GET "/" [] (ok {:kikka "kukka"})))] + (fact "api works" + (let [[status body] (get* app "/")] + status => 200 + body => {:kikka "kukka"})) + (fact "swagger docs" + (-> app get-spec :paths keys) => ["/"]))) + +(fact "describe works on anonymous bodys, #168" + (let [app (api + (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"})))) + +(facts "swagger responses headers are mapped correctly, #232" + (let [app (api + (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"}})) + +(facts "api-middleware can be disabled" + (let [app (api + {: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" + (let [[status body] (raw-get* app "/params" {:x 1})] + status => 200 + body => {:x nil})) + + (fact "exceptions are not caught" + (raw-get* app "/throw") => throws))) + +(facts "custom formats contribute to Swagger :consumes & :produces" + (let [custom-json (format-response/make-encoder json "application/vnd.vendor.v1+json") + app (api + {:swagger {:spec "/swagger.json"} + :format {:formats [custom-json :json]}} + (POST "/echo" [] + :body [data {:kikka s/Str}] + (ok data)))] + + (fact "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"}))) + + (fact "spec is correct" + (get-spec app) => (contains + {:produces ["application/vnd.vendor.v1+json" "application/json"] + :consumes ["application/vnd.vendor.v1+json" "application/json"]})))) + +(fact "static contexts work" + (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"]}))) diff --git a/test-suites/compojure1/test/compojure/api/meta_test.clj b/test-suites/compojure1/test/compojure/api/meta_test.clj new file mode 100644 index 00000000..68c1a03d --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/meta_test.clj @@ -0,0 +1,7 @@ +(ns compojure.api.meta-test + (:require [compojure.api.meta :refer :all] + [midje.sweet :refer :all])) + +(fact "src-coerce! with deprecated types" + (src-coerce! nil nil :query) => (throws AssertionError) + (src-coerce! nil nil :json) => (throws AssertionError)) diff --git a/test-suites/compojure1/test/compojure/api/middleware_test.clj b/test-suites/compojure1/test/compojure/api/middleware_test.clj new file mode 100644 index 00000000..5e1fc64e --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/middleware_test.clj @@ -0,0 +1,88 @@ +(ns compojure.api.middleware-test + (:require [compojure.api.middleware :refer :all] + [compojure.api.exception :as ex] + [midje.sweet :refer :all] + [ring.util.http-response :refer [ok]] + [ring.util.http-status :as status] + ring.util.test + [slingshot.slingshot :refer [throw+]]) + (:import [java.io PrintStream ByteArrayOutputStream])) + +(defmacro without-err + "Evaluates exprs in a context in which *err* is bound to a fresh + StringWriter. Returns the string created by any nested printing + calls." + [& body] + `(let [s# (PrintStream. (ByteArrayOutputStream.)) + err# (System/err)] + (System/setErr s#) + (try + ~@body + (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 + + {: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)) + +(def default-options (:exceptions api-middleware-defaults)) + +(facts "wrap-exceptions" + (with-out-str + (without-err + (let [exception (RuntimeException. "kosh") + exception-class (.getName (.getClass exception)) + handler (-> (fn [_] (throw 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"})}))))) + + (with-out-str + (without-err + (fact "Slingshot exception map type can be matched" + (let [handler (-> (fn [_] (throw+ {:type ::test} (RuntimeException. "kosh"))) + (wrap-exceptions (assoc-in default-options [:handlers ::test] (fn [ex _ _] {:status 500 :body "hello"}))))] + (handler {}) => (contains {:status status/internal-server-error + :body "hello"}))))) + + (without-err + (fact "Default handler logs exceptions to console" + (let [handler (-> (fn [_] (throw (RuntimeException. "kosh"))) + (wrap-exceptions default-options))] + (with-out-str (handler {})) => "ERROR kosh\n"))) + + (without-err + (fact "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 {})) => ""))) + + (without-err + (fact "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")))) + +(facts "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)) diff --git a/test-suites/compojure1/test/compojure/api/perf_test.clj b/test-suites/compojure1/test/compojure/api/perf_test.clj new file mode 100644 index 00000000..ce960b2a --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/perf_test.clj @@ -0,0 +1,237 @@ +(ns compojure.api.perf-test + (:require [compojure.api.sweet :refer :all] + [compojure.api.test-utils :as h] + [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]) + (:import (java.io ByteArrayInputStream))) + +;; +;; start repl with `lein perf repl` +;; perf measured with the following setup: +;; +;; Model Name: MacBook Pro +;; Model Identifier: MacBookPro11,3 +;; Processor Name: Intel Core i7 +;; Processor Speed: 2,5 GHz +;; Number of Processors: 1 +;; Total Number of Cores: 4 +;; L2 Cache (per Core): 256 KB +;; L3 Cache: 6 MB +;; Memory: 16 GB +;; + +(defn title [s] + (println + (str "\n\u001B[35m" + (apply str (repeat (+ 6 (count s)) "#")) + "\n## " s " ##\n" + (apply str (repeat (+ 6 (count s)) "#")) + "\u001B[0m\n"))) + +(defn post* [app uri json] + (-> + (app {:uri uri + :request-method :post + :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 + :address (s/maybe {:street s/Str + :country (s/enum "FI" "PO")}) + :orders [{:name #"^k" + :price s/Any + :shipping s/Bool}]}) + +(defn bench [] + + ; 27µs + ; 27µs (-0%) + ; 25µs (1.0.0) + (let [app (api + (GET "/30" [] + (ok {:result 30}))) + call #(h/get* app "/30")] + + (title "GET JSON") + (assert (= {:result 30} (second (call)))) + (cc/bench (call))) + + ;; 73µs + ;; 53µs (-27%) + ;; 50µs (1.0.0) + (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}) + call #(post* app "/plus" data)] + + (title "JSON POST with 2-way coercion") + (assert (= {:result 30} (parse (call)))) + (cc/bench (call))) + + ;; 85µs + ;; 67µs (-21%) + ;; 66µs (1.0.0) + (let [app (api + (context "/a" [] + (context "/b" [] + (context "/c" [] + (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}) + call #(post* app "/a/b/c/plus" data)] + + (title "JSON POST with 2-way coercion + contexts") + (assert (= {:result 30} (parse (call)))) + (cc/bench (call))) + + ;; 266µs + ;; 156µs (-41%) + ;; 146µs (1.0.0) + (let [app (api + (POST "/echo" [] + :return Order + :body [order Order] + (ok order))) + data (h/json {:id "123" + :name "Tommi's order" + :description "Totally great order" + :address {:street "Randomstreet 123" + :country "FI"} + :orders [{:name "k1" + :price 123.0 + :shipping true} + {:name "k2" + :price 42.0 + :shipping false}]}) + call #(post* app "/echo" data)] + + (title "JSON POST with nested data") + (s/validate Order (parse (call))) + (cc/bench (call)))) + +(defn resource-bench [] + + (let [resource-map {:post {:responses {200 {:schema {:result s/Int}}} + :parameters {:body-params {:x s/Int, :y s/Int}} + :handler (fn [{{:keys [x y]} :body-params}] + (ok {:result (+ x y)}))}}] + + ;; 62µs + (let [my-resource (resource resource-map) + app (api + (context "/plus" [] + my-resource)) + data (h/json {: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))) + + ;; 68µs + (let [app (api + (context "/plus" [] + (resource resource-map))) + data (h/json {: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))) + + ;; 26µs + (let [my-resource (resource resource-map) + app my-resource + data {:x 10, :y 20} + call #(app {:request-method :post :uri "/irrelevant" :body-params data})] + + (title "direct POST to pre-defined resource with 2-way coercion") + (assert (= {:result 30} (:body (call)))) + (cc/bench (call))) + + ;; 30µs + (let [my-resource (resource resource-map) + app (context "/plus" [] + my-resource) + data {:x 10, :y 20} + call #(app {:request-method :post :uri "/plus" :body-params data})] + + (title "POST to pre-defined resource with 2-way coercion") + (assert (= {:result 30} (:body (call)))) + (cc/bench (call))) + + ;; 40µs + (let [app (context "/plus" [] + (resource resource-map)) + data {:x 10, :y 20} + call #(app {:request-method :post :uri "/plus" :body-params data})] + + (title "POST to inlined resource with 2-way coercion") + (assert (= {:result 30} (:body (call)))) + (cc/bench (call))))) + +(defn e2e-json-comparison-different-payloads [] + (let [json-request (fn [data] + {:uri "/echo" + :request-method :post + :headers {"content-type" "application/json" + "accept" "application/json"} + :body (cheshire/generate-string data)}) + request-stream (fn [request] + (let [b (.getBytes ^String (:body request))] + (fn [] + (assoc request :body (ByteArrayInputStream. b))))) + app (api + (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 (cheshire/parse-string (slurp file)) + request (json-request data) + request! (request-stream request)]] + + "10b" + ;; 42µs + + "100b" + ;; 79µs + + "1k" + ;; 367µs + + "10k" + ;; 2870µs + + "100k" + ;; 10800µs + + (title file) + (cc/bench (-> (request!) app :body slurp))))) + +(comment + (bench) + (resource-bench) + (e2e-json-comparison-different-payloads)) + +(comment + (bench) + (resource-bench)) diff --git a/test-suites/compojure1/test/compojure/api/resource_test.clj b/test-suites/compojure1/test/compojure/api/resource_test.clj new file mode 100644 index 00000000..fbe6ccb5 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/resource_test.clj @@ -0,0 +1,206 @@ +(ns compojure.api.resource-test + (:require [compojure.api.sweet :refer :all] + [compojure.api.test-utils :refer :all] + [plumbing.core :refer [fnk]] + [midje.sweet :refer :all] + [ring.util.http-response :refer :all] + [schema.core :as s]) + (:import [clojure.lang ExceptionInfo])) + +(defn has-body [expected] + (fn [{:keys [body]}] + (= body expected))) + +(def request-validation-failed? + (throws ExceptionInfo #"Request validation failed")) + +(def response-validation-failed? + (throws ExceptionInfo #"Response validation failed")) + +(facts "resource definitions" + + (fact "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})))) + + (fact "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}))) + + (fact "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" + (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)}))} + :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}) + + (fact "if no handler is found, nil is returned" + (handler {:request-method :post, :query-params {:x "1"}}) => nil))) + + (fact "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"}))) + + (fact "resource without coercion" + (let [handler (resource + {: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" + (let [handler (resource + {:get {:parameters {:query-params {:q s/Str} + :body-params {:b s/Str} + :form-params {:f s/Str} + :header-params {:h s/Str} + :path-params {:p s/Str}} + :handler (fn [request] + (ok (select-keys request [:query-params + :body-params + :form-params + :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" + (let [handler (resource + {:responses {200 {:schema {:total (s/constrained Long pos? 'pos)}}} + :parameters {:query-params {:x Long}} + :get {:responses {200 {:schema {:total (s/constrained Long #(>= % 10) 'gte10)}}} + :handler (fnk [[:query-params x]] + (ok {:total x}))} + :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]))) + + (context "/context" [] + (resource + {:handler (constantly (ok "CONTEXT"))})) + + ;; does not work + (ANY "/any" [] + (resource + {:handler (constantly (ok "ANY"))})) + + (context "/path/:id" [] + (resource + {:parameters {:path-params {:id s/Int}} + :handler (fn [request] + (ok (select-keys request [:path-params :route-params])))})) + + (resource + {:get {:handler (fn [request] + (ok (select-keys request [:uri :path-info])))}}))] + + (fact "normal endpoint works" + (handler {:request-method :get, :uri "/rest/no"}) => (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))) + +(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})})})})}))) diff --git a/test-suites/compojure1/test/compojure/api/routes_test.clj b/test-suites/compojure1/test/compojure/api/routes_test.clj new file mode 100644 index 00000000..b44e100b --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/routes_test.clj @@ -0,0 +1,152 @@ +(ns compojure.api.routes-test + (:require [midje.sweet :refer :all] + [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))) + more-routes (fn [version] + (routes + (GET "/more" [] + (ok {:message version})))) + routes (context "/api/:version" [] + :path-params [version :- String] + (GET "/ping" [] + (ok {:message (str "pong - " version)})) + (POST "/ping" [] + (ok {:message (str "pong - " version)})) + (middleware [mw] + (GET "/hello" [] + :return {:message String} + :summary "cool ping" + :query-params [name :- String] + (ok {:message (str "Hello, " name)})) + (more-routes version))) + app (api + (swagger-routes) + routes)] + + (fact "all routes can be invoked" + (let [[status body] (get* app "/api/v1/hello" {:name "Tommi"})] + status = 200 + body => {:message "Hello, Tommi"}) + + (let [[status body] (get* app "/api/v1/ping")] + status = 200 + body => {:message "pong - v1"}) + + (let [[status body] (get* app "/api/v2/ping")] + status = 200 + 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"]))) + +(def more-routes + (routes + (GET "/more" [] + (ok {:gary "moore"})))) + +(facts "following var-routes, #219" + (let [routes (context "/api" [] #'more-routes)] + (routes/get-routes routes) => [["/api/more" :get {}]])) + +;; TODO: should this do something different? +(facts "dynamic routes" + (let [more-routes (fn [version] + (GET (str "/" version) [] + (ok {:message version}))) + routes (context "/api/:version" [] + :path-params [version :- String] + (more-routes version)) + app (api + (swagger-routes) + routes)] + + (fact "all routes can be invoked" + (let [[status body] (get* app "/api/v3/v3")] + status => 200 + 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" + (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}) => []) + + (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)) + + (fact "throw exception" + (routes/get-routes r {:invalid-routes-fn routes/fail-on-invalid-child-routes})) => throws)) + +(fact "context routes with compojure destructuring" + (let [app (context "/api" req + (GET "/ping" [] (ok (:magic req))))] + (app {:request-method :get :uri "/api/ping" :magic {:just "works"}}) => (contains {:body {:just "works"}}))) diff --git a/test-suites/compojure1/test/compojure/api/swagger_ordering_test.clj b/test-suites/compojure1/test/compojure/api/swagger_ordering_test.clj new file mode 100644 index 00000000..55112ecf --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/swagger_ordering_test.clj @@ -0,0 +1,36 @@ +(ns compojure.api.swagger-ordering-test + (:require [midje.sweet :refer :all] + [compojure.api.sweet :refer :all] + [compojure.api.test-utils :refer :all])) + +(def more-routes + (routes + (GET "/6" [] identity) + (GET "/7" [] identity) + (GET "/8" [] identity))) + +(facts "with 10+ routes" + (let [app (api + (context "/a" [] + (GET "/1" [] identity) + (GET "/2" [] identity) + (GET "/3" [] identity) + (context "/b" [] + (GET "/4" [] identity) + (GET "/5" [] identity)) + (context "/c" [] + more-routes + (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"]))) diff --git a/test-suites/compojure1/test/compojure/api/swagger_test.clj b/test-suites/compojure1/test/compojure/api/swagger_test.clj new file mode 100644 index 00000000..e47cfd63 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/swagger_test.clj @@ -0,0 +1,187 @@ +(ns compojure.api.swagger-test + (:require [schema.core :as s] + [compojure.api.sweet :refer :all] + compojure.core + [compojure.api.test-utils :refer :all] + [midje.sweet :refer :all])) + +(defmacro optional-routes [p & body] (when p `(routes ~@body))) +(defmacro GET+ [p & body] `(GET ~(str "/xxx" p) ~@body)) + +(fact "extracting compojure paths" + + (fact "all compojure.api.core macros are interpreted" + (let [app (context "/a" [] + (routes + (context "/b" [] + (let-routes [] + (GET "/c" [] identity) + (POST "/d" [] identity) + (PUT "/e" [] identity) + (DELETE "/f" [] identity) + (OPTIONS "/g" [] identity) + (PATCH "/h" [] identity))) + (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" + (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 {}}}) + + (fact "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" + (let [app (routes + (context "/api" [] + :tags [:kiss] + (GET "/kakka" [] + identity)) + (context "/api" [] + :tags [:kiss] + (GET "/kukka" [] + identity)))] + (extract-paths app) + => {"/api/kukka" {:get {:tags #{:kiss}}} + "/api/kakka" {:get {:tags #{:kiss}}}})) + +(def r1 + (GET "/:id" [] + :path-params [id :- s/Str] + identity)) +(def r2 + (GET "/kukka/:id" [] + :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")) diff --git a/test-suites/compojure1/test/compojure/api/sweet_test.clj b/test-suites/compojure1/test/compojure/api/sweet_test.clj new file mode 100644 index 00000000..c9605bc3 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/sweet_test.clj @@ -0,0 +1,209 @@ +(ns compojure.api.sweet-test + (:require [compojure.api.sweet :refer :all] + [compojure.api.test-utils :refer :all] + [midje.sweet :refer :all] + [ring.mock.request :refer :all] + [schema.core :as s] + [ring.swagger.validator :as v])) + +(s/defschema Band {:id s/Int + :name s/Str + (s/optional-key :description) (s/maybe s/Str) + :toppings [(s/enum :cheese :olives :ham :pepperoni :habanero)]}) + +(s/defschema NewBand (dissoc Band :id)) + +(def ping-route + (GET "/ping" [] identity)) + +(def app + (api + {:swagger {:spec "/swagger.json" + :data {: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"}}}}} + ping-route + (context "/api" [] + ping-route + (GET "/bands" [] + :name :bands + :return [Band] + :summary "Gets all Bands" + :description "bands bands bands" + :operationId "getBands" + identity) + (GET "/bands/:id" [id] + :return Band + :summary "Gets a Band" + :operationId "getBand" + identity) + (POST "/bands" [] + :return Band + :body [band [NewBand]] + :summary "Adds a Band" + :operationId "addBand" + identity) + (GET "/query" [] + :query-params [qp :- Boolean] + identity) + (GET "/header" [] + :header-params [hp :- Boolean] + identity) + (POST "/form" [] + :form-params [fp :- Boolean] + identity) + (GET "/primitive" [] + :return String + identity) + (GET "/primitiveArray" [] + :return [String] + identity)))) + +(facts "api documentation" + (fact "details are generated" + + (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 + :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" + (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" + :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" + :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}}} + + (fact "spec is valid" + (v/validate spec) => nil)))) diff --git a/test-suites/compojure1/test/compojure/api/test_domain.clj b/test-suites/compojure1/test/compojure/api/test_domain.clj new file mode 100644 index 00000000..cd9eee65 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/test_domain.clj @@ -0,0 +1,17 @@ +(ns compojure.api.test-domain + (:require [schema.core :as s] + [compojure.api.sweet :refer :all] + [ring.util.http-response :refer [ok]])) + +(s/defschema Topping {:name s/Str}) +(s/defschema Pizza {:toppings (s/maybe [Topping])}) + +(s/defschema Beef {:name s/Str}) +(s/defschema Burger {:ingredients (s/maybe [Beef])}) + +(def burger-routes + (routes + (POST "/burger" [] + :return Burger + :body [burger Burger] + (ok burger)))) diff --git a/test-suites/compojure1/test/compojure/api/test_utils.clj b/test-suites/compojure1/test/compojure/api/test_utils.clj new file mode 100644 index 00000000..60d24a87 --- /dev/null +++ b/test-suites/compojure1/test/compojure/api/test_utils.clj @@ -0,0 +1,103 @@ +(ns compojure.api.test-utils + (:require [cheshire.core :as cheshire] + [clojure.string :as str] + [peridot.core :as p] + [clojure.java.io :as io] + [compojure.api.routes :as routes]) + (:import [java.io InputStream])) + +(defn read-body [body] + (if (instance? InputStream body) + (slurp body) + body)) + +(defn parse-body [body] + (let [body (read-body body) + body (if (instance? String body) + (cheshire/parse-string body true) + body)] + body)) + +(defn extract-schema-name [ref-str] + (last (str/split ref-str #"/"))) + +(defn find-definition [spec ref] + (let [schema-name (keyword (extract-schema-name ref))] + (get-in spec [:definitions schema-name]))) + +;; +;; integration tests +;; + +;; +;; common +;; + +(defn json [x] (cheshire/generate-string x)) + +(defn json-stream [x] (io/input-stream (.getBytes (json x)))) + +(defn follow-redirect [state] + (if (some-> state :response :headers (get "Location")) + (p/follow-redirect state) + state)) + +(defn raw-get* [app uri & [params headers]] + (let [{{:keys [status body headers]} :response} + (-> (p/session app) + (p/request uri + :request-method :get + :params (or params {}) + :headers (or headers {})) + follow-redirect)] + [status (read-body body) headers])) + +(defn get* [app uri & [params headers]] + (let [[status body headers] + (raw-get* app uri params headers)] + [status (parse-body body) headers])) + +(defn form-post* [app uri params] + (let [{{:keys [status body]} :response} + (-> (p/session app) + (p/request uri + :request-method :post + :params params))] + [status (parse-body body)])) + +(defn raw-post* [app uri & [data content-type headers]] + (let [{{:keys [status body]} :response} + (-> (p/session app) + (p/request uri + :request-method :post + :headers (or headers {}) + :content-type (or content-type "application/json") + :body (.getBytes data)))] + [status (read-body body)])) + +(defn post* [app uri & [data]] + (let [[status body] (raw-post* app uri data)] + [status (parse-body body)])) + +(defn headers-post* [app uri headers] + (let [[status body] (raw-post* app uri "" nil headers)] + [status (parse-body body)])) + +;; +;; get-spec +;; + +(defn extract-paths [app] + (-> app routes/get-routes routes/ring-swagger-paths :paths)) + +(defn get-spec [app] + (let [[status spec] (get* app "/swagger.json" {})] + (assert (= status 200)) + (if (:paths spec) + (update-in spec [:paths] (fn [paths] + (into + (empty paths) + (for [[k v] paths] + [(if (= k (keyword "/")) + "/" (str "/" (name k))) v])))) + spec))) diff --git a/test/compojure/api/integration_test.clj b/test/compojure/api/integration_test.clj index 29c46384..e8a022da 100644 --- a/test/compojure/api/integration_test.clj +++ b/test/compojure/api/integration_test.clj @@ -535,7 +535,107 @@ (is (= pertti body))) (is (= 1 @execution-times))))) -(deftest swagger-docs-test +(deftest ring-middleware-format-swagger-docs + (let [app (api + {:format {:formats [:json-kw :edn :UNKNOWN]}} + (swagger-routes) + (GET "/user" [] + (continue)))] + + (testing "api-listing shows produces & consumes for known types" + (is (= (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 "swagger-routes" + + (testing "with defaults" + (let [app (api (swagger-routes))] + + (testing "api-docs are mounted to /" + (let [[status body] (raw-get* app "/")] + (is (= 200 status)) + (is (str/includes? body "<title>Swagger UI</title>")))) + + (testing "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + (is (= 200 status)) + (is (= "2.0" (:swagger body))))))) + + (testing "with partial overridden values" + (let [app (api (swagger-routes {:ui "/api-docs" + :data {:info {:title "Kikka"} + :paths {"/ping" {:get {}}}}}))] + + (testing "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + (is (= 200 status)) + (is (str/includes? body "<title>Swagger UI</title>")))) + + (testing "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + (is (= 200 status)) + (is (= "2.0" (:swagger body))) + (is (= "Kikka" (-> body :info :title))) + (is (some? (-> body :paths (get (keyword "/ping")))))))))) + + (testing "swagger via api-options" + + (testing "with defaults" + (let [app (api)] + + (testing "api-docs are not mounted" + (let [[status body] (raw-get* app "/")] + (is (nil? status)))) + + (testing "spec is not mounted" + (let [[status body] (get* app "/swagger.json")] + (is (nil? status)))))) + + (testing "with spec" + (let [app (api {:swagger {:spec "/swagger.json"}})] + + (testing "api-docs are not mounted" + (let [[status body] (raw-get* app "/")] + (is (nil? status)))) + + (testing "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + (is (nil? status)) + (is (= "2.0" (:swagger body)))))))) + + (testing "with ui" + (let [app (api {:swagger {:ui "/api-docs"}})] + + (testing "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + (is-200-status status) + (is (str/includes? body "<title>Swagger UI</title>")))) + + (testing "spec is not mounted" + (let [[status body] (get* app "/swagger.json")] + (is (nil? status)))))) + + (testing "with ui and spec" + (let [app (api {:swagger {:spec "/swagger.json", :ui "/api-docs"}})] + + (testing "api-docs are mounted" + (let [[status body] (raw-get* app "/api-docs")] + (is-200-status status) + (is (str/includes? body "<title>Swagger UI</title>")))) + + (testing "spec is mounted to /swagger.json" + (let [[status body] (get* app "/swagger.json")] + (is-200-status status) + (is (= "2.0" (:swagger body)))))))) + +(deftest muuntaja-swagger-docs-test (let [app (api {:formats (m/select-formats m/default-options diff --git a/test19/compojure/api/coercion/spec_coercion_test.clj b/test19/compojure/api/coercion/spec_coercion_test.clj index 53412eac..b19c0762 100644 --- a/test19/compojure/api/coercion/spec_coercion_test.clj +++ b/test19/compojure/api/coercion/spec_coercion_test.clj @@ -393,7 +393,7 @@ :responses {:default {:description ""}}}} "/body-map" {:post {:parameters [{:description "" :in "body" - :name "" + :name "body" :required true :schema {:properties {:x {:format "int64" :type "integer"} @@ -404,7 +404,7 @@ :responses {:default {:description ""}}}} "/body-params" {:post {:parameters [{:description "" :in "body" - :name "" + :name "body" :required true :schema {:properties {:x {:format "int64" :type "integer"} @@ -415,7 +415,7 @@ :responses {:default {:description ""}}}} "/body-string" {:post {:parameters [{:description "" :in "body" - :name "" + :name "body" :required true :schema {:type "string"}}] :responses {:default {:description ""}}}} @@ -476,7 +476,7 @@ :default {:description ""}}} :post {:parameters [{:description "" :in "body" - :name "" + :name "body" :required true :schema {:properties {:x {:format "int64" :type "integer"}