From 8a158dd3cff218223afb9dd77c567023795bfc7e Mon Sep 17 00:00:00 2001 From: Asko Nõmm Date: Sat, 2 Oct 2021 01:26:07 -0300 Subject: Initial commit --- .gitignore | 15 ++++++++ LICENSE.txt | 21 ++++++++++ README.md | 65 +++++++++++++++++++++++++++++++ project.clj | 10 +++++ src/ruuter/core.clj | 97 +++++++++++++++++++++++++++++++++++++++++++++++ test/ruuter/core_test.clj | 3 ++ 6 files changed, 211 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 project.clj create mode 100644 src/ruuter/core.clj create mode 100644 test/ruuter/core_test.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68e52fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +/target +/classes +/checkouts +profiles.clj +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +/.prepl-port +.hgignore +.hg/ +.idea/ +ruuter.iml \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0ec86b4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Asko Nõmm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..703e0bc --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# 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. + +## Usage + +### Setting up + +Require the namespace `ruuter.core` and then pass your routes to the `route!` function, like this: + +```clojure +(ns myapp.core + (:require [ruuter.core :as ruuter])) + +(defn -main [& opts] + (ruuter/route! [{:path "/" + :method :get + :response {:status 200 + :body "Hi there!"}}])) +``` + +This will start an HTTP server on a default port of 9600 using [http-kit](https://github.com/http-kit/http-kit) under the hood. + +The `route!` function also takes a second, optional argument, which is the [options map for http-kit](http://http-kit.github.io/http-kit/org.httpkit.server.html#var-run-server), allowing you to specify the port and so on, like this: + +```clojure +(def routes [{:path "/" + :method :get + :response {:status 200 + :body "Hi there!"}}]) + +(ruuter/route! routes {:port 8080}) +``` + +### 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`. + +Each route consists of three items: + +#### `:path` + +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: + +```clojure +(fn [req] + (let [name (:name (:params req))] + {:status 200 + :body (str "Hi, " name)})) +``` + +#### `:method` + +The HTTP method to listen for when matching the given path. + +Accepted values are: + +- `:get` +- `:post` + +#### `:response` + +The response can be a direct map, or a function returning a map. In case of a function, you will also get passed to you the `request` object. For better information on what are all the things you could do with a response, check out [the http-kit documentation](https://http-kit.github.io/server.html). diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..36356c3 --- /dev/null +++ b/project.clj @@ -0,0 +1,10 @@ +(defproject ruuter "1.0.0" + :description "A tiny HTTP router" + :url "https://github.com/askonomm/ruuter" + :license {:name "MIT" + :url "https://raw.githubusercontent.com/askonomm/ruuter/master/LICENSE.txt"} + :dependencies [[org.clojure/clojure "1.10.1"] + [http-kit "2.5.3"]] + :main ruuter.core + :min-lein-version "2.0.0" + :repl-options {:init-ns ruuter.core}) diff --git a/src/ruuter/core.clj b/src/ruuter/core.clj new file mode 100644 index 0000000..2504b6f --- /dev/null +++ b/src/ruuter/core.clj @@ -0,0 +1,97 @@ +(ns ruuter.core + (:require [clojure.string :as string] + [org.httpkit.server :as http])) + + +(def routes [{:path "/" + :method :get + :response {:status 200 + :body "Hello, World."}} + {:path "/some/page/goes/here" + :method :get + :response {:status 200 + :body ":)"}} + {:path "/hi/:name" + :method :get + :response (fn [req] + {:status 200 + :body (str "Hi, " (:name (:params req)))})} + {:path :not-found + :response {:status 404 + :body "Not found."}}]) + + +(defn- path->regex-path + [path] + (if (= "/" path) + path + (->> (string/split path #"/") + (map (fn [piece] + (if (string/starts-with? piece ":") + ".*" + piece))) + (string/join "/")))) + + +(defn- path+uri->path-params + [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 + [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 + [{: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- router + [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))) + + +(defn route! + ([routes] + (route! routes {:port 9600})) + ([routes opts] + (http/run-server #(router routes %) opts))) + + +(defn -main [& opts] + (route! routes)) \ No newline at end of file diff --git a/test/ruuter/core_test.clj b/test/ruuter/core_test.clj new file mode 100644 index 0000000..f98d41c --- /dev/null +++ b/test/ruuter/core_test.clj @@ -0,0 +1,3 @@ +(ns ruuter.core-test + (:require [clojure.test :refer :all] + [ruuter.core :refer :all])) \ No newline at end of file -- cgit v1.2.3