From e1b8f0969a4bd5f1e1e390364a55ffc6aa141d65 Mon Sep 17 00:00:00 2001 From: Asko Nõmm Date: Sun, 3 Oct 2021 12:50:37 -0300 Subject: Optional path parameters, ClojureScript support and Babashka example in README --- README.md | 38 ++++++++++++++- project.clj | 2 +- src/ruuter/core.clj | 94 ------------------------------------ src/ruuter/core.cljc | 118 ++++++++++++++++++++++++++++++++++++++++++++++ test/ruuter/core_test.clj | 24 +++++----- 5 files changed, 167 insertions(+), 109 deletions(-) delete mode 100644 src/ruuter/core.clj create mode 100644 src/ruuter/core.cljc diff --git a/README.md b/README.md index e23b6dc..a6d4180 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ruuter -A tiny HTTP router that operates with a simple data structure where each route is a map inside a vector. Yup, that's it. No magic, no bullshit. +A tiny, zero dependency HTTP router for Clojure(Script) that operates with a simple data structure where each route is a map inside a vector. Yup, that's it. No magic, no bullshit. ## Installation @@ -78,6 +78,30 @@ Now, obviously on its own the router is not very useful as it needs an actual HT (jetty/run-jetty #(ruuter/route routes %) {:port 8080})) ``` +### Setting up with [Babashka](https://github.com/babashka/babashka) + +You can also use Ruuter with [Babashka](https://github.com/babashka/babashka), by using the built-in http-kit server, for example. Either add the dependency in your `bb.edn` file or if you want to make the whole thing one-file-rules-them-all, then load it in with `deps/add-deps`, like below: + +```clojure +#!/usr/bin/env bb + +(require '[org.httpkit.server :as http] + '[babashka.deps :as deps]) + +(deps/add-deps '{:deps {org.clojars.askonomm/ruuter {:mvn/version "1.1.0"}}}) + +(require '[ruuter.core :as ruuter]) + +(def routes [{:path "/" + :method :get + :response {:status 200 + :body "Hi there!"}}]) + +(http/run-server #(ruuter/route routes %) {:port 8082}) + +@(promise) +``` + ### Creating routes Like mentioned above, each route is a map inside of a vector - the order is important only in that the route matcher will return the first result it finds according to `:path`. @@ -88,7 +112,7 @@ Each route consists of three items: A string path starting with a forward slash describing the URL path to match. -To create parameters from the path, prepend a colon (:) in front of a path slice like you would with a Clojure keyword. For example a string such as `/hi/:name` would match any string that matches the `/hi/.*` regex. The `:name` itself will then be available with its value from the `request` passed to the response function, like this: +To create parameters from the path, prepend a colon (:) in front of a path slice like you would with a Clojure keyword. For example a string such as `/hi/:name` would match any string that matches the `\/hi\/.*` regex. The `:name` itself will then be available with its value from the `request` passed to the response function, like this: ```clojure (fn [req] @@ -97,6 +121,8 @@ To create parameters from the path, prepend a colon (:) in front of a path slice :body (str "Hi, " name)})) ``` +Additionally, you may want to use an optional parameter, in which case you'd want to add a question mark to the end of it, like `/hi/:name?`, which will match the `\/hi\/?.*?` regex, meaning that the previous forward slash is optional, and what comes after that is also optional. + #### `:method` The HTTP method to listen for when matching the given path. This can be whatever the HTTP server uses. For example, if you're using http-kit for the HTTP server then the accepted values are: @@ -132,6 +158,14 @@ What the actual map can contain that you return depends again on the HTTP server ## Changelog +### 1.2.0 + +- Implemented optional route parameters, so now you can do paths like `/hi/:name?` in your routes, and it would match the route even if the `:name` is not present. All you have to do is add a question mark to the parameter, and that's it. + +- Changed Ruuter from a .clj file to a .cljc file, so it would also work with ClojureScript. Although it would probably require a more hands-on set-up than just a drop-in to an HTTP server like http-kit or ring + jetty, there is no reason that the router itself wouldn't work as it does not rely on any platform-specific code. + +- Ruuter also works with [Babashka](https://github.com/babashka/babashka), and I've created a "Setting up with Babashka" section in this README to show that. + ### 1.1.0 - Made Ruuter server-agnostic, which means now it really is just a router and nothing else, and can thus be used with just about any HTTP server you can throw at it. It also means there are now zero dependencies! ZERO! \ No newline at end of file diff --git a/project.clj b/project.clj index 21498d6..7674f4e 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject org.clojars.askonomm/ruuter "1.1.0" +(defproject org.clojars.askonomm/ruuter "1.2.0" :description "A tiny HTTP router" :url "https://github.com/askonomm/ruuter" :license {:name "MIT" diff --git a/src/ruuter/core.clj b/src/ruuter/core.clj deleted file mode 100644 index f811802..0000000 --- a/src/ruuter/core.clj +++ /dev/null @@ -1,94 +0,0 @@ -(ns ruuter.core - (:require [clojure.string :as string]) - (:gen-class)) - - -(defn- path->regex-path - "Takes in a raw route `path` and turns it into a regex pattern to - match against the request URI." - [path] - (if (= "/" path) - path - (->> (string/split path #"/") - (map (fn [piece] - (if (string/starts-with? piece ":") - ".*" - piece))) - (string/join "/")))) - - -(defn- path+uri->path-params - "Takes a raw route `path` and the actual request `uri`, which it then - turns into a map of k:v, if any parameters were used in the `path`." - [path uri] - (if (= "/" path) - {} - (let [split-path (string/split path #"/") - split-uri (string/split uri #"/")] - (into {} (map-indexed - (fn [idx item] - (when (string/starts-with? item ":") - {(keyword (subs item 1)) (get split-uri idx)})) - split-path))))) - - -(defn- match-route - "For a collection of `route`, will attempt to find one that matches - the given `uri` and `request-method`. If none is matched, `nil` will - be returned instead." - [routes uri request-method] - (->> routes - (filter #(not (= :not-found (:path %)))) - (map #(merge % {:regex-path (path->regex-path (:path %))})) - (filter #(and (re-matches (re-pattern (:regex-path %)) uri) - (= (:method %) request-method))) - first)) - - -(defn- route+req->response - "Given the current route and the current HTTP request, it will - attempt to return a response, either directly if it's a map or - indirectly if it's a function. In case of a function, it will also - pass along the request map with added-in params that were parsed - from the route path. - - If the response is invalid, or does not exist, a error message with - status code 404 will be returned instead." - [{:keys [path response]} {:keys [uri] :as req}] - (cond - ; responses are maps, so there's no reason they can't be - ; direct maps. - (map? response) - response - ; responses can also be functions that return maps, and - ; when using a function, you get the whole `req` and params - ; with it as well. - (fn? response) - (response (->> {:params (path+uri->path-params path uri)} - (merge req))) - ; if by whatever reason we make it here it must mean the - ; route is invalid, or doesn't exist, in which case we return - ; an error message. - :else - {:status 404 - :body "Not found."})) - - -(defn route - "For a given collection of `routes` and the current HTTP request as - `req`, will attempt to match a route with the HTTP request, which it - will then try to return a response for. The only requirement for `req` - is to contain both a `uri` and `request-method` key. First should match - the request path (like the paths defined in routes) and the second - should match the request method used by the HTTP server you pass this fn to. - - If no route matched for a given HTTP request it will try to find a - route with `:not-found` as its `:path` instead, and return the response - for that, and if that route was also not found, will return a built-in - 404 response instead." - [routes {:keys [uri request-method] :as req}] - (if-let [route (match-route routes uri request-method)] - (route+req->response route req) - (route+req->response (->> routes - (filter #(= :not-found (:path %))) - first) req))) diff --git a/src/ruuter/core.cljc b/src/ruuter/core.cljc new file mode 100644 index 0000000..0da46b6 --- /dev/null +++ b/src/ruuter/core.cljc @@ -0,0 +1,118 @@ +(ns ruuter.core + (:require [clojure.string :as string]) + (:gen-class)) + + +(defn- path->regex-path + "Takes in a raw route `path` and turns it into a regex pattern to + match against the request URI." + [path] + (if (= "/" path) + "\\/" + (->> (string/split path #"/") + (map #(cond + ; matches anything, and must be present + ; for example `:name` + (and (string/starts-with? % ":") + (not (string/ends-with? % "?"))) + ".*" + ; matches anything, but is optional + ; for example `:name?` + (and (string/starts-with? % ":") + (string/ends-with? % "?")) + "?.*?" + :else + ; what comes around, goes around + %)) + (string/join "\\/") + (re-pattern)))) + + +(defn- path+uri->path-params + "Takes a raw route `path` and the actual request `uri`, which it then + turns into a map of k:v, if any parameters were used in the `path`." + [path uri] + (if (= "/" path) + {} + (let [split-path (string/split path #"/") + split-uri (string/split uri #"/")] + (into {} (map-indexed + (fn [idx item] + (cond + ; required parameter + (and (string/starts-with? item ":") + (not (string/ends-with? item "?"))) + {(keyword (subs item 1)) (get split-uri idx)} + ; optional parameter + (and (string/starts-with? item ":") + (string/ends-with? item "?") + (get split-uri idx)) + {(keyword (-> item + (subs 0 (- (count item) 1)) + (subs 1))) + (get split-uri idx)})) + split-path))))) + + +(defn- match-route + "For a collection of `route`, will attempt to find one that matches + the given `uri` and `request-method`. If none is matched, `nil` will + be returned instead." + [routes uri request-method] + (let [route (->> routes + (filter #(not (= :not-found (:path %)))) + (map #(merge % {:regex-path (path->regex-path (:path %))})) + (filter #(and (re-matches (:regex-path %) uri) + (= (:method %) request-method))) + first)] + (when route + (dissoc route :regex-path)))) + + +(defn- route+req->response + "Given the current route and the current HTTP request, it will + attempt to return a response, either directly if it's a map or + indirectly if it's a function. In case of a function, it will also + pass along the request map with added-in params that were parsed + from the route path. + + If the response is invalid, or does not exist, a error message with + status code 404 will be returned instead." + [{:keys [path response]} {:keys [uri] :as req}] + (cond + ; responses are maps, so there's no reason they can't be + ; direct maps. + (map? response) + response + ; responses can also be functions that return maps, and + ; when using a function, you get the whole `req` and params + ; with it as well. + (fn? response) + (response (->> {:params (path+uri->path-params path uri)} + (merge req))) + ; if by whatever reason we make it here it must mean the + ; route is invalid, or doesn't exist, in which case we return + ; an error message. + :else + {:status 404 + :body "Not found."})) + + +(defn route + "For a given collection of `routes` and the current HTTP request as + `req`, will attempt to match a route with the HTTP request, which it + will then try to return a response for. The only requirement for `req` + is to contain both a `uri` and `request-method` key. First should match + the request path (like the paths defined in routes) and the second + should match the request method used by the HTTP server you pass this fn to. + + If no route matched for a given HTTP request it will try to find a + route with `:not-found` as its `:path` instead, and return the response + for that, and if that route was also not found, will return a built-in + 404 response instead." + [routes {:keys [uri request-method] :as req}] + (if-let [route (match-route routes uri request-method)] + (route+req->response route req) + (route+req->response (->> routes + (filter #(= :not-found (:path %))) + first) req))) diff --git a/test/ruuter/core_test.clj b/test/ruuter/core_test.clj index c735f1a..4f60054 100644 --- a/test/ruuter/core_test.clj +++ b/test/ruuter/core_test.clj @@ -2,31 +2,31 @@ (:require [clojure.test :refer :all] [ruuter.core :as ruuter])) -(deftest path->regex-path-test - (let [testfn #'ruuter/path->regex-path] - (testing "Converting a path to a regex path with no params" - (is (= "/hello/world" (testfn "/hello/world")))) - (testing "Converting a path to a regex path with params" - (is (= "/hello/.*" (testfn "/hello/:who"))) - (is (= "/.*/.*/.*" (testfn "/:these/:are/:params")))))) - (deftest path+uri->path-params-test (let [testfn #'ruuter/path+uri->path-params] (testing "No params returns an empty map" - (is (= {} (testfn "/hello/world" "/hello/world")))) + (is (= {} + (testfn "/hello/world" "/hello/world")))) (testing "Having a param returns a map accordingly" - (is (= {:who "world"} (testfn "/hello/:who" "/hello/world")))) + (is (= {:who "world"} + (testfn "/hello/:who" "/hello/world")))) (testing "Multiple params returns a map accordingly" (is (= {:who "world" - :why "because"} (testfn "/hello/:who/:why" "/hello/world/because")))))) + :why "because"} + (testfn "/hello/:who/:why" "/hello/world/because")))) + (testing "Multiple params, but one is optional" + (is (= {:who "world"} + (testfn "/hello/:who/:why?" "/hello/world"))) + (is (= {:who "world" + :why "because"} + (testfn "/hello/:who/:why?" "/hello/world/because")))))) (deftest match-route-test (let [testfn #'ruuter/match-route] (testing "Find a route that exists" (is (= {:path "/hello" - :regex-path "/hello" :method :get :response {:status 200 :body "Hello."}} -- cgit v1.2.3