diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2c2a54a --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index ee74370..0b2d284 100644 --- a/README.md +++ b/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,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. \ No newline at end of file diff --git a/ningle-fbr-test.asd b/ningle-fbr-test.asd new file mode 100644 index 0000000..47b744e --- /dev/null +++ b/ningle-fbr-test.asd @@ -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))) diff --git a/ningle-fbr-tests.asd b/ningle-fbr-tests.asd deleted file mode 100644 index 8bdb5a1..0000000 --- a/ningle-fbr-tests.asd +++ /dev/null @@ -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))) diff --git a/ningle-fbr.asd b/ningle-fbr.asd index 9b2737e..a567398 100644 --- a/ningle-fbr.asd +++ b/ningle-fbr.asd @@ -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")))) diff --git a/qlfile b/qlfile index 087ffa1..88e3eca 100644 --- a/qlfile +++ b/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 diff --git a/qlfile.lock b/qlfile.lock index 5634b14..251711d 100644 --- a/qlfile.lock +++ b/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")) diff --git a/src/main.lisp b/src/main.lisp index 0c3632a..8236432 100644 --- a/src/main.lisp +++ b/src/main.lisp @@ -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)))))) diff --git a/src/router.lisp b/src/router.lisp new file mode 100644 index 0000000..de26076 --- /dev/null +++ b/src/router.lisp @@ -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)))))) diff --git a/tests/main.lisp b/tests/main.lisp deleted file mode 100644 index 06f02eb..0000000 --- a/tests/main.lisp +++ /dev/null @@ -1,4 +0,0 @@ -(defpackage #:ningle-fbr-tests/main - (:use #:cl - #:rove)) -(in-package #:ningle-fbr-tests/main) diff --git a/tests/router.lisp b/tests/router.lisp new file mode 100644 index 0000000..03724a0 --- /dev/null +++ b/tests/router.lisp @@ -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)))))) + diff --git a/tests/routes/hello.lisp b/tests/routes/hello.lisp new file mode 100644 index 0000000..18beefa --- /dev/null +++ b/tests/routes/hello.lisp @@ -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") diff --git a/tests/routes/index.lisp b/tests/routes/index.lisp new file mode 100644 index 0000000..dab5d79 --- /dev/null +++ b/tests/routes/index.lisp @@ -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") diff --git a/tests/routes/nested/page.lisp b/tests/routes/nested/page.lisp new file mode 100644 index 0000000..afc9f62 --- /dev/null +++ b/tests/routes/nested/page.lisp @@ -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") diff --git a/tests/routes/not-found.lisp b/tests/routes/not-found.lisp new file mode 100644 index 0000000..5392fd8 --- /dev/null +++ b/tests/routes/not-found.lisp @@ -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") diff --git a/tests/routes/users/<id>.lisp b/tests/routes/users/<id>.lisp new file mode 100644 index 0000000..0eb385f --- /dev/null +++ b/tests/routes/users/<id>.lisp @@ -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)) diff --git a/tests/routes/users/index.lisp b/tests/routes/users/index.lisp new file mode 100644 index 0000000..5473c9b --- /dev/null +++ b/tests/routes/users/index.lisp @@ -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")