├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── phel-config.php ├── src ├── router.phel └── router │ ├── flatten.phel │ ├── handler.phel │ └── middleware.phel └── tests ├── performance.phel ├── router.phel └── router └── flatten.phel /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - pull_request 3 | - push 4 | 5 | name: CI 6 | 7 | jobs: 8 | tests: 9 | name: Run tests on PHP ${{ matrix.php }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | php: [ '8.2', '8.3', '8.4' ] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | coverage: none 22 | tools: composer 23 | 24 | - name: Get composer cache directory 25 | id: composer-cache 26 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 27 | 28 | - name: Cache dependencies 29 | uses: actions/cache@v4 30 | with: 31 | path: ${{ steps.composer-cache.outputs.dir }} 32 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 33 | restore-keys: ${{ runner.os }}-composer- 34 | 35 | - name: Install dependencies 36 | run: composer install --no-interaction --no-ansi --no-progress 37 | 38 | - name: Run tests to the core library 39 | run: ./vendor/bin/phel test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .phpbench/ 3 | .vscode/ 4 | data/ 5 | out/ 6 | PhelGenerated/ 7 | var/ 8 | vendor/ 9 | 10 | *.cache 11 | .php-cs-fixer.php 12 | .phel-repl-history 13 | local.phel 14 | phel-config-local.php 15 | gacela*.php 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.5.0](https://github.com/phel-lang/phel-lang/compare/v0.4.0...v0.5.0) - 2024-06-22 6 | 7 | * Support phel-lang >= 0.15 8 | 9 | ## [0.4.0](https://github.com/phel-lang/phel-lang/compare/v0.3.0...v0.4.0) - 2024-05-24 10 | 11 | * Require phel-lang ^0.14 12 | 13 | ## [0.3.0](https://github.com/phel-lang/phel-lang/compare/v0.2.0...v0.3.0) - 2024-04-17 14 | 15 | * Require phel-lang ^0.13 16 | 17 | ## [0.2.0](https://github.com/phel-lang/phel-lang/compare/v0.1.0...v0.2.0) - 2024-01-11 18 | 19 | * Require PHP >= 8.2 20 | 21 | ## [0.1.0](https://github.com/phel-lang/router/releases/tag/v0.1.0) - 2023-04-01 22 | 23 | * Initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-today Jens Haase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phel router 2 | 3 | A data driver router for [Phel](https://phel-lang.org/). 4 | 5 | * Based on [Symfony Routing](https://github.com/symfony/routing) 6 | * Inspired by [reitit](https://github.com/metosin/reitit) 7 | * Fast 8 | 9 | ## Installation 10 | 11 | ```bash 12 | composer require phel-lang/router 13 | ``` 14 | 15 | ## Route syntax 16 | 17 | Routes are defined as vectors. The first element is the path of the route. This element is followed an optional map for route data and an optional vector of child routes. The paths of a route can have path parameters. 18 | 19 | ### Examples 20 | 21 | Simple route: 22 | 23 | ```phel 24 | ["/ping"] 25 | ``` 26 | 27 | Two routes: 28 | 29 | ```phel 30 | [["/ping"] 31 | ["/pong"]] 32 | ``` 33 | 34 | Routes with data: 35 | 36 | ```phel 37 | [["/ping" {:name ::ping}] 38 | ["/pong" {:name ::pong}]] 39 | ``` 40 | 41 | Routes with path parameters: 42 | 43 | ```phel 44 | [["/users/{user-id}"] 45 | ["/api/{version}/ping]] 46 | ``` 47 | 48 | Routes with path parameter validation: 49 | 50 | ```phel 51 | ["/users/{user-id<\d+>}"] 52 | ``` 53 | 54 | Routes with catch all parameters: 55 | 56 | ```phel 57 | ["/public/{path<.*>}"] 58 | ``` 59 | 60 | Nested routes: 61 | 62 | ```phel 63 | ["/api" 64 | ["/admin" {:middleware [admin-middleware-fn]} 65 | ["" {:name ::admin}] 66 | ["/db" {name ::db}]] 67 | ["/ping" {:name ::ping}]] 68 | ``` 69 | 70 | Same routes flattened: 71 | 72 | ```phel 73 | [["/api/admin" {:middleware [admin-middleware-fn] :name ::admin}] 74 | ["/api/admin/db" {:middleware [admin-middleware-fn] :name ::db}] 75 | ["/api/ping" {:name ::ping}]] 76 | ``` 77 | 78 | ## Router 79 | 80 | Given a vector of routes a router can be create. Phel router offers two option to create a router. The first is a dynamic router that is evaluated with every new request: 81 | 82 | ```phel 83 | (ns my-app 84 | (:require phel\router :as r)) 85 | 86 | (def router 87 | (r/router 88 | ["/api" 89 | ["/ping" {:name ::ping}] 90 | ["/user/{id}" {:name ::user}]])) 91 | ``` 92 | 93 | The second router is a compiled router. This router is evaluated during compile time (macro) and is therefore very fast. The drawback is that routes can not be created dynamically during the execution of a request. 94 | 95 | ```phel 96 | (ns my-app 97 | (:require phel\router :as r)) 98 | 99 | (def router 100 | (r/compiled-router 101 | ["/api" 102 | ["/ping" {:name ::ping}] 103 | ["/user/{id}" {:name ::user}]])) 104 | ``` 105 | 106 | ### Path based routing 107 | 108 | To match a route given a path the `phel\router/match-by-path` function can be used. It takes a router and a path as arguments and returns a map or `nil`. 109 | 110 | ```phel 111 | (r/match-by-path router "/api/user/10") 112 | # Evaluates to 113 | # {:template "/api/user/{id}" 114 | # :data {:name ::user 115 | # :path "/api/user/10" 116 | # :path-params {:id "10"}}} 117 | 118 | (r/match-by-path router "/hello") # Evaluates to nil 119 | ``` 120 | 121 | 122 | ### Name based routing 123 | 124 | All routes that have `:name` route data can be matched by name using the `phel\router/match-by-name` function. It takes a router and the name of route and returns a map or `nil`. 125 | 126 | ```phel 127 | (r/match-by-name router ::ping) 128 | # Evaluates to 129 | # {:template "/api/ping" 130 | # :data {:name ::ping}} 131 | 132 | (r/match-by-name router ::foo) # Evaluates to nil 133 | ``` 134 | 135 | ### Generate path 136 | 137 | It is also possible to generate a path for a give route and it's path parameters. The function `phel\router/generate` takes a router, the name of the route and a map of router parameters. It returns either the generate path as string or throws an exception if the route can not be found ore path parameters are missing. 138 | 139 | ```phel 140 | (r/generate router ::ping {}) # Evaluates to "/api/ping" 141 | 142 | (r/generate router ::user {:id 10}) # Evaluates to "/api/user/10" 143 | 144 | (r/generate router ::user {:id 10 :foo "bar"}) # Evaluates to "/api/user/10?foo=bar" 145 | 146 | (r/generate router ::user {}) # Throws Symfony\Component\Routing\Exception\MissingMandatoryParametersException 147 | 148 | (r/generate router ::foo {}) # Throws Symfony\Component\Routing\Exception\RouteNotFoundException 149 | ``` 150 | 151 | # Handler 152 | 153 | Each route can have a handler functions that can are execute when a route matches the current path. A handler function is a function that takes as argument as `phel\http/request` and returns a `phel\http/response`. 154 | 155 | ```phel 156 | # request -> response 157 | (fn [request] 158 | (h/response-from-map {:status 200 :body "ok"})) 159 | ``` 160 | 161 | A handler can be placed either at the top level of the route data using the `:handler` keyword or under a specific method (`:get`, `:head`, `:patch`, `:delete`, `:options`, `:post`, `:put` or `:trace`). The top level handler is used if a request method based handler is not found. 162 | 163 | ```phel 164 | [["/all" {:handler handler-fn}] 165 | ["/ping" {:name ::ping 166 | :get {:handler handler-fn} 167 | :post {:handler handler-fn}}]] 168 | ``` 169 | 170 | To process a request the router must be wrapped in the `phel\router/handler` method. This method returns a function that accepts a `phel\http/request` and returns a `phel\http/response`. 171 | 172 | ```phel 173 | (ns my-app 174 | (:require phel\router :as r) 175 | (:require phel\http :as h)) 176 | 177 | (defn handler [req] 178 | (h/response-from-map {:status 200 :body "ok"})) 179 | 180 | (def app 181 | (r/handler 182 | (r/router 183 | [["/all" {:handler handler}] 184 | ["/ping" {:name ::ping 185 | :get {:handler handler} 186 | :post {:handler handler}}]]))) 187 | 188 | (app (h/request-from-map {:method "DELETE" :uri "/all"})) 189 | # Evaluates to (h/response-from-map {:status 200 :body "ok"}) 190 | 191 | (app (h/request-from-map {:method "GET" :uri "/ping"})) 192 | # Evaluates to (h/response-from-map {:status 200 :body "ok"}) 193 | 194 | (app (h/request-from-map {:method "PUT" :uri "/ping"})) 195 | # Evaluates to (h/response-from-map {:status 404 :body "Not found"}) 196 | ``` 197 | 198 | # Middleware 199 | 200 | Each router can have multiple middleware functions. A middleware function is a function that takes a handler function and a `phel\http/request` and returns a `phel\http/response`. 201 | 202 | ```phel 203 | # handler -> request -> response 204 | (fn [handler request] (handler request)) 205 | ``` 206 | 207 | A middleware can be placed either at the top level of the route data using the `:middleware` keyword or under a specific method (`:get`, `:head`, `:patch`, `:delete`, `:options`, `:post`, `:put` or `:trace`). 208 | 209 | ```phel 210 | (ns my-app 211 | (:require phel\router :as r) 212 | (:require phel\http :as h)) 213 | 214 | (defn handler [req] 215 | (h/response-from-map 216 | {:status 200 217 | :body (push (get-in req [:attributes :my-middleware]) :handler)})) 218 | 219 | (defn my-middleware [name] 220 | (fn [handler request] 221 | (handler 222 | (update-in 223 | request 224 | [:attributes :my-middleware] 225 | (fn [x] 226 | (if (nil? x) 227 | [name] 228 | (push x name))))))) 229 | 230 | (def app 231 | (r/handler 232 | (r/router 233 | ["/api" {:middleware [(my-middleware :api)]} 234 | ["/ping" {:handler handler}] 235 | ["/admin" {:middleware [(my-middleware :admin)]} 236 | ["/db" {:middleware [(my-middleware :db)] 237 | :delete {:middleware [(my-middleware :delete)] 238 | :handler handler}}]]]))) 239 | 240 | (app (h/request-from-map {:method "DELETE" :uri "/api/ping"})) 241 | # Evaluates to (h/response-from-map {:status 200 :body [:api :handler]}) 242 | 243 | (app (h/request-from-map {:method "DELETE" :uri "/api/admin/db"})) 244 | # Evaluates to (h/response-from-map {:status 200 :body [:api :admin :db :delete :handler]}) 245 | ``` 246 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phel-lang/router", 3 | "type": "library", 4 | "description": "A Phel router based on symfony routing component", 5 | "keywords": [ 6 | "phel", 7 | "lisp", 8 | "functional", 9 | "language" 10 | ], 11 | "homepage": "https://phel-lang.org/", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Jens Haase", 16 | "email": "je.haase@gmail.com" 17 | }, 18 | { 19 | "name": "Jose Maria Valera Reales", 20 | "email": "chemaclass@outlook.es", 21 | "homepage": "https://chemaclass.com" 22 | } 23 | ], 24 | "require": { 25 | "php": ">=8.2", 26 | "phel-lang/phel-lang": ">=0.17", 27 | "gacela-project/gacela": ">=1.9", 28 | "symfony/routing": ">=7.3" 29 | }, 30 | "scripts": { 31 | "build": "vendor/bin/phel build --no-cache", 32 | "format": "vendor/bin/phel format", 33 | "test": "vendor/bin/phel test" 34 | }, 35 | "config": { 36 | "platform": { 37 | "php": "8.2" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "d2e400bfcd092c91d1589e3654188bdf", 8 | "packages": [ 9 | { 10 | "name": "gacela-project/container", 11 | "version": "0.6.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/gacela-project/container.git", 15 | "reference": "3b52beec5d01ab8084ab294c5d1b69a630cbced5" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/gacela-project/container/zipball/3b52beec5d01ab8084ab294c5d1b69a630cbced5", 20 | "reference": "3b52beec5d01ab8084ab294c5d1b69a630cbced5", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=8.1", 25 | "psr/container": ">=1.1" 26 | }, 27 | "require-dev": { 28 | "friendsofphp/php-cs-fixer": "^3.59", 29 | "phpstan/phpstan": "^1.11", 30 | "phpunit/phpunit": "^10.5", 31 | "psalm/plugin-phpunit": "^0.18", 32 | "symfony/var-dumper": "^5.4", 33 | "vimeo/psalm": "^5.25" 34 | }, 35 | "type": "library", 36 | "autoload": { 37 | "psr-4": { 38 | "Gacela\\": "src/" 39 | } 40 | }, 41 | "notification-url": "https://packagist.org/downloads/", 42 | "license": [ 43 | "MIT" 44 | ], 45 | "authors": [ 46 | { 47 | "name": "Jose Maria Valera Reales", 48 | "email": "chemaclass@outlook.es", 49 | "homepage": "https://chemaclass.com" 50 | }, 51 | { 52 | "name": "Jesus Valera Reales", 53 | "email": "jesus1902@outlook.com", 54 | "homepage": "https://jesusvalerareales.com/" 55 | } 56 | ], 57 | "description": "A minimalistic container dependency resolver", 58 | "homepage": "https://gacela-project.com", 59 | "keywords": [ 60 | "container", 61 | "gacela", 62 | "php", 63 | "resolver" 64 | ], 65 | "support": { 66 | "issues": "https://github.com/gacela-project/resolver/issues", 67 | "source": "https://github.com/gacela-project/container/tree/0.6.1" 68 | }, 69 | "funding": [ 70 | { 71 | "url": "https://chemaclass.com/sponsor", 72 | "type": "custom" 73 | } 74 | ], 75 | "time": "2024-07-06T11:15:35+00:00" 76 | }, 77 | { 78 | "name": "gacela-project/gacela", 79 | "version": "1.9.1", 80 | "source": { 81 | "type": "git", 82 | "url": "https://github.com/gacela-project/gacela.git", 83 | "reference": "c2e757b73e476c5fc9a07e6b2e1f59abf1c9011f" 84 | }, 85 | "dist": { 86 | "type": "zip", 87 | "url": "https://api.github.com/repos/gacela-project/gacela/zipball/c2e757b73e476c5fc9a07e6b2e1f59abf1c9011f", 88 | "reference": "c2e757b73e476c5fc9a07e6b2e1f59abf1c9011f", 89 | "shasum": "" 90 | }, 91 | "require": { 92 | "gacela-project/container": "^0.6", 93 | "php": ">=8.1" 94 | }, 95 | "require-dev": { 96 | "ergebnis/composer-normalize": "^2.45", 97 | "friendsofphp/php-cs-fixer": "^3.56", 98 | "infection/infection": "^0.26", 99 | "phpbench/phpbench": "^1.3", 100 | "phpmetrics/phpmetrics": "^2.8", 101 | "phpstan/phpstan": "^1.12", 102 | "phpstan/phpstan-strict-rules": "^1.6", 103 | "phpunit/phpunit": "^10.5", 104 | "psalm/plugin-phpunit": "^0.19", 105 | "rector/rector": "^1.2", 106 | "symfony/console": "^6.4", 107 | "symfony/var-dumper": "^6.4", 108 | "vimeo/psalm": "^5.26" 109 | }, 110 | "suggest": { 111 | "gacela-project/gacela-env-config-reader": "Allows to read .env config files", 112 | "gacela-project/gacela-yaml-config-reader": "Allows to read yml/yaml config files", 113 | "gacela-project/phpstan-extension": "A set of phpstan rules for Gacela", 114 | "symfony/console": "Allows to use vendor/bin/gacela script" 115 | }, 116 | "bin": [ 117 | "bin/gacela" 118 | ], 119 | "type": "library", 120 | "autoload": { 121 | "psr-4": { 122 | "Gacela\\": "src/" 123 | } 124 | }, 125 | "notification-url": "https://packagist.org/downloads/", 126 | "license": [ 127 | "MIT" 128 | ], 129 | "authors": [ 130 | { 131 | "name": "Jose Maria Valera Reales", 132 | "email": "chemaclass@outlook.es", 133 | "homepage": "https://chemaclass.com" 134 | }, 135 | { 136 | "name": "Jesus Valera Reales", 137 | "email": "jesus1902@outlook.com", 138 | "homepage": "https://jesusvalerareales.com/" 139 | } 140 | ], 141 | "description": "Gacela helps you separate your project into modules", 142 | "homepage": "https://gacela-project.com", 143 | "keywords": [ 144 | "framework", 145 | "kernel", 146 | "modular", 147 | "php" 148 | ], 149 | "support": { 150 | "issues": "https://github.com/gacela-project/gacela/issues", 151 | "source": "https://github.com/gacela-project/gacela/tree/1.9.1" 152 | }, 153 | "funding": [ 154 | { 155 | "url": "https://chemaclass.com/sponsor", 156 | "type": "custom" 157 | } 158 | ], 159 | "time": "2024-12-12T19:17:34+00:00" 160 | }, 161 | { 162 | "name": "phel-lang/phel-lang", 163 | "version": "v0.17.0", 164 | "source": { 165 | "type": "git", 166 | "url": "https://github.com/phel-lang/phel-lang.git", 167 | "reference": "548e7e5cd0cfd0a76efdcbdff695c34b336b5bbc" 168 | }, 169 | "dist": { 170 | "type": "zip", 171 | "url": "https://api.github.com/repos/phel-lang/phel-lang/zipball/548e7e5cd0cfd0a76efdcbdff695c34b336b5bbc", 172 | "reference": "548e7e5cd0cfd0a76efdcbdff695c34b336b5bbc", 173 | "shasum": "" 174 | }, 175 | "require": { 176 | "gacela-project/gacela": "^1.9", 177 | "php": ">=8.2", 178 | "phpunit/php-timer": "^6.0", 179 | "symfony/console": "^7.0" 180 | }, 181 | "require-dev": { 182 | "ergebnis/composer-normalize": "^2.45", 183 | "ext-readline": "*", 184 | "friendsofphp/php-cs-fixer": "^3.65", 185 | "phpbench/phpbench": "^1.3", 186 | "phpstan/phpstan": "^2.0", 187 | "phpunit/phpunit": "^10.5", 188 | "psalm/plugin-phpunit": "^0.19", 189 | "rector/rector": "^2.0", 190 | "symfony/var-dumper": "^7.2", 191 | "vimeo/psalm": "^5.26" 192 | }, 193 | "bin": [ 194 | "bin/phel" 195 | ], 196 | "type": "library", 197 | "autoload": { 198 | "psr-4": { 199 | "Phel\\": "src/php/" 200 | } 201 | }, 202 | "notification-url": "https://packagist.org/downloads/", 203 | "license": [ 204 | "MIT" 205 | ], 206 | "authors": [ 207 | { 208 | "name": "Jens Haase", 209 | "email": "je.haase@gmail.com" 210 | }, 211 | { 212 | "name": "Jose M. Valera Reales", 213 | "email": "chemaclass@outlook.es", 214 | "homepage": "https://chemaclass.com" 215 | } 216 | ], 217 | "description": "Phel is a functional programming language that transpiles to PHP", 218 | "homepage": "https://phel-lang.org/", 219 | "keywords": [ 220 | "functional", 221 | "language", 222 | "lisp", 223 | "phel" 224 | ], 225 | "support": { 226 | "issues": "https://github.com/phel-lang/phel-lang/issues", 227 | "source": "https://github.com/phel-lang/phel-lang/tree/v0.17.0" 228 | }, 229 | "funding": [ 230 | { 231 | "url": "https://chemaclass.com/sponsor", 232 | "type": "custom" 233 | } 234 | ], 235 | "time": "2025-06-01T01:50:03+00:00" 236 | }, 237 | { 238 | "name": "phpunit/php-timer", 239 | "version": "6.0.0", 240 | "source": { 241 | "type": "git", 242 | "url": "https://github.com/sebastianbergmann/php-timer.git", 243 | "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" 244 | }, 245 | "dist": { 246 | "type": "zip", 247 | "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", 248 | "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", 249 | "shasum": "" 250 | }, 251 | "require": { 252 | "php": ">=8.1" 253 | }, 254 | "require-dev": { 255 | "phpunit/phpunit": "^10.0" 256 | }, 257 | "type": "library", 258 | "extra": { 259 | "branch-alias": { 260 | "dev-main": "6.0-dev" 261 | } 262 | }, 263 | "autoload": { 264 | "classmap": [ 265 | "src/" 266 | ] 267 | }, 268 | "notification-url": "https://packagist.org/downloads/", 269 | "license": [ 270 | "BSD-3-Clause" 271 | ], 272 | "authors": [ 273 | { 274 | "name": "Sebastian Bergmann", 275 | "email": "sebastian@phpunit.de", 276 | "role": "lead" 277 | } 278 | ], 279 | "description": "Utility class for timing", 280 | "homepage": "https://github.com/sebastianbergmann/php-timer/", 281 | "keywords": [ 282 | "timer" 283 | ], 284 | "support": { 285 | "issues": "https://github.com/sebastianbergmann/php-timer/issues", 286 | "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" 287 | }, 288 | "funding": [ 289 | { 290 | "url": "https://github.com/sebastianbergmann", 291 | "type": "github" 292 | } 293 | ], 294 | "time": "2023-02-03T06:57:52+00:00" 295 | }, 296 | { 297 | "name": "psr/container", 298 | "version": "2.0.2", 299 | "source": { 300 | "type": "git", 301 | "url": "https://github.com/php-fig/container.git", 302 | "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" 303 | }, 304 | "dist": { 305 | "type": "zip", 306 | "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", 307 | "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", 308 | "shasum": "" 309 | }, 310 | "require": { 311 | "php": ">=7.4.0" 312 | }, 313 | "type": "library", 314 | "extra": { 315 | "branch-alias": { 316 | "dev-master": "2.0.x-dev" 317 | } 318 | }, 319 | "autoload": { 320 | "psr-4": { 321 | "Psr\\Container\\": "src/" 322 | } 323 | }, 324 | "notification-url": "https://packagist.org/downloads/", 325 | "license": [ 326 | "MIT" 327 | ], 328 | "authors": [ 329 | { 330 | "name": "PHP-FIG", 331 | "homepage": "https://www.php-fig.org/" 332 | } 333 | ], 334 | "description": "Common Container Interface (PHP FIG PSR-11)", 335 | "homepage": "https://github.com/php-fig/container", 336 | "keywords": [ 337 | "PSR-11", 338 | "container", 339 | "container-interface", 340 | "container-interop", 341 | "psr" 342 | ], 343 | "support": { 344 | "issues": "https://github.com/php-fig/container/issues", 345 | "source": "https://github.com/php-fig/container/tree/2.0.2" 346 | }, 347 | "time": "2021-11-05T16:47:00+00:00" 348 | }, 349 | { 350 | "name": "symfony/console", 351 | "version": "v7.3.0", 352 | "source": { 353 | "type": "git", 354 | "url": "https://github.com/symfony/console.git", 355 | "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" 356 | }, 357 | "dist": { 358 | "type": "zip", 359 | "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", 360 | "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", 361 | "shasum": "" 362 | }, 363 | "require": { 364 | "php": ">=8.2", 365 | "symfony/deprecation-contracts": "^2.5|^3", 366 | "symfony/polyfill-mbstring": "~1.0", 367 | "symfony/service-contracts": "^2.5|^3", 368 | "symfony/string": "^7.2" 369 | }, 370 | "conflict": { 371 | "symfony/dependency-injection": "<6.4", 372 | "symfony/dotenv": "<6.4", 373 | "symfony/event-dispatcher": "<6.4", 374 | "symfony/lock": "<6.4", 375 | "symfony/process": "<6.4" 376 | }, 377 | "provide": { 378 | "psr/log-implementation": "1.0|2.0|3.0" 379 | }, 380 | "require-dev": { 381 | "psr/log": "^1|^2|^3", 382 | "symfony/config": "^6.4|^7.0", 383 | "symfony/dependency-injection": "^6.4|^7.0", 384 | "symfony/event-dispatcher": "^6.4|^7.0", 385 | "symfony/http-foundation": "^6.4|^7.0", 386 | "symfony/http-kernel": "^6.4|^7.0", 387 | "symfony/lock": "^6.4|^7.0", 388 | "symfony/messenger": "^6.4|^7.0", 389 | "symfony/process": "^6.4|^7.0", 390 | "symfony/stopwatch": "^6.4|^7.0", 391 | "symfony/var-dumper": "^6.4|^7.0" 392 | }, 393 | "type": "library", 394 | "autoload": { 395 | "psr-4": { 396 | "Symfony\\Component\\Console\\": "" 397 | }, 398 | "exclude-from-classmap": [ 399 | "/Tests/" 400 | ] 401 | }, 402 | "notification-url": "https://packagist.org/downloads/", 403 | "license": [ 404 | "MIT" 405 | ], 406 | "authors": [ 407 | { 408 | "name": "Fabien Potencier", 409 | "email": "fabien@symfony.com" 410 | }, 411 | { 412 | "name": "Symfony Community", 413 | "homepage": "https://symfony.com/contributors" 414 | } 415 | ], 416 | "description": "Eases the creation of beautiful and testable command line interfaces", 417 | "homepage": "https://symfony.com", 418 | "keywords": [ 419 | "cli", 420 | "command-line", 421 | "console", 422 | "terminal" 423 | ], 424 | "support": { 425 | "source": "https://github.com/symfony/console/tree/v7.3.0" 426 | }, 427 | "funding": [ 428 | { 429 | "url": "https://symfony.com/sponsor", 430 | "type": "custom" 431 | }, 432 | { 433 | "url": "https://github.com/fabpot", 434 | "type": "github" 435 | }, 436 | { 437 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 438 | "type": "tidelift" 439 | } 440 | ], 441 | "time": "2025-05-24T10:34:04+00:00" 442 | }, 443 | { 444 | "name": "symfony/deprecation-contracts", 445 | "version": "v3.6.0", 446 | "source": { 447 | "type": "git", 448 | "url": "https://github.com/symfony/deprecation-contracts.git", 449 | "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" 450 | }, 451 | "dist": { 452 | "type": "zip", 453 | "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", 454 | "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", 455 | "shasum": "" 456 | }, 457 | "require": { 458 | "php": ">=8.1" 459 | }, 460 | "type": "library", 461 | "extra": { 462 | "thanks": { 463 | "url": "https://github.com/symfony/contracts", 464 | "name": "symfony/contracts" 465 | }, 466 | "branch-alias": { 467 | "dev-main": "3.6-dev" 468 | } 469 | }, 470 | "autoload": { 471 | "files": [ 472 | "function.php" 473 | ] 474 | }, 475 | "notification-url": "https://packagist.org/downloads/", 476 | "license": [ 477 | "MIT" 478 | ], 479 | "authors": [ 480 | { 481 | "name": "Nicolas Grekas", 482 | "email": "p@tchwork.com" 483 | }, 484 | { 485 | "name": "Symfony Community", 486 | "homepage": "https://symfony.com/contributors" 487 | } 488 | ], 489 | "description": "A generic function and convention to trigger deprecation notices", 490 | "homepage": "https://symfony.com", 491 | "support": { 492 | "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" 493 | }, 494 | "funding": [ 495 | { 496 | "url": "https://symfony.com/sponsor", 497 | "type": "custom" 498 | }, 499 | { 500 | "url": "https://github.com/fabpot", 501 | "type": "github" 502 | }, 503 | { 504 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 505 | "type": "tidelift" 506 | } 507 | ], 508 | "time": "2024-09-25T14:21:43+00:00" 509 | }, 510 | { 511 | "name": "symfony/polyfill-ctype", 512 | "version": "v1.32.0", 513 | "source": { 514 | "type": "git", 515 | "url": "https://github.com/symfony/polyfill-ctype.git", 516 | "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" 517 | }, 518 | "dist": { 519 | "type": "zip", 520 | "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", 521 | "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", 522 | "shasum": "" 523 | }, 524 | "require": { 525 | "php": ">=7.2" 526 | }, 527 | "provide": { 528 | "ext-ctype": "*" 529 | }, 530 | "suggest": { 531 | "ext-ctype": "For best performance" 532 | }, 533 | "type": "library", 534 | "extra": { 535 | "thanks": { 536 | "url": "https://github.com/symfony/polyfill", 537 | "name": "symfony/polyfill" 538 | } 539 | }, 540 | "autoload": { 541 | "files": [ 542 | "bootstrap.php" 543 | ], 544 | "psr-4": { 545 | "Symfony\\Polyfill\\Ctype\\": "" 546 | } 547 | }, 548 | "notification-url": "https://packagist.org/downloads/", 549 | "license": [ 550 | "MIT" 551 | ], 552 | "authors": [ 553 | { 554 | "name": "Gert de Pagter", 555 | "email": "BackEndTea@gmail.com" 556 | }, 557 | { 558 | "name": "Symfony Community", 559 | "homepage": "https://symfony.com/contributors" 560 | } 561 | ], 562 | "description": "Symfony polyfill for ctype functions", 563 | "homepage": "https://symfony.com", 564 | "keywords": [ 565 | "compatibility", 566 | "ctype", 567 | "polyfill", 568 | "portable" 569 | ], 570 | "support": { 571 | "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" 572 | }, 573 | "funding": [ 574 | { 575 | "url": "https://symfony.com/sponsor", 576 | "type": "custom" 577 | }, 578 | { 579 | "url": "https://github.com/fabpot", 580 | "type": "github" 581 | }, 582 | { 583 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 584 | "type": "tidelift" 585 | } 586 | ], 587 | "time": "2024-09-09T11:45:10+00:00" 588 | }, 589 | { 590 | "name": "symfony/polyfill-intl-grapheme", 591 | "version": "v1.32.0", 592 | "source": { 593 | "type": "git", 594 | "url": "https://github.com/symfony/polyfill-intl-grapheme.git", 595 | "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" 596 | }, 597 | "dist": { 598 | "type": "zip", 599 | "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", 600 | "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", 601 | "shasum": "" 602 | }, 603 | "require": { 604 | "php": ">=7.2" 605 | }, 606 | "suggest": { 607 | "ext-intl": "For best performance" 608 | }, 609 | "type": "library", 610 | "extra": { 611 | "thanks": { 612 | "url": "https://github.com/symfony/polyfill", 613 | "name": "symfony/polyfill" 614 | } 615 | }, 616 | "autoload": { 617 | "files": [ 618 | "bootstrap.php" 619 | ], 620 | "psr-4": { 621 | "Symfony\\Polyfill\\Intl\\Grapheme\\": "" 622 | } 623 | }, 624 | "notification-url": "https://packagist.org/downloads/", 625 | "license": [ 626 | "MIT" 627 | ], 628 | "authors": [ 629 | { 630 | "name": "Nicolas Grekas", 631 | "email": "p@tchwork.com" 632 | }, 633 | { 634 | "name": "Symfony Community", 635 | "homepage": "https://symfony.com/contributors" 636 | } 637 | ], 638 | "description": "Symfony polyfill for intl's grapheme_* functions", 639 | "homepage": "https://symfony.com", 640 | "keywords": [ 641 | "compatibility", 642 | "grapheme", 643 | "intl", 644 | "polyfill", 645 | "portable", 646 | "shim" 647 | ], 648 | "support": { 649 | "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" 650 | }, 651 | "funding": [ 652 | { 653 | "url": "https://symfony.com/sponsor", 654 | "type": "custom" 655 | }, 656 | { 657 | "url": "https://github.com/fabpot", 658 | "type": "github" 659 | }, 660 | { 661 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 662 | "type": "tidelift" 663 | } 664 | ], 665 | "time": "2024-09-09T11:45:10+00:00" 666 | }, 667 | { 668 | "name": "symfony/polyfill-intl-normalizer", 669 | "version": "v1.32.0", 670 | "source": { 671 | "type": "git", 672 | "url": "https://github.com/symfony/polyfill-intl-normalizer.git", 673 | "reference": "3833d7255cc303546435cb650316bff708a1c75c" 674 | }, 675 | "dist": { 676 | "type": "zip", 677 | "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", 678 | "reference": "3833d7255cc303546435cb650316bff708a1c75c", 679 | "shasum": "" 680 | }, 681 | "require": { 682 | "php": ">=7.2" 683 | }, 684 | "suggest": { 685 | "ext-intl": "For best performance" 686 | }, 687 | "type": "library", 688 | "extra": { 689 | "thanks": { 690 | "url": "https://github.com/symfony/polyfill", 691 | "name": "symfony/polyfill" 692 | } 693 | }, 694 | "autoload": { 695 | "files": [ 696 | "bootstrap.php" 697 | ], 698 | "psr-4": { 699 | "Symfony\\Polyfill\\Intl\\Normalizer\\": "" 700 | }, 701 | "classmap": [ 702 | "Resources/stubs" 703 | ] 704 | }, 705 | "notification-url": "https://packagist.org/downloads/", 706 | "license": [ 707 | "MIT" 708 | ], 709 | "authors": [ 710 | { 711 | "name": "Nicolas Grekas", 712 | "email": "p@tchwork.com" 713 | }, 714 | { 715 | "name": "Symfony Community", 716 | "homepage": "https://symfony.com/contributors" 717 | } 718 | ], 719 | "description": "Symfony polyfill for intl's Normalizer class and related functions", 720 | "homepage": "https://symfony.com", 721 | "keywords": [ 722 | "compatibility", 723 | "intl", 724 | "normalizer", 725 | "polyfill", 726 | "portable", 727 | "shim" 728 | ], 729 | "support": { 730 | "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" 731 | }, 732 | "funding": [ 733 | { 734 | "url": "https://symfony.com/sponsor", 735 | "type": "custom" 736 | }, 737 | { 738 | "url": "https://github.com/fabpot", 739 | "type": "github" 740 | }, 741 | { 742 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 743 | "type": "tidelift" 744 | } 745 | ], 746 | "time": "2024-09-09T11:45:10+00:00" 747 | }, 748 | { 749 | "name": "symfony/polyfill-mbstring", 750 | "version": "v1.32.0", 751 | "source": { 752 | "type": "git", 753 | "url": "https://github.com/symfony/polyfill-mbstring.git", 754 | "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" 755 | }, 756 | "dist": { 757 | "type": "zip", 758 | "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", 759 | "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", 760 | "shasum": "" 761 | }, 762 | "require": { 763 | "ext-iconv": "*", 764 | "php": ">=7.2" 765 | }, 766 | "provide": { 767 | "ext-mbstring": "*" 768 | }, 769 | "suggest": { 770 | "ext-mbstring": "For best performance" 771 | }, 772 | "type": "library", 773 | "extra": { 774 | "thanks": { 775 | "url": "https://github.com/symfony/polyfill", 776 | "name": "symfony/polyfill" 777 | } 778 | }, 779 | "autoload": { 780 | "files": [ 781 | "bootstrap.php" 782 | ], 783 | "psr-4": { 784 | "Symfony\\Polyfill\\Mbstring\\": "" 785 | } 786 | }, 787 | "notification-url": "https://packagist.org/downloads/", 788 | "license": [ 789 | "MIT" 790 | ], 791 | "authors": [ 792 | { 793 | "name": "Nicolas Grekas", 794 | "email": "p@tchwork.com" 795 | }, 796 | { 797 | "name": "Symfony Community", 798 | "homepage": "https://symfony.com/contributors" 799 | } 800 | ], 801 | "description": "Symfony polyfill for the Mbstring extension", 802 | "homepage": "https://symfony.com", 803 | "keywords": [ 804 | "compatibility", 805 | "mbstring", 806 | "polyfill", 807 | "portable", 808 | "shim" 809 | ], 810 | "support": { 811 | "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" 812 | }, 813 | "funding": [ 814 | { 815 | "url": "https://symfony.com/sponsor", 816 | "type": "custom" 817 | }, 818 | { 819 | "url": "https://github.com/fabpot", 820 | "type": "github" 821 | }, 822 | { 823 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 824 | "type": "tidelift" 825 | } 826 | ], 827 | "time": "2024-12-23T08:48:59+00:00" 828 | }, 829 | { 830 | "name": "symfony/routing", 831 | "version": "v7.3.0", 832 | "source": { 833 | "type": "git", 834 | "url": "https://github.com/symfony/routing.git", 835 | "reference": "8e213820c5fea844ecea29203d2a308019007c15" 836 | }, 837 | "dist": { 838 | "type": "zip", 839 | "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", 840 | "reference": "8e213820c5fea844ecea29203d2a308019007c15", 841 | "shasum": "" 842 | }, 843 | "require": { 844 | "php": ">=8.2", 845 | "symfony/deprecation-contracts": "^2.5|^3" 846 | }, 847 | "conflict": { 848 | "symfony/config": "<6.4", 849 | "symfony/dependency-injection": "<6.4", 850 | "symfony/yaml": "<6.4" 851 | }, 852 | "require-dev": { 853 | "psr/log": "^1|^2|^3", 854 | "symfony/config": "^6.4|^7.0", 855 | "symfony/dependency-injection": "^6.4|^7.0", 856 | "symfony/expression-language": "^6.4|^7.0", 857 | "symfony/http-foundation": "^6.4|^7.0", 858 | "symfony/yaml": "^6.4|^7.0" 859 | }, 860 | "type": "library", 861 | "autoload": { 862 | "psr-4": { 863 | "Symfony\\Component\\Routing\\": "" 864 | }, 865 | "exclude-from-classmap": [ 866 | "/Tests/" 867 | ] 868 | }, 869 | "notification-url": "https://packagist.org/downloads/", 870 | "license": [ 871 | "MIT" 872 | ], 873 | "authors": [ 874 | { 875 | "name": "Fabien Potencier", 876 | "email": "fabien@symfony.com" 877 | }, 878 | { 879 | "name": "Symfony Community", 880 | "homepage": "https://symfony.com/contributors" 881 | } 882 | ], 883 | "description": "Maps an HTTP request to a set of configuration variables", 884 | "homepage": "https://symfony.com", 885 | "keywords": [ 886 | "router", 887 | "routing", 888 | "uri", 889 | "url" 890 | ], 891 | "support": { 892 | "source": "https://github.com/symfony/routing/tree/v7.3.0" 893 | }, 894 | "funding": [ 895 | { 896 | "url": "https://symfony.com/sponsor", 897 | "type": "custom" 898 | }, 899 | { 900 | "url": "https://github.com/fabpot", 901 | "type": "github" 902 | }, 903 | { 904 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 905 | "type": "tidelift" 906 | } 907 | ], 908 | "time": "2025-05-24T20:43:28+00:00" 909 | }, 910 | { 911 | "name": "symfony/service-contracts", 912 | "version": "v3.6.0", 913 | "source": { 914 | "type": "git", 915 | "url": "https://github.com/symfony/service-contracts.git", 916 | "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" 917 | }, 918 | "dist": { 919 | "type": "zip", 920 | "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", 921 | "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", 922 | "shasum": "" 923 | }, 924 | "require": { 925 | "php": ">=8.1", 926 | "psr/container": "^1.1|^2.0", 927 | "symfony/deprecation-contracts": "^2.5|^3" 928 | }, 929 | "conflict": { 930 | "ext-psr": "<1.1|>=2" 931 | }, 932 | "type": "library", 933 | "extra": { 934 | "thanks": { 935 | "url": "https://github.com/symfony/contracts", 936 | "name": "symfony/contracts" 937 | }, 938 | "branch-alias": { 939 | "dev-main": "3.6-dev" 940 | } 941 | }, 942 | "autoload": { 943 | "psr-4": { 944 | "Symfony\\Contracts\\Service\\": "" 945 | }, 946 | "exclude-from-classmap": [ 947 | "/Test/" 948 | ] 949 | }, 950 | "notification-url": "https://packagist.org/downloads/", 951 | "license": [ 952 | "MIT" 953 | ], 954 | "authors": [ 955 | { 956 | "name": "Nicolas Grekas", 957 | "email": "p@tchwork.com" 958 | }, 959 | { 960 | "name": "Symfony Community", 961 | "homepage": "https://symfony.com/contributors" 962 | } 963 | ], 964 | "description": "Generic abstractions related to writing services", 965 | "homepage": "https://symfony.com", 966 | "keywords": [ 967 | "abstractions", 968 | "contracts", 969 | "decoupling", 970 | "interfaces", 971 | "interoperability", 972 | "standards" 973 | ], 974 | "support": { 975 | "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" 976 | }, 977 | "funding": [ 978 | { 979 | "url": "https://symfony.com/sponsor", 980 | "type": "custom" 981 | }, 982 | { 983 | "url": "https://github.com/fabpot", 984 | "type": "github" 985 | }, 986 | { 987 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 988 | "type": "tidelift" 989 | } 990 | ], 991 | "time": "2025-04-25T09:37:31+00:00" 992 | }, 993 | { 994 | "name": "symfony/string", 995 | "version": "v7.3.0", 996 | "source": { 997 | "type": "git", 998 | "url": "https://github.com/symfony/string.git", 999 | "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" 1000 | }, 1001 | "dist": { 1002 | "type": "zip", 1003 | "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", 1004 | "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", 1005 | "shasum": "" 1006 | }, 1007 | "require": { 1008 | "php": ">=8.2", 1009 | "symfony/polyfill-ctype": "~1.8", 1010 | "symfony/polyfill-intl-grapheme": "~1.0", 1011 | "symfony/polyfill-intl-normalizer": "~1.0", 1012 | "symfony/polyfill-mbstring": "~1.0" 1013 | }, 1014 | "conflict": { 1015 | "symfony/translation-contracts": "<2.5" 1016 | }, 1017 | "require-dev": { 1018 | "symfony/emoji": "^7.1", 1019 | "symfony/error-handler": "^6.4|^7.0", 1020 | "symfony/http-client": "^6.4|^7.0", 1021 | "symfony/intl": "^6.4|^7.0", 1022 | "symfony/translation-contracts": "^2.5|^3.0", 1023 | "symfony/var-exporter": "^6.4|^7.0" 1024 | }, 1025 | "type": "library", 1026 | "autoload": { 1027 | "files": [ 1028 | "Resources/functions.php" 1029 | ], 1030 | "psr-4": { 1031 | "Symfony\\Component\\String\\": "" 1032 | }, 1033 | "exclude-from-classmap": [ 1034 | "/Tests/" 1035 | ] 1036 | }, 1037 | "notification-url": "https://packagist.org/downloads/", 1038 | "license": [ 1039 | "MIT" 1040 | ], 1041 | "authors": [ 1042 | { 1043 | "name": "Nicolas Grekas", 1044 | "email": "p@tchwork.com" 1045 | }, 1046 | { 1047 | "name": "Symfony Community", 1048 | "homepage": "https://symfony.com/contributors" 1049 | } 1050 | ], 1051 | "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", 1052 | "homepage": "https://symfony.com", 1053 | "keywords": [ 1054 | "grapheme", 1055 | "i18n", 1056 | "string", 1057 | "unicode", 1058 | "utf-8", 1059 | "utf8" 1060 | ], 1061 | "support": { 1062 | "source": "https://github.com/symfony/string/tree/v7.3.0" 1063 | }, 1064 | "funding": [ 1065 | { 1066 | "url": "https://symfony.com/sponsor", 1067 | "type": "custom" 1068 | }, 1069 | { 1070 | "url": "https://github.com/fabpot", 1071 | "type": "github" 1072 | }, 1073 | { 1074 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1075 | "type": "tidelift" 1076 | } 1077 | ], 1078 | "time": "2025-04-20T20:19:01+00:00" 1079 | } 1080 | ], 1081 | "packages-dev": [], 1082 | "aliases": [], 1083 | "minimum-stability": "stable", 1084 | "stability-flags": [], 1085 | "prefer-stable": false, 1086 | "prefer-lowest": false, 1087 | "platform": { 1088 | "php": ">=8.2" 1089 | }, 1090 | "platform-dev": [], 1091 | "platform-overrides": { 1092 | "php": "8.2" 1093 | }, 1094 | "plugin-api-version": "2.6.0" 1095 | } 1096 | -------------------------------------------------------------------------------- /phel-config.php: -------------------------------------------------------------------------------- 1 | setIgnoreWhenBuilding([ 9 | 'performance.phel', 10 | 'local.phel' 11 | ]) 12 | ; 13 | -------------------------------------------------------------------------------- /src/router.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router 2 | (:require phel\router\flatten) 3 | (:require phel\router\handler) 4 | (:use \Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper) 5 | (:use \Symfony\Component\Routing\Generator\UrlGenerator) 6 | (:use \Symfony\Component\Routing\Generator\CompiledUrlGenerator) 7 | (:use \Symfony\Component\Routing\Generator\Dumper\CompiledUrlGeneratorDumper) 8 | (:use \Symfony\Component\Routing\Matcher\UrlMatcher) 9 | (:use \Symfony\Component\Routing\Matcher\CompiledUrlMatcher) 10 | (:use \Symfony\Component\Routing\Route) 11 | (:use \Symfony\Component\Routing\RouteCollection) 12 | (:use \Symfony\Component\Routing\RequestContext) 13 | (:use \Symfony\Component\Routing\Exception\MethodNotAllowedException) 14 | (:use \Symfony\Component\Routing\Exception\NoConfigurationException) 15 | (:use \Symfony\Component\Routing\Exception\ResourceNotFoundException)) 16 | 17 | (definterface Router 18 | (match-by-path [this path] "Matches a route given a path. Returns nil if path doesn't match.") 19 | (match-by-name [this route-name] "Matches a route given a route name. Returns nil if route can't be found.") 20 | (generate [this route-name parameter] "Generate a url for a route")) 21 | 22 | (defn- build-route-collection [normalized-routes] 23 | (for [route :in normalized-routes 24 | :let [[path data] route 25 | symfony-route (php/new Route path (php/array) (php/array) (php-associative-array "data" data "template" path)) 26 | route-name (full-name (get data :name (str "_unnamed_route_" route)))] 27 | :reduce [coll (php/new RouteCollection)]] 28 | (php/-> coll (add route-name symfony-route)) 29 | coll)) 30 | 31 | (defstruct SymfonyRouter [route-collection] 32 | Router 33 | (match-by-path [this path] 34 | (try 35 | (let [context (php/new RequestContext) 36 | matcher (php/new UrlMatcher route-collection context) 37 | parameters (php/-> matcher (match path)) 38 | params-map (for [[k v] :pairs parameters 39 | :when (not (= k "_route")) 40 | :reduce [acc {}]] 41 | (put acc (keyword k) v)) 42 | route-name (php/aget parameters "_route") 43 | route (php/-> route-collection (get route-name))] 44 | {:template (php/-> route (getOption "template")) 45 | :data (php/-> route (getOption "data")) 46 | :path path 47 | :path-params params-map}) 48 | (catch NoConfigurationException e nil) 49 | (catch ResourceNotFoundException e nil) 50 | (catch MethodNotAllowedException e nil))) 51 | (match-by-name [this route-name] 52 | (let [route (php/-> route-collection (get (full-name route-name)))] 53 | (when route 54 | {:template (php/-> route (getOption "template")) 55 | :data (php/-> route (getOption "data"))}))) 56 | (generate [this route-name parameters] 57 | (let [context (php/new RequestContext) 58 | generator (php/new UrlGenerator route-collection context) 59 | arr (->> 60 | parameters 61 | (map-indexed (fn [k v] [(name k) v])) 62 | flatten 63 | (apply php-associative-array))] 64 | (php/-> generator (generate (full-name route-name) arr))))) 65 | 66 | (defn router 67 | "Builds a router" 68 | [raw-routes & [options]] 69 | (let [{:path path :data data} options 70 | path (or path "") 71 | data (or data {})] 72 | (SymfonyRouter 73 | (-> raw-routes (flatten/flatten-routes path data) build-route-collection)))) 74 | 75 | (defstruct CompiledSymfonyRouter [compiled-matcher-routes compiled-generator-routes indexed-routes] 76 | Router 77 | (match-by-path [this path] 78 | (try 79 | (let [context (php/new RequestContext) 80 | matcher (php/new CompiledUrlMatcher compiled-matcher-routes context) 81 | parameters (php/-> matcher (match path)) 82 | params-map (for [[k v] :pairs parameters 83 | :when (not (= k "_route")) 84 | :reduce [acc {}]] 85 | (put acc (keyword k) v)) 86 | route-name (php/aget parameters "_route") 87 | [template data] (get indexed-routes route-name)] 88 | {:template template 89 | :data data 90 | :path path 91 | :path-params params-map}) 92 | (catch NoConfigurationException e nil) 93 | (catch ResourceNotFoundException e nil) 94 | (catch MethodNotAllowedException e nil))) 95 | (match-by-name [this route-name] 96 | (let [route (get indexed-routes (full-name route-name))] 97 | (when route 98 | {:template (first route) 99 | :data (second route)}))) 100 | (generate [this route-name parameters] 101 | (let [context (php/new RequestContext) 102 | generator (php/new CompiledUrlGenerator compiled-generator-routes context) 103 | arr (->> 104 | parameters 105 | (map-indexed (fn [k v] [(name k) v])) 106 | flatten 107 | (apply php-associative-array))] 108 | (php/-> generator (generate (full-name route-name) arr))))) 109 | 110 | (defmacro compiled-router 111 | "Builds a compiled router" 112 | [raw-routes & [options]] 113 | (let [{:path path :data data} (eval options) 114 | path (or path "") 115 | data (or data {}) 116 | flattened-routes (flatten/flatten-routes (eval raw-routes) path data) 117 | indexed-routes (for [route :in flattened-routes 118 | :let [[_ data] route 119 | name (full-name (get data :name (str "_unnamed_route_" route)))] 120 | :reduce [acc {}]] 121 | (put acc name route)) 122 | route-collection (build-route-collection flattened-routes) 123 | match-dumper (php/new CompiledUrlMatcherDumper route-collection) 124 | compiled-matcher-routes (php/-> match-dumper (getCompiledRoutes)) 125 | generator-dumper (php/new CompiledUrlGeneratorDumper route-collection) 126 | compiled-generator-routes (php/-> generator-dumper (getCompiledRoutes))] 127 | `(CompiledSymfonyRouter ,compiled-matcher-routes ,compiled-generator-routes ,indexed-routes))) 128 | 129 | (defn handler 130 | "Returns a function request -> response that can be used to handle a route request." 131 | [router & [options]] 132 | (let [default-handler (get options :default-handler (handler/create-default-handler {})) 133 | middleware (get options :middleware [])] 134 | (fn [request] 135 | (let [match (match-by-path router (get-in request [:uri :path]))] 136 | (if match 137 | (let [method (-> request :method name php/strtolower keyword) 138 | [handler route-data] (or (handler/find-handler match method middleware) [default-handler {}]) 139 | request (handler/enrich-request request match route-data)] 140 | (or (handler request) (default-handler request))) 141 | (default-handler request)))))) 142 | -------------------------------------------------------------------------------- /src/router/flatten.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router\flatten) 2 | 3 | (declare walk-one) 4 | 5 | (defn- walk-many [routes prefix-path prev-data] 6 | (apply concat [] 7 | (for [r :in routes] 8 | (walk-one r prefix-path prev-data)))) 9 | 10 | (defn- walk-one [routes prefix-path prev-data] 11 | (if (vector? (first routes)) 12 | (walk-many routes prefix-path prev-data) 13 | (when (string? (first routes)) 14 | (let [[path & args] routes 15 | [maybe-arg] args 16 | [data childs] (if (or (vector? maybe-arg) (nil? maybe-arg)) 17 | [{} args] 18 | [maybe-arg (rest args)]) 19 | next-data (deep-merge prev-data data) 20 | child-routes (walk-many (keep identity childs) (str prefix-path path) next-data)] 21 | (if (empty? childs) [[(str prefix-path path) next-data]] child-routes))))) 22 | 23 | (defn flatten-routes 24 | "Flattens nested routes to a vector of tuples [path data]." 25 | [raw-routes path-prefix common-data] 26 | (walk-one raw-routes path-prefix common-data)) 27 | -------------------------------------------------------------------------------- /src/router/handler.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router\handler 2 | (:require phel\router\middleware) 3 | (:require phel\http :as h)) 4 | 5 | (defn- compile-handler [middlewares handler] 6 | (let [middleware-fn (middleware/compose-middleware middlewares)] 7 | (fn [request] (middleware-fn handler request)))) 8 | 9 | (defn find-handler 10 | "Finds the handler for given route match and request-method keyword (:get, :post, ...). 11 | If no handler can be found nil is returned." 12 | [match request-method middleware] 13 | (let [route-data (:data match) 14 | request-method-data (get route-data request-method) 15 | data (if request-method-data (deep-merge route-data request-method-data) route-data) 16 | middleware (concat middleware (get data :middleware []))] 17 | (when (:handler data) 18 | [(compile-handler middleware (:handler data)) data]))) 19 | 20 | (defn create-default-handler 21 | "Create a default handler that handles the following cases, configured via options. 22 | 23 | | key | description | 24 | | ----------------------|-------------| 25 | | `:not-found` | 404, no route matches 26 | | `:method-not-allowed` | 405, no method matches 27 | | `:not-acceptable` | 406, handler returned `nil`" 28 | [{:not-found not-found :method-not-allowed method-not-allowed :not-acceptable not-acceptable}] 29 | (let [not-found (or not-found (fn [req] (h/response-from-map {:status 404 :body "Not found"}))) 30 | method-not-allowed (or method-not-allowed (fn [req] (h/response-from-map {:status 405 :body "Method not allowed"}))) 31 | not-acceptable (or not-acceptable (fn [req] (h/response-from-map {:status 405 :body "Not acceptable"})))] 32 | (fn [request] 33 | (let [route (get-in request [:attributes :route])] 34 | (if route 35 | (let [method (-> request :method php/strtolower keyword) 36 | handler (find-handler route method nil)] 37 | (if handler 38 | (not-acceptable request) 39 | (method-not-allowed request))) 40 | (not-found request)))))) 41 | 42 | (defn enrich-request [request match route-data] 43 | (-> request 44 | (update-in [:attributes :match] match) 45 | (update-in [:attributes :route-data] route-data))) 46 | -------------------------------------------------------------------------------- /src/router/middleware.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router\middleware) 2 | 3 | (defn identity-middleware [handler request] 4 | (handler request)) 5 | 6 | (defn compose-middleware-2 [f g] 7 | (fn [handler request] 8 | (f |(g handler $) request))) 9 | 10 | (defn compose-middleware [middlewares] 11 | (reduce compose-middleware-2 identity-middleware middlewares)) 12 | -------------------------------------------------------------------------------- /tests/performance.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router-test\performance 2 | (:require phel\router :as r) 3 | (:require phel\test :refer [deftest])) 4 | 5 | (def- routes 6 | (apply 7 | concat 8 | (for [i :range [0 400]] 9 | [[(str "/abc" i) {:name (str "r" i)}] 10 | [(str "/abc{foo}/" i) {:name (str "f" i)}]]))) 11 | 12 | (def- dynamic-router (r/router routes {})) 13 | (def- compiled-router (r/compiled-router routes {})) 14 | 15 | (def- loops 10000) 16 | 17 | (deftest test-dynamic-vs-compiled-router-performance 18 | (let [s (php/microtime 1)] 19 | (for [i :range [0 loops]] 20 | (r/match-by-path dynamic-router "/abcdef/399")) 21 | (println "Dynamic router: " (* 1000 (- (php/microtime 1) s))))) 22 | 23 | (deftest test-compiled-router-performance 24 | (let [s (php/microtime 1)] 25 | (for [i :range [0 loops]] 26 | (r/match-by-path compiled-router "/abcdef/399")) 27 | (println "Compiled router: " (* 1000 (- (php/microtime 1) s))))) 28 | -------------------------------------------------------------------------------- /tests/router.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router-test\router 2 | (:require phel\http :as h) 3 | (:require phel\test :refer [deftest is]) 4 | (:require phel\router :as r :refer [match-by-path match-by-name generate]) 5 | (:use Symfony\Component\Routing\Exception\RouteNotFoundException) 6 | (:use Symfony\Component\Routing\Exception\MissingMandatoryParametersException)) 7 | 8 | (deftest test-match-by-path 9 | (is (= 10 | {:template "/ping" :data {} :path "/ping" :path-params {}} 11 | (match-by-path (r/router ["/ping"]) "/ping")) 12 | "single static route") 13 | (is (= 14 | nil 15 | (match-by-path (r/router ["/ping"]) "/pong")) 16 | "no matching static route") 17 | (is (= 18 | {:template "/pong" :data {} :path "/pong" :path-params {}} 19 | (match-by-path (r/router [["/ping"] ["/pong"]]) "/pong")) 20 | "two static route") 21 | (is (= 22 | {:template "/ping/{id<\\d+>}" :data {:name ::ping} :path "/ping/123" :path-params {:id "123"}} 23 | (match-by-path (r/router [["/ping/{id<\\d+>}" {:name ::ping}]]) "/ping/123")) 24 | "route with path parameter") 25 | (is (= 26 | nil 27 | (match-by-path (r/router [["/ping/{id<\d+>}" {:name ::ping}]]) "/ping/abc")) 28 | "no matching path requirement with path parameter")) 29 | 30 | (deftest test-match-by-path-compiled-router 31 | (is (= 32 | {:template "/ping" :data {} :path "/ping" :path-params {}} 33 | (match-by-path (r/compiled-router ["/ping"]) "/ping")) 34 | "single static route") 35 | (is (= 36 | nil 37 | (match-by-path (r/compiled-router ["/ping"]) "/pong")) 38 | "no matching static route") 39 | (is (= 40 | {:template "/pong" :data {} :path "/pong" :path-params {}} 41 | (match-by-path (r/compiled-router [["/ping"] ["/pong"]]) "/pong")) 42 | "two static route") 43 | (is (= 44 | {:template "/ping/{id<\\d+>}" :data {:name ::ping} :path "/ping/123" :path-params {:id "123"}} 45 | (match-by-path (r/compiled-router [["/ping/{id<\\d+>}" {:name ::ping}]]) "/ping/123")) 46 | "route with path parameter") 47 | (is (= 48 | nil 49 | (match-by-path (r/compiled-router [["/ping/{id<\d+>}" {:name ::ping}]]) "/ping/abc")) 50 | "no matching path requirement with path parameter")) 51 | 52 | (deftest test-match-by-name 53 | (is (= 54 | {:template "/ping" :data {:name ::ping}} 55 | (match-by-name (r/router [["/ping" {:name ::ping}]]) ::ping)) 56 | "single static route") 57 | (is (= 58 | {:template "/ping/{id}" :data {:name ::ping}} 59 | (match-by-name (r/router [["/ping/{id}" {:name ::ping}]]) ::ping)) 60 | "route with path parameter") 61 | (is (= 62 | nil 63 | (match-by-name (r/router [["/ping/{id}" {:name ::ping}]]) ::pong)) 64 | "route with name not found")) 65 | 66 | (deftest test-match-by-name-compiled-router 67 | (is (= 68 | {:template "/ping" :data {:name ::ping}} 69 | (match-by-name (r/compiled-router [["/ping" {:name ::ping}]]) ::ping)) 70 | "single static route") 71 | (is (= 72 | {:template "/ping/{id}" :data {:name ::ping}} 73 | (match-by-name (r/compiled-router [["/ping/{id}" {:name ::ping}]]) ::ping)) 74 | "route with path parameter") 75 | (is (= 76 | nil 77 | (match-by-name (r/compiled-router [["/ping/{id}" {:name ::ping}]]) ::pong)) 78 | "route with name not found")) 79 | 80 | (deftest test-generate 81 | (is (= 82 | "/ping" 83 | (generate (r/router [["/ping" {:name ::ping}]]) ::ping {})) 84 | "single static route") 85 | (is (thrown? 86 | RouteNotFoundException 87 | (generate (r/router [["/ping" {:name ::ping}]]) ::pong {})) 88 | "unknown static route") 89 | (is (= 90 | "/ping/10" 91 | (generate (r/router [["/ping/{id}" {:name ::ping}]]) ::ping {:id 10})) 92 | "generate route with path parameter") 93 | (is (thrown? 94 | MissingMandatoryParametersException 95 | (generate (r/router [["/ping/{id}" {:name ::ping}]]) ::ping {})) 96 | "generate route with missing path parameter") 97 | (is (= 98 | "/ping/10?foo=bar" 99 | (generate (r/router [["/ping/{id}" {:name ::ping}]]) ::ping {:id 10 :foo "bar"})) 100 | "generate route with additional query parameter")) 101 | 102 | (deftest test-generate-compiled-router 103 | (is (= 104 | "/ping" 105 | (generate (r/compiled-router [["/ping" {:name ::ping}]]) ::ping {})) 106 | "single static route") 107 | (is (thrown? 108 | RouteNotFoundException 109 | (generate (r/compiled-router [["/ping" {:name ::ping}]]) ::pong {})) 110 | "unknown static route") 111 | (is (= 112 | "/ping/10" 113 | (generate (r/compiled-router [["/ping/{id}" {:name ::ping}]]) ::ping {:id 10})) 114 | "generate route with path parameter") 115 | (is (thrown? 116 | MissingMandatoryParametersException 117 | (generate (r/compiled-router [["/ping/{id}" {:name ::ping}]]) ::ping {})) 118 | "generate route with missing path parameter") 119 | (is (= 120 | "/ping/10?foo=bar" 121 | (generate (r/compiled-router [["/ping/{id}" {:name ::ping}]]) ::ping {:id 10 :foo "bar"})) 122 | "generate route with additional query parameter")) 123 | 124 | (defn fnil [f x] 125 | (fn [a & args] 126 | (apply f (if (nil? a) x a) args))) 127 | 128 | (defn mv [name] 129 | (fn [handler request] 130 | (handler (update-in request [:attributes :mv] (fnil push []) name)))) 131 | 132 | (defn handler [request] 133 | (let [mv (get-in request [:attributes :mv] [])] 134 | {:status 200 :body (push mv :ok)})) 135 | 136 | (deftest handler-test 137 | (let [api-mv (mv :api) 138 | test-router (r/router 139 | ["/api" {:middleware [api-mv]} 140 | ["/all" {:handler handler}] 141 | ["/get" {:get {:handler handler}}] 142 | ["/users" {:middleware [(mv :users)] 143 | :get {:handler handler} 144 | :post {:middleware [(mv :post)] 145 | :handler handler} 146 | :handler handler}]]) 147 | app (r/handler test-router {:default-handler (fn [request] nil)})] 148 | (is (= nil (app (h/request-from-map {:uri "/favicon.ico"}))) "not found") 149 | (is (= {:status 200 :body [:api :ok]} (app (h/request-from-map {:uri "/api/all" :method "GET"}))) "catch all handler") 150 | (is (= {:status 200 :body [:api :ok]} (app (h/request-from-map {:uri "/api/get" :method "GET"}))) "get handler") 151 | (is (= nil (app (h/request-from-map {:uri "/api/get" :method :post}))) "get handler with post") 152 | (is (= {:status 200 :body [:api :users :ok]} (app (h/request-from-map {:uri "/api/users" :method "GET"}))) "expanded method handler") 153 | (is (= {:status 200 :body [:api :users :post :ok]} (app (h/request-from-map {:uri "/api/users" :method "POST"}))) "method handler with middleware") 154 | (is (= {:status 200 :body [:api :users :ok]} (app (h/request-from-map {:uri "/api/users" :method "PUT"}))) "fallback handler"))) 155 | 156 | (deftest with-top-level-middleware 157 | (let [test-router (r/router 158 | ["/api" {:middleware [(mv :api)]} 159 | ["/get" {:get {:handler handler}}]] 160 | {}) 161 | app (r/handler test-router {:middleware [(mv :top)] :default-handler (fn [request] nil)})] 162 | (is (= nil (app (h/request-from-map {:uri "/favicon.ico"}))) "not found") 163 | (is (= {:status 200 :body [:top :api :ok]} (app (h/request-from-map {:uri "/api/get" :method "GET"}))) "on match"))) 164 | -------------------------------------------------------------------------------- /tests/router/flatten.phel: -------------------------------------------------------------------------------- 1 | (ns phel\router-test\flatten 2 | (:require phel\test :refer [deftest is]) 3 | (:require phel\router\flatten :refer [flatten-routes])) 4 | 5 | (deftest flatten-routes-test 6 | (is (= 7 | [["/ping" {}]] 8 | (flatten-routes ["/ping"] "" {})) 9 | "simple route") 10 | (is (= 11 | [["/ping" {}] ["/pong" {}]] 12 | (flatten-routes [["/ping"] ["/pong"]] "" {})) 13 | "two routes") 14 | (is (= 15 | [["/ping" {:name ::ping}] ["/pong" {:name ::pong}]] 16 | (flatten-routes [["/ping" {:name ::ping}] ["/pong" {:name ::pong}]] "" {})) 17 | "route with arguments") 18 | (is (= 19 | [["/api/admin" {:middleware [::admin] :name ::admin}] 20 | ["/api/admin/db" {:middleware [::admin ::connect] :name ::db}] 21 | ["/api/ping" {:name ::ping}]] 22 | (flatten-routes 23 | ["/api" 24 | ["/admin" {:middleware [::admin]} 25 | ["" {:name ::admin}] 26 | ["/db" {:name ::db :middleware [::connect]}]] 27 | ["/ping" {:name ::ping}]] "" {})) 28 | "nested routes")) 29 | --------------------------------------------------------------------------------