diff --git a/.gitignore b/.gitignore index 0ef5d3f..47fed6c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .lein-repl-history .lein-plugins/ .lein-failures +.nrepl-port diff --git a/project.clj b/project.clj index b956e2c..bee916b 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,10 @@ (defproject puppetlabs/pl-bidi "0.1.0-SNAPSHOT" :description "Puppet Labs utility functions for use with the bidi web routing library" :url "https://github.com/puppetlabs/pl-bidi" - :dependencies [[org.clojure/clojure "1.6.0"]]) + :dependencies [[org.clojure/clojure "1.6.0"] + [bidi "1.18.8-SNAPSHOT"] + [compojure "1.3.2"] + [prismatic/schema "0.2.2"]] + + :profiles {:dev {:dependencies [[spyscope "0.1.4" :exclusions [clj-time]]] + :injections [(require 'spyscope.core)]}}) diff --git a/src/pl_bidi/core.clj b/src/pl_bidi/core.clj deleted file mode 100644 index 7d52f9b..0000000 --- a/src/pl_bidi/core.clj +++ /dev/null @@ -1,6 +0,0 @@ -(ns pl-bidi.core) - -(defn foo - "I don't do a whole lot." - [x] - (println x "Hello, World!")) diff --git a/src/puppetlabs/bidi.clj b/src/puppetlabs/bidi.clj new file mode 100644 index 0000000..4c01c3d --- /dev/null +++ b/src/puppetlabs/bidi.clj @@ -0,0 +1,292 @@ +(ns puppetlabs.bidi + (:require [bidi.ring :as bidi-ring] + [bidi.bidi :as bidi] + [clojure.zip :as zip] + [compojure.core :as compojure] + [compojure.response :as compojure-response] + [ring.util.response :as ring-response] + [schema.core :as schema]) + (:import (java.util.regex Pattern))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;; Schemas + +;; NOTE: This function should be added to kitchensink soon; we +;; can remove it from here once that's in a release. +(defn zipper? + "Checks to see if the object has zip/make-node metadata on it (confirming it +to be a zipper." + [obj] + (contains? (meta obj) :zip/make-node)) + +(defn pattern? + [x] + (instance? Pattern x)) + +(def Zipper + (schema/pred zipper?)) + +(def http-methods + #{:any :get :post :put :delete :head}) + +(def RequestMethod + (schema/enum :any :get :post :put :delete :head)) + +(def RegexPathElement + [(schema/one Pattern "regex") (schema/one schema/Keyword "variable")]) + +(def PathElement + (schema/conditional + string? schema/Str + keyword? schema/Keyword + vector? RegexPathElement)) + +(def RouteInfo + {:path [PathElement] + :request-method RequestMethod}) + +(def Handler + (schema/conditional + keyword? schema/Keyword + fn? (schema/pred fn?) + map? {RequestMethod (schema/recursive #'Handler)})) + +(def RouteMetadata + {:routes [RouteInfo] + :handlers {Handler RouteInfo}}) + +(def BidiPattern + (schema/conditional + keyword? schema/Keyword + string? schema/Str + sequential? [PathElement])) + +(def BidiRouteDestination + (schema/conditional + #(nil? (schema/check Handler %)) Handler + :else [[(schema/one BidiPattern "pattern") + (schema/one (schema/recursive #'BidiRouteDestination) "destination")]])) + +(def BidiRoute + [(schema/one BidiPattern "pattern") + (schema/one BidiRouteDestination "destination")]) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;; Private + +(defmacro handler-fn* + "Helper macro, used by the compojure-like macros (GET/POST/etc.) to generate + a function that provides compojure's destructuring and rendering support." + [bindings body] + `(fn [request#] + (compojure-response/render + (compojure/let-request [~bindings request#] ~@body) + request#))) + +(defn route-with-method* + "Helper function, used by the compojure-like macros (GET/POST/etc.) to generate + a bidi route that includes a wrapped handler function." + [method pattern bindings body] + `[~pattern {~method (handler-fn* ~bindings ~body)}]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;; Private - route metadata computation + +(schema/defn ^:always-validate + update-route-info* :- RouteInfo + "Helper function, used to maintain a RouteInfo data structure that represents + the current path elements of a route as we traverse the Bidi route tree via + zipper." + [route-info :- RouteInfo + pattern :- BidiPattern] + (cond + (contains? http-methods pattern) + (assoc-in route-info [:request-method] pattern) + + (nil? (schema/check RegexPathElement pattern)) + (update-in route-info [:path] concat [pattern]) + + (sequential? pattern) + (if-let [next (first pattern)] + (update-route-info* + (update-in route-info [:path] conj next) + (rest pattern)) + route-info) + + :else + (update-in route-info [:path] conj pattern))) + +(declare breadth-route-metadata*) + +(schema/defn ^:always-validate + depth-route-metadata* :- RouteMetadata + "Helper function used to traverse branches of the Bidi route tree, depth-first." + [route-meta :- RouteMetadata + route-info :- RouteInfo + loc :- Zipper] + (let [[pattern matched] (zip/node loc)] + (cond + (map? matched) + (depth-route-metadata* + route-meta + route-info + (-> loc zip/down zip/right (zip/edit #(into [] %)) zip/up)) + + (vector? matched) + (breadth-route-metadata* + route-meta + (update-route-info* route-info pattern) + (-> loc zip/down zip/right zip/down)) + + :else + (let [route-info (update-route-info* route-info pattern)] + (-> route-meta + (update-in [:routes] conj route-info) + (assoc-in [:handlers matched] route-info)))))) + +(schema/defn ^:always-validate + breadth-route-metadata* :- RouteMetadata + "Helper function used to traverse branches of the Bidi route tree, breadth-first." + [route-meta :- RouteMetadata + route-info :- RouteInfo + loc :- Zipper] + (loop [route-meta route-meta + loc loc] + (let [routes (depth-route-metadata* route-meta route-info loc)] + (if-let [next (zip/right loc)] + (recur routes next) + routes)))) + +(schema/defn ^:always-validate + route-metadata :- RouteMetadata + "Traverses a Bidi route tree and returns route metadata, which includes a list + of RouteInfo objects (one per route), plus a mechanism to look up the + RouteInfo for a given handler." + [routes :- BidiRoute] + (let [route-info {:path [] + :request-method :any} + loc (-> [routes] zip/vector-zip zip/down)] + (breadth-route-metadata* {:routes [] + :handlers {}} route-info loc))) + +(schema/defn ^:always-validate + make-handler :- (schema/pred fn?) + "Create a Ring handler from the route definition data structure. (This code + is largely borrowed from bidi core.) Arguments: + + - route-meta: metadata about the routes; allows us to look up the route info + by handler. You can get this by calling `route-metadata`. + - routes: the Bidi route tree + - handler-fn: this fn will be called on all of the handlers found in the bidi + route tree; it is expected to return a ring handler fn for that + route. If you are using the compojure-like macros in this + namespace or have nested your ring handler functions in the bidi + tree by other means, you can just pass `identity` here, or pass + in some middleware fn to wrap around the nested ring handlers. + The handlers will have access to the `RouteInfo` of the matching + bidi route via the `:route-info` key in the request map." + [route-meta :- RouteMetadata + routes :- BidiRoute + handler-fn :- (schema/pred fn?)] + (assert routes "Cannot create a Ring handler with a nil Route(s) parameter") + (let [compiled-routes (bidi/compile-route routes)] + (fn [{:keys [uri path-info] :as req}] + (let [path (or path-info uri) + {:keys [handler route-params] :as match-context} + (apply bidi/match-route compiled-routes path (apply concat (seq req)))] + (when handler + (let [req (-> req + (update-in [:params] merge route-params) + (update-in [:route-params] merge route-params) + (assoc-in [:route-info] (get-in route-meta + [:handlers handler])))] + (bidi-ring/request + (handler-fn handler) + req + (apply dissoc match-context :handler (keys req))))))))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Public - core functions + +(schema/defn ^:always-validate + routes :- BidiRoute + "Combines multiple bidi routes into a single data structure; this is largely + just a convenience function for grouping several routes together as a single + object that can be passed around." + [& routes :- [BidiRoute]] + ["" (vec routes)]) + +(schema/defn ^:always-validate + context :- BidiRoute + [url-prefix :- BidiPattern + & routes :- [BidiRoute]] + "Combines multiple bidi routes together into a single data structure, but nests + them all under the given url-prefix. This is similar to compojure's `context` + macro, but does not accept a binding form. You can still destructure variables + by passing a bidi pattern for `url-prefix`, and the variables will be available + to all nested routes." + [url-prefix (vec routes)]) + +(schema/defn ^:always-validate + routes->handler :- (schema/pred fn?) + "Given a bidi route tree, converts into a ring request handler function. You + may pass an optional middleware function that will be wrapped around the + request handling; the middleware fn will have access to the `RouteInfo` of the + matching bidi route via the `:route-info` key in the request map." + ([routes :- BidiRoute + route-middleware-fn :- (schema/maybe (schema/pred fn?))] + (let [route-meta (route-metadata routes)] + (with-meta + (make-handler route-meta + routes + route-middleware-fn) + {:route-metadata route-meta}))) + ([routes] + (routes->handler routes identity))) + +(schema/defn ^:always-validate + context-handler :- (schema/pred fn?) + "Convenience function that effectively composes `context` and `routes->handler`." + ([url-prefix :- BidiPattern + & routes :- [BidiRoute]] + (routes->handler + (apply context url-prefix routes)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;; Public - compojure-like convenience macros + +(defmacro ANY + [pattern bindings & body] + `[~pattern (handler-fn* ~bindings ~body)]) + +(defmacro GET + [pattern bindings & body] + (route-with-method* :get pattern bindings body)) + +(defmacro HEAD + [pattern bindings & body] + (route-with-method* :head pattern bindings body)) + +(defmacro PUT + [pattern bindings & body] + (route-with-method* :put pattern bindings body)) + +(defmacro POST + [pattern bindings & body] + (route-with-method* :post pattern bindings body)) + +(defmacro DELETE + [pattern bindings & body] + (route-with-method* :delete pattern bindings body)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;; Public - pre-built routes + +(defn not-found + [body] + [[[#".*" :rest]] (fn [request] + (-> (compojure-response/render body request) + (ring-response/status 404)))]) diff --git a/test/pl_bidi/core_test.clj b/test/pl_bidi/core_test.clj deleted file mode 100644 index 9fca710..0000000 --- a/test/pl_bidi/core_test.clj +++ /dev/null @@ -1,7 +0,0 @@ -(ns pl-bidi.core-test - (:require [clojure.test :refer :all] - [pl-bidi.core :refer :all])) - -(deftest a-test - (testing "FIXME, I fail." - (is (= 1 1)))) diff --git a/test/puppetlabs/bidi_test.clj b/test/puppetlabs/bidi_test.clj new file mode 100644 index 0000000..763dd4b --- /dev/null +++ b/test/puppetlabs/bidi_test.clj @@ -0,0 +1,284 @@ +(ns puppetlabs.bidi-test + (require [clojure.test :refer :all] + [puppetlabs.bidi :as pl-bidi] + [schema.test :as schema-test] + [puppetlabs.bidi :refer :all] + [schema.core :as schema] + [clojure.zip :as zip])) + +(use-fixtures :once schema-test/validate-schemas) + +(defn replace-regexes-for-equality-check + [xs] + (loop [loc (zip/vector-zip xs)] + (if (zip/end? loc) + (zip/root loc) + (recur + (let [node (zip/node loc)] + (if (pattern? node) + (zip/edit loc #(str "REGEX: " (.pattern %))) + (zip/next loc))))))) + +(deftest handler-schema-test + (testing "handler schema" + (is (nil? (schema/check Handler :foo))) + (is (nil? (schema/check Handler (fn [] :foo)))) + (is (nil? (schema/check Handler {:get (fn [] :foo)}))) + (is (nil? (schema/check Handler {:post :foo}))))) + +(deftest pattern-schema-test + (testing "pattern schema" + (is (nil? (schema/check BidiPattern "/foo"))) + (is (nil? (schema/check BidiPattern :foo))) + (is (nil? (schema/check BidiPattern ["/foo/" :foo "/foo"]))) + (is (nil? (schema/check BidiPattern ["/foo/" [#".*" :rest]]))))) + +(deftest destination-schema-test + (testing "route destination schema" + (is (nil? (schema/check BidiRouteDestination :foo))) + (is (nil? (schema/check BidiRouteDestination (fn [] nil)))) + (is (nil? (schema/check BidiRouteDestination {:get (fn [] nil)}))) + (is (nil? (schema/check BidiRouteDestination {:get :my-handler}))) + (is (nil? (schema/check BidiRouteDestination [[["/foo/" :foo "/foo"] :foo]]))) + (is (not (nil? (schema/check BidiRouteDestination [["/foo/" :foo "/foo"] :foo])))) + (is (nil? (schema/check BidiRouteDestination [[["/foo/" :foo] + :foo-handler] + [["/bar/" :bar] + {:get :bar-handler}]]))))) + +(deftest route-schema-test + (testing "route schema" + (is (nil? (schema/check BidiRoute [:foo :foo]))) + (is (nil? (schema/check BidiRoute ["/foo" [[:foo :foo]]]))) + (is (not (nil? (schema/check BidiRoute ["/foo" [:foo :foo]])))) + (is (nil? (schema/check BidiRoute ["" [[["/foo/" :foo] + :foo-handler] + [["/bar/" :bar] + {:get :bar-handler}]]]))))) + +(deftest update-route-info-test + (let [orig-route-info {:path [] + :request-method :any}] + (testing "HTTP verb keyword causes request-method to be updated" + (doseq [verb [:get :post :put :delete :head]] + (is (= {:path [] + :request-method verb} + (update-route-info* orig-route-info verb))))) + (testing "string path elements get added to the path" + (is (= {:path ["/foo"] + :request-method :any} + (update-route-info* orig-route-info "/foo")))) + (testing "keyword path elements get added to the path" + (is (= {:path [:foo] + :request-method :any} + (update-route-info* orig-route-info :foo)))) + (testing "vector path elements get flattened and added to the path" + (is (= {:path ["/foo/" :foo] + :request-method :any} + (update-route-info* orig-route-info ["/foo/" :foo])))) + (testing "regex path element gets added to the path" + (is (= {:path ["/foo/" ["REGEX: .*" :foo]] + :request-method :any} + (-> (update-route-info* orig-route-info ["/foo/" [#".*" :foo]]) + (update-in [:path] replace-regexes-for-equality-check))))))) + +(deftest route-metadata-test + (testing "route metadata includes ordered list of routes and lookup by handler" + (let [routes ["" [[["/foo/" :foo] + :foo-handler] + [["/bar/" :bar] + [["/baz" {:get :baz-handler}] + ["/bam" {:put :bam-handler}] + ["/bap" {:any :bap-handler}]]] + ["/buzz" {:post :buzz-handler}]]] + expected-foo-meta {:path '("" "/foo/" :foo) + :request-method :any} + expected-baz-meta {:path '("" "/bar/" :bar "/baz") + :request-method :get} + expected-bam-meta {:path '("" "/bar/" :bar "/bam") + :request-method :put} + expected-bap-meta {:path '("" "/bar/" :bar "/bap") + :request-method :any} + expected-buzz-meta {:path '("" "/buzz") + :request-method :post}] + (is (= (pl-bidi/route-metadata routes) + {:routes [expected-foo-meta + expected-baz-meta + expected-bam-meta + expected-bap-meta + expected-buzz-meta] + :handlers {:foo-handler expected-foo-meta + :baz-handler expected-baz-meta + :bam-handler expected-bam-meta + :bap-handler expected-bap-meta + :buzz-handler expected-buzz-meta}}))))) + +(deftest routes-test + (is (= ["" [["/foo" :foo-handler] + [["/bar/" :bar] :bar-handler]]] + (routes ["/foo" :foo-handler] + [["/bar/" :bar] :bar-handler])))) + +(deftest context-test + (testing "simple context" + (is (= ["/foo" [["/bar" :bar-handler] + [["/baz" :baz] :baz-handler]]] + (context "/foo" + ["/bar" :bar-handler] + [["/baz" :baz] :baz-handler])))) + (testing "context with variable" + (is (= [["/foo" :foo] [["/bar" :bar-handler] + [["/baz" :baz] :baz-handler]]] + (context ["/foo" :foo] + ["/bar" :bar-handler] + [["/baz" :baz] :baz-handler]))))) + +(deftest routes->handler-test + (testing "routes are matched against a request properly, with route params" + (let [handler (routes->handler ["/foo" + [["" + [["/bar" + (fn [req] :bar)] + [["/baz/" :baz] + (fn [req] + {:endpoint :baz + :route-params (:route-params req)})]]]]])] + (is (= :bar (handler {:uri "/foo/bar"}))) + (is (= {:endpoint :baz + :route-params {:baz "howdy"}} + (handler {:uri "/foo/baz/howdy"}))))) + (testing "request-methods are honored" + (let [handler (routes->handler ["/foo" {:get (fn [req] :foo)}])] + (is (nil? (handler {:uri "/foo"}))) + (is (= :foo (handler {:uri "/foo" :request-method :get}))))) + (testing "contexts can bind route variables" + (let [handler (routes->handler + (context ["/foo/" :foo] + [["/bar/" :bar] + (fn [req] (:route-params req))]))] + (is (= {:foo "hi" + :bar "there"} + (handler {:uri "/foo/hi/bar/there"}))))) + (testing "route metadata is added to fn metadata" + (let [foo-handler (fn [req] :foo) + handler (routes->handler ["/foo" {:get foo-handler}])] + (let [route-meta (:route-metadata (meta handler))] + (is (= {:routes [{:path ["/foo"] + :request-method :get}] + :handlers {foo-handler {:path ["/foo"] + :request-method :get}}} + route-meta)))))) + +(deftest routes->handler-middleware-test + (let [handler (routes->handler + (context ["/foo/" :foo] + [["/bar/" :bar] + (fn [req] (:route-params req))]) + (fn [f] + (fn [req] + {:result (f req) + :route-info (:route-info req)})))] + (is (= {:result {:foo "hi" + :bar "there"} + :route-info {:path ["/foo/" :foo "/bar/" :bar] + :request-method :any}} + (handler {:uri "/foo/hi/bar/there"}))))) + +(deftest context-handler-test + (let [handler (context-handler ["/foo/" :foo] + [["/bar/" :bar] + (fn [req] (:route-params req))])] + (is (= {:foo "hi" + :bar "there"} + (handler {:uri "/foo/hi/bar/there"}))))) + + +(deftest compojure-macros-test + (let [routes (context ["/foo/" :foo] + (ANY ["/any/" :any] [foo any] + (str "foo: " foo " any: " any)) + (GET ["/get/" :get] [foo get] + (fn [req] {:foo foo + :get get})) + (HEAD ["/head/" :head] [foo head] + {:foo foo + :head head}) + (PUT "/put" [foo] + {:status 500 + :body foo}) + (POST ["/post/" :post] [post] + post) + (DELETE ["/delete/" :delete] [foo delete] + (atom {:foo foo + :delete delete}))) + handler (routes->handler routes)] + (is (nil? (handler {:uri "/foo/hi/get/there" :request-method :post}))) + (is (nil? (handler {:uri "/foo/hi/head/there" :request-method :get}))) + (is (nil? (handler {:uri "/foo/hi/put" :request-method :get}))) + (is (nil? (handler {:uri "/foo/hi/post/there" :request-method :get}))) + (is (nil? (handler {:uri "/foo/hi/delete/there" :request-method :get}))) + + (is (= "foo: hi any: there" (:body (handler {:uri "/foo/hi/any/there"})))) + (is (= {:foo "hi" + :get "there"} + (select-keys + (handler {:uri "/foo/hi/get/there" :request-method :get}) + [:foo :get]))) + (is (= {:foo "hi" + :head "there"} + (select-keys + (handler {:uri "/foo/hi/head/there" :request-method :head}) + [:foo :head]))) + (is (= {:status 500 + :body "hi"} + (select-keys + (handler {:uri "/foo/hi/put" :request-method :put}) + [:status :body]))) + (is (= {:status 200 + :body "there"} + (select-keys + (handler {:uri "/foo/hi/post/there" :request-method :post}) + [:status :body]))) + (is (= {:status 200 + :foo "hi" + :delete "there"} + (select-keys + (handler {:uri "/foo/hi/delete/there" :request-method :delete}) + [:status :foo :delete]))))) + +(deftest not-found-test + (testing "root not-found handler" + (let [handler (routes->handler (not-found "nobody's home, yo"))] + (is (= {:status 404 + :body "nobody's home, yo"} + (select-keys + (handler {:uri "/hi/there"}) + [:body :status]))))) + (testing "nested not-found handler" + (let [handler (routes->handler + (routes + ["/bar" [["" (fn [req] :bar)] + (not-found "nothing else under bar!")]] + (not-found "nothing else under root!")))] + (is (= :bar (handler {:uri "/bar"}))) + (is (= {:status 404 + :body "nothing else under bar!"} + (select-keys + (handler {:uri "/bar/baz"}) + [:status :body]))) + (is (= {:status 404 + :body "nothing else under root!"} + (select-keys + (handler {:uri "/yo/mang"}) + [:status :body])))))) + +(deftest regex-test + (let [handler (routes->handler + ["/foo" [[["/boo/" [#".*" :rest]] + (fn [req] (:rest (:route-params req)))]]])] + (is (= "hi/there" + (handler {:uri "/foo/boo/hi/there"}))))) + + + +