Compare commits

..

10 commits

Author SHA1 Message Date
6cdf49965b Add trivial-system-loader to qlot deps
Some checks failed
test / tests (ccl-bin) (push) Has been cancelled
test / tests (sbcl-bin) (push) Has been cancelled
2024-12-28 23:44:37 +09:00
Anthony Green
90ac8ee8f5
Remove dependency on quicklisp (#5) 2024-12-28 23:41:05 +09:00
1151e4adac Update README 2024-12-25 02:47:46 +09:00
7641ec5f61 Add test for set-routes 2024-12-22 18:33:49 +09:00
234b2e50f3 Update README 2024-12-20 15:42:01 +09:00
d20dbe2649 Fix 2024-12-20 15:15:04 +09:00
09c5a126be Update README 2024-12-20 15:09:37 +09:00
b1dee071ff Refactor (#4)
* Update system info

* Update qlfile

* Organize code

* Add test

* Implement uri-mapper and package-mapper

* Change type of package from string to keyword

* Implement router
2024-12-20 15:09:24 +09:00
7ab7284e88 Use absolute path instead 2024-06-13 21:02:55 +09:00
989ba63bf1 Use fiveam instead of rove 2024-06-13 21:00:50 +09:00
17 changed files with 323 additions and 113 deletions

29
.github/workflows/test.yml vendored Normal file
View 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

View file

@ -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 projects 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 files 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 packages 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,42 +73,50 @@ 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
(:use #:cl)
(:export #:handle-not-found))
(in-package #:example/routes/not-found)
(defun handle-not-found ()
...)
;; Implement custom 404 logic here
)
```
## License
Licensed under the MIT License.
© 2024, skyizwhite.
© 2024, skyizwhite.

6
ningle-fbr-test.asd Normal file
View 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)))

View file

@ -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)))

View file

@ -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
View file

@ -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

View file

@ -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"))

View file

@ -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
View 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))))))

View file

@ -1,4 +0,0 @@
(defpackage #:ningle-fbr-tests/main
(:use #:cl
#:rove))
(in-package #:ningle-fbr-tests/main)

83
tests/router.lisp Normal file
View 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
View 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
View 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")

View 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")

View 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")

View 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))

View 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")