Compare commits
10 commits
31d9e48b13
...
6cdf49965b
Author | SHA1 | Date | |
---|---|---|---|
6cdf49965b | |||
|
90ac8ee8f5 | ||
1151e4adac | |||
7641ec5f61 | |||
234b2e50f3 | |||
d20dbe2649 | |||
09c5a126be | |||
b1dee071ff | |||
7ab7284e88 | |||
989ba63bf1 |
17 changed files with 323 additions and 113 deletions
29
.github/workflows/test.yml
vendored
Normal file
29
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: 'test'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
lisp:
|
||||
- sbcl-bin
|
||||
- ccl-bin
|
||||
|
||||
env:
|
||||
LISP: ${{ matrix.lisp }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: 40ants/setup-lisp@v4
|
||||
with:
|
||||
asdf-system: ningle-fbr
|
||||
- uses: 40ants/run-tests@v2
|
||||
with:
|
||||
asdf-system: ningle-fbr
|
80
README.md
80
README.md
|
@ -1,16 +1,37 @@
|
|||
# ningle-fbr (WIP)
|
||||
# ningle-fbr
|
||||
|
||||
A utility for [ningle](https://github.com/fukamachi/ningle) and [jingle](https://github.com/dnaeon/cl-jingle) to enable file-based routing.
|
||||
A file-based router for [ningle](https://github.com/fukamachi/ningle).
|
||||
|
||||
## Warning
|
||||
|
||||
This software is currently in ALPHA stage. Its APIs are subject to change.
|
||||
|
||||
Check the [release notes](https://github.com/skyizwhite/ningle-fbr/releases) for the latest updates.
|
||||
|
||||
## What is File-Based Routing?
|
||||
|
||||
File-based routing is a concept commonly used in modern web frameworks such as [Next.js](https://nextjs.org/). Instead of explicitly defining routes through configuration or code, the framework automatically sets up routes based on the file hierarchy of a specific directory (usually the "pages" or "routes" directory).
|
||||
File-based routing automatically creates URL routes based on a project’s file and directory structure. Instead of manually configuring routes in a separate routing file, each file in a designated directory (e.g., `pages` or `routes`) becomes a route. This simplifies development and maintenance since adding, removing, or renaming a route is often just a matter of modifying a file’s name or location.
|
||||
|
||||
## Usage
|
||||
|
||||
To use ningle-fbr, you need to use [package-inferred-system](https://asdf.common-lisp.dev/asdf/The-package_002dinferred_002dsystem-extension.html).
|
||||
To use ningle-fbr, set up your project using the [package-inferred-system](https://asdf.common-lisp.dev/asdf/The-package_002dinferred_002dsystem-extension.html) style.
|
||||
|
||||
`/example.asd`
|
||||
**Example directory structure**:
|
||||
```
|
||||
example.asd
|
||||
src/
|
||||
app.lisp
|
||||
routes/
|
||||
index.lisp
|
||||
hello.lisp
|
||||
users/
|
||||
index.lisp
|
||||
<id>.lisp
|
||||
nested/
|
||||
page.lisp
|
||||
```
|
||||
|
||||
**`example.asd`**:
|
||||
```lisp
|
||||
(defsystem "example"
|
||||
:class :package-inferred-system
|
||||
|
@ -18,33 +39,30 @@ To use ningle-fbr, you need to use [package-inferred-system](https://asdf.common
|
|||
:depends-on ("example/app"))
|
||||
```
|
||||
|
||||
`/src/app.lisp`
|
||||
**`/src/app.lisp`**:
|
||||
```lisp
|
||||
(defpackage #:example
|
||||
(:nicknames #:example/app)
|
||||
(:use #:cl)
|
||||
(:import-from #:ningle)
|
||||
(:import-from #:ningle-fbr
|
||||
#:assign-routes))
|
||||
(:import-from #:ningle-fbr #:set-routes))
|
||||
(in-package #:example/app)
|
||||
|
||||
(defparameter *app* (make-instance 'ningle:<app>))
|
||||
|
||||
(assign-routes *app*
|
||||
:directory "src/routes"
|
||||
:system "example")
|
||||
(set-routes *app* :system :example :target-dir-path "routes")
|
||||
```
|
||||
|
||||
### Static Routing
|
||||
|
||||
`/src/routes/index.lisp` → `/`
|
||||
Routes are determined by packages located under `:example/routes`. The package’s name corresponds directly to a URL path:
|
||||
|
||||
`/src/routes/hello.lisp` → `/hello`
|
||||
|
||||
`/src/routes/users/index.lisp` → `/users`
|
||||
|
||||
`/src/routes/nested/page.lisp` → `/nested/page`
|
||||
- `:example/routes/index` → `/`
|
||||
- `:example/routes/hello` → `/hello`
|
||||
- `:example/routes/users/index` → `/users`
|
||||
- `:example/routes/nested/page` → `/nested/page`
|
||||
|
||||
**`/src/routes/index.lisp`**:
|
||||
```lisp
|
||||
(defpackage #:example/routes/index
|
||||
(:use #:cl)
|
||||
|
@ -55,29 +73,36 @@ To use ningle-fbr, you need to use [package-inferred-system](https://asdf.common
|
|||
(in-package #:example/routes/index)
|
||||
|
||||
(defun handle-get (params)
|
||||
...)
|
||||
;; Implement GET logic here
|
||||
)
|
||||
|
||||
(defun handle-post (params)
|
||||
...)
|
||||
;; Implement POST logic here
|
||||
)
|
||||
|
||||
(defun handle-put (params)
|
||||
...)
|
||||
;; Implement PUT logic here
|
||||
)
|
||||
|
||||
(defun handle-delete (params)
|
||||
...)
|
||||
;; Implement DELETE logic here
|
||||
)
|
||||
```
|
||||
|
||||
Handlers are chosen based on the HTTP method. If `handle-get` is exported, it will be called for `GET` requests on `/`.
|
||||
|
||||
### Dynamic Routing
|
||||
|
||||
A file or directory name prefixed with '=' indicates a dynamic path.
|
||||
To define dynamic routes, use `<>` in the file name to indicate URL parameters.
|
||||
|
||||
In the example below, the parameter `id` can be obtained from the handler's params.
|
||||
For example:
|
||||
`:example/routes/user/<id>` → `/user/:id`
|
||||
|
||||
`/src/routes/user/=id.lisp` → `/user/:id`
|
||||
If a request comes in at `/user/123`, `params` will include `:id "123"`.
|
||||
|
||||
### Not Found Error
|
||||
### 404 Handling
|
||||
|
||||
`not-found.lisp` is a special file to handle 404 errors. Implement the `handle-not-found` function and export it.
|
||||
To handle 404 (Not Found) errors, create a special package named `:example/routes/not-found` and define `handle-not-found`:
|
||||
|
||||
```lisp
|
||||
(defpackage #:example/routes/not-found
|
||||
|
@ -86,7 +111,8 @@ In the example below, the parameter `id` can be obtained from the handler's para
|
|||
(in-package #:example/routes/not-found)
|
||||
|
||||
(defun handle-not-found ()
|
||||
...)
|
||||
;; Implement custom 404 logic here
|
||||
)
|
||||
```
|
||||
|
||||
## License
|
||||
|
|
6
ningle-fbr-test.asd
Normal file
6
ningle-fbr-test.asd
Normal file
|
@ -0,0 +1,6 @@
|
|||
(defsystem "ningle-fbr-test"
|
||||
:class :package-inferred-system
|
||||
:pathname "tests"
|
||||
:depends-on ("rove"
|
||||
"ningle-fbr-test/router")
|
||||
:perform (test-op (o c) (symbol-call :rove :run c :style :dot)))
|
|
@ -1,9 +0,0 @@
|
|||
(defsystem "ningle-fbr-tests"
|
||||
:author "skyizwhite <paku@skyizwhite.dev>"
|
||||
:license "MIT"
|
||||
:class :package-inferred-system
|
||||
:pathname "tests"
|
||||
:depends-on ("rove"
|
||||
"ningle-fbr-tests/main")
|
||||
:perform (test-op (o c)
|
||||
(symbol-call :rove '#:run c)))
|
|
@ -1,11 +1,12 @@
|
|||
(defsystem "ningle-fbr"
|
||||
:version "0.1.0"
|
||||
:description "Plugin for ningle to enable file-based routing"
|
||||
:description "File-based router for Ningle"
|
||||
:long-description #.(uiop:read-file-string
|
||||
(uiop:subpathname *load-pathname* "README.md"))
|
||||
:author "skyizwhite <paku@skyizwhite.dev>"
|
||||
:author "skyizwhite"
|
||||
:maintainer "skyizwhite <paku@skyizwhite.dev>"
|
||||
:license "MIT"
|
||||
:class :package-inferred-system
|
||||
:pathname "src"
|
||||
:depends-on ("ningle-fbr/main")
|
||||
:in-order-to ((test-op (test-op "ningle-fbr-tests"))))
|
||||
:in-order-to ((test-op (test-op "ningle-fbr-test"))))
|
||||
|
|
6
qlfile
6
qlfile
|
@ -1,3 +1,7 @@
|
|||
ql ningle
|
||||
ql lack
|
||||
ql cl-ppcre
|
||||
ql rove
|
||||
ql alexandria
|
||||
ql trivial-system-loader
|
||||
github rove fukamachi/rove
|
||||
github dissect shinmera/dissect
|
||||
|
|
20
qlfile.lock
20
qlfile.lock
|
@ -6,11 +6,27 @@
|
|||
(:class qlot/source/ql:source-ql
|
||||
:initargs (:%version :latest)
|
||||
:version "ql-2023-10-21"))
|
||||
("lack" .
|
||||
(:class qlot/source/ql:source-ql
|
||||
:initargs (:%version :latest)
|
||||
:version "ql-2024-10-12"))
|
||||
("cl-ppcre" .
|
||||
(:class qlot/source/ql:source-ql
|
||||
:initargs (:%version :latest)
|
||||
:version "ql-2023-10-21"))
|
||||
("rove" .
|
||||
("alexandria" .
|
||||
(:class qlot/source/ql:source-ql
|
||||
:initargs (:%version :latest)
|
||||
:version "ql-2023-10-21"))
|
||||
:version "ql-2024-10-12"))
|
||||
("trivial-system-loader" .
|
||||
(:class qlot/source/ql:source-ql
|
||||
:initargs (:%version :latest)
|
||||
:version "ql-2024-10-12"))
|
||||
("rove" .
|
||||
(:class qlot/source/github:source-github
|
||||
:initargs (:repos "fukamachi/rove" :ref nil :branch nil :tag nil)
|
||||
:version "github-cacea7331c10fe9d8398d104b2dfd579bf7ea353"))
|
||||
("dissect" .
|
||||
(:class qlot/source/github:source-github
|
||||
:initargs (:repos "shinmera/dissect" :ref nil :branch nil :tag nil)
|
||||
:version "github-a70cabcd748cf7c041196efd711e2dcca2bbbb2c"))
|
||||
|
|
|
@ -1,68 +1,6 @@
|
|||
(uiop:define-package :ningle-fbr
|
||||
(:nicknames #:ningle-fbr/main)
|
||||
(:use #:cl)
|
||||
(:import-from #:cl-ppcre)
|
||||
(:import-from #:ningle)
|
||||
(:export #:assign-routes))
|
||||
(:use #:cl
|
||||
#:ningle-fbr/router)
|
||||
(:export #:set-routes))
|
||||
(in-package :ningle-fbr)
|
||||
|
||||
(defun remove-file-type (namestr)
|
||||
(cl-ppcre:regex-replace ".lisp" namestr ""))
|
||||
|
||||
(defun remove-index (url)
|
||||
(if (string= url "/index")
|
||||
"/"
|
||||
(cl-ppcre:regex-replace "/index" url "")))
|
||||
|
||||
(defun replace-dynamic-annotation (url)
|
||||
(cl-ppcre:regex-replace "=" url ":"))
|
||||
|
||||
(defun format-url (url)
|
||||
(replace-dynamic-annotation (remove-index url)))
|
||||
|
||||
(defun pathname->url (pathname dir-namestring)
|
||||
(format-url
|
||||
(cl-ppcre:regex-replace dir-namestring
|
||||
(remove-file-type (namestring pathname))
|
||||
"")))
|
||||
|
||||
(defun pathname->package (pathname system-path-namestring system-prefix)
|
||||
(string-upcase
|
||||
(cl-ppcre:regex-replace system-path-namestring
|
||||
(remove-file-type (namestring pathname))
|
||||
system-prefix)))
|
||||
|
||||
(defun dir->pathnames (dir)
|
||||
(directory (concatenate 'string
|
||||
dir
|
||||
"/**/*.lisp")))
|
||||
|
||||
(defun dir->urls-and-packages (dir system)
|
||||
(let ((dir-namestring (namestring
|
||||
(asdf:system-relative-pathname system dir)))
|
||||
(system-path-namestring (namestring
|
||||
(asdf/component:component-relative-pathname
|
||||
(asdf/find-system:find-system system))))
|
||||
(system-prefix (concatenate 'string system "/")))
|
||||
(mapcar (lambda (pathname)
|
||||
(cons (pathname->url pathname dir-namestring)
|
||||
(pathname->package pathname system-path-namestring system-prefix)))
|
||||
(dir->pathnames dir))))
|
||||
|
||||
(defparameter *http-request-methods*
|
||||
'(:GET :POST :PUT :DELETE :HEAD :CONNECT :OPTIONS :PATCH))
|
||||
|
||||
(defun assign-routes (app &key directory system)
|
||||
(loop
|
||||
:for (url . pkg) :in (dir->urls-and-packages directory system)
|
||||
:do (ql:quickload pkg)
|
||||
(if (string= url "/not-found")
|
||||
(let ((handler (find-symbol "HANDLE-NOT-FOUND" pkg)))
|
||||
(defmethod ningle:not-found ((app ningle:app))
|
||||
(funcall handler))))
|
||||
(loop
|
||||
:for method :in *http-request-methods*
|
||||
:do (let ((handler (find-symbol (concatenate 'string "HANDLE-" (string method))
|
||||
pkg)))
|
||||
(when handler
|
||||
(setf (ningle:route app url :method method) handler))))))
|
||||
|
|
69
src/router.lisp
Normal file
69
src/router.lisp
Normal file
|
@ -0,0 +1,69 @@
|
|||
(defpackage #:ningle-fbr/router
|
||||
(:use #:cl)
|
||||
(:import-from #:alexandria
|
||||
#:make-keyword)
|
||||
(:import-from #:cl-ppcre
|
||||
#:quote-meta-chars
|
||||
#:regex-replace
|
||||
#:regex-replace-all)
|
||||
(:import-from #:ningle)
|
||||
(:import-from #:trivial-system-loader
|
||||
#:load-system)
|
||||
(:export #:pathname->path
|
||||
#:path->uri
|
||||
#:path-package
|
||||
#:set-routes))
|
||||
(in-package #:ningle-fbr/router)
|
||||
|
||||
(defun pathname->path (pathname target-dir-pathname)
|
||||
(let* ((full (namestring pathname))
|
||||
(prefix (quote-meta-chars (namestring target-dir-pathname))))
|
||||
(regex-replace (format nil "^~A(.*?).lisp$" prefix) full "/\\1")))
|
||||
|
||||
(defun detect-paths (system target-dir-path)
|
||||
(let ((target-dir-pathname
|
||||
(merge-pathnames (concatenate 'string
|
||||
target-dir-path
|
||||
"/")
|
||||
(asdf:component-pathname (asdf:find-system system)))))
|
||||
(mapcar (lambda (pathname)
|
||||
(pathname->path pathname target-dir-pathname))
|
||||
(directory (merge-pathnames "**/*.lisp" target-dir-pathname)))))
|
||||
|
||||
(defun remove-index (path)
|
||||
(if (string= path "/index")
|
||||
"/"
|
||||
(regex-replace "/index$" path "")))
|
||||
|
||||
(defun bracket->colon (path)
|
||||
(regex-replace-all "<(.*?)>" path ":\\1"))
|
||||
|
||||
(defun path->uri (path)
|
||||
(bracket->colon (remove-index path)))
|
||||
|
||||
(defun path->package (path system target-dir-path)
|
||||
(make-keyword (string-upcase (concatenate 'string
|
||||
(string system)
|
||||
"/"
|
||||
target-dir-path
|
||||
path))))
|
||||
|
||||
(defparameter *http-request-methods*
|
||||
'(:GET :POST :PUT :DELETE :HEAD :CONNECT :OPTIONS :PATCH))
|
||||
|
||||
(defmethod set-routes ((app ningle:app) &key system target-dir-path)
|
||||
(loop
|
||||
:for path :in (detect-paths system target-dir-path)
|
||||
:for uri := (path->uri path)
|
||||
:for pkg := (path->package path system target-dir-path)
|
||||
:do (load-system pkg)
|
||||
(if (string= uri "/not-found")
|
||||
(let ((handler (find-symbol "HANDLE-NOT-FOUND" pkg)))
|
||||
(defmethod ningle:not-found ((app ningle:app))
|
||||
(funcall handler))))
|
||||
(loop
|
||||
:for method :in *http-request-methods*
|
||||
:do (let ((handler (find-symbol (concatenate 'string "HANDLE-" (string method))
|
||||
pkg)))
|
||||
(when handler
|
||||
(setf (ningle:route app uri :method method) handler))))))
|
|
@ -1,4 +0,0 @@
|
|||
(defpackage #:ningle-fbr-tests/main
|
||||
(:use #:cl
|
||||
#:rove))
|
||||
(in-package #:ningle-fbr-tests/main)
|
83
tests/router.lisp
Normal file
83
tests/router.lisp
Normal file
|
@ -0,0 +1,83 @@
|
|||
(defpackage #:ningle-fbr-test/router
|
||||
(:use #:cl
|
||||
#:rove)
|
||||
(:import-from #:ningle)
|
||||
(:import-from #:lack)
|
||||
(:import-from #:lack/test
|
||||
#:testing-app
|
||||
#:request)
|
||||
(:import-from #:ningle-fbr/router
|
||||
#:path->uri
|
||||
#:path->package
|
||||
#:pathname->path
|
||||
#:set-routes))
|
||||
(in-package #:ningle-fbr-test/router)
|
||||
|
||||
(deftest uri-test
|
||||
(testing "normal path"
|
||||
(ok (string= (path->uri "/foo") "/foo"))
|
||||
(ok (string= (path->uri "/foo/bar") "/foo/bar")))
|
||||
|
||||
(testing "index path"
|
||||
(ok (string= (path->uri "/index") "/"))
|
||||
(ok (string= (path->uri "/nested/index") "/nested")))
|
||||
|
||||
(testing "dynamic path"
|
||||
(ok (string= (path->uri "/user/<id>") "/user/:id"))
|
||||
(ok (string= (path->uri "/location/<country>/<city>") "/location/:country/:city"))))
|
||||
|
||||
(deftest package-test
|
||||
(testing "normal case"
|
||||
(ok (eq (path->package "/foo" :app "routes")
|
||||
:app/routes/foo))
|
||||
(ok (eq (path->package "/foo" :app "somedir/routes")
|
||||
:app/somedir/routes/foo))))
|
||||
|
||||
(deftest router-test
|
||||
(testing "pathname->path"
|
||||
(ok (string= (pathname->path #P"/home/app/src/routes/foo.lisp"
|
||||
#P"/home/app/src/routes/")
|
||||
"/foo")))
|
||||
|
||||
(testing "set-routes"
|
||||
(testing-app (let ((app (make-instance 'ningle:app)))
|
||||
(set-routes app
|
||||
:system :ningle-fbr-test
|
||||
:target-dir-path "routes")
|
||||
(lack:builder app))
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "ok"))
|
||||
(ok (eql status 200)))
|
||||
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/hello")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "ok"))
|
||||
(ok (eql status 200)))
|
||||
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/nested/page")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "ok"))
|
||||
(ok (eql status 200)))
|
||||
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/users")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "ok"))
|
||||
(ok (eql status 200)))
|
||||
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/users/bob")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "bob"))
|
||||
(ok (eql status 200)))
|
||||
|
||||
(multiple-value-bind (body status headers)
|
||||
(request "/missing")
|
||||
(declare (ignore headers))
|
||||
(ok (string= body "Not Found"))
|
||||
(ok (eql status 404))))))
|
||||
|
8
tests/routes/hello.lisp
Normal file
8
tests/routes/hello.lisp
Normal file
|
@ -0,0 +1,8 @@
|
|||
(defpackage #:ningle-fbr-test/routes/hello
|
||||
(:use #:cl)
|
||||
(:export #:handle-get))
|
||||
(in-package #:ningle-fbr-test/routes/hello)
|
||||
|
||||
(defun handle-get (params)
|
||||
(declare (ignore params))
|
||||
"ok")
|
8
tests/routes/index.lisp
Normal file
8
tests/routes/index.lisp
Normal file
|
@ -0,0 +1,8 @@
|
|||
(defpackage #:ningle-fbr-test/routes/index
|
||||
(:use #:cl)
|
||||
(:export #:handle-get))
|
||||
(in-package #:ningle-fbr-test/routes/index)
|
||||
|
||||
(defun handle-get (params)
|
||||
(declare (ignore params))
|
||||
"ok")
|
8
tests/routes/nested/page.lisp
Normal file
8
tests/routes/nested/page.lisp
Normal file
|
@ -0,0 +1,8 @@
|
|||
(defpackage #:ningle-fbr-test/routes/nested/page
|
||||
(:use #:cl)
|
||||
(:export #:handle-get))
|
||||
(in-package #:ningle-fbr-test/routes/nested/page)
|
||||
|
||||
(defun handle-get (params)
|
||||
(declare (ignore params))
|
||||
"ok")
|
11
tests/routes/not-found.lisp
Normal file
11
tests/routes/not-found.lisp
Normal file
|
@ -0,0 +1,11 @@
|
|||
(defpackage #:ningle-fbr-test/routes/not-found
|
||||
(:use #:cl)
|
||||
(:import-from #:lack/response)
|
||||
(:import-from #:ningle)
|
||||
(:export #:handle-not-found))
|
||||
(in-package #:ningle-fbr-test/routes/not-found)
|
||||
|
||||
(defun handle-not-found ()
|
||||
(setf (lack/response:response-status ningle:*response*)
|
||||
404)
|
||||
"Not Found")
|
8
tests/routes/users/<id>.lisp
Normal file
8
tests/routes/users/<id>.lisp
Normal file
|
@ -0,0 +1,8 @@
|
|||
(defpackage #:ningle-fbr-test/routes/users/<id>
|
||||
(:use #:cl)
|
||||
(:export #:handle-get))
|
||||
(in-package #:ningle-fbr-test/routes/users/<id>)
|
||||
|
||||
(defun handle-get (params)
|
||||
(let ((id (cdr (assoc :id params))))
|
||||
id))
|
8
tests/routes/users/index.lisp
Normal file
8
tests/routes/users/index.lisp
Normal file
|
@ -0,0 +1,8 @@
|
|||
(defpackage #:ningle-fbr-test/routes/users/index
|
||||
(:use #:cl)
|
||||
(:export #:handle-get))
|
||||
(in-package #:ningle-fbr-test/routes/users/index)
|
||||
|
||||
(defun handle-get (params)
|
||||
(declare (ignore params))
|
||||
"ok")
|
Loading…
Reference in a new issue