├── .github └── workflows │ ├── analyze.yml │ ├── pub_publish.yml │ └── test.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── pubspec.yaml └── utopia_http_example.dart ├── lib ├── src │ ├── app_mode.dart │ ├── http.dart │ ├── isolate_entry_point.dart │ ├── isolate_message.dart │ ├── isolate_supervisor.dart │ ├── request.dart │ ├── response.dart │ ├── route.dart │ ├── router.dart │ ├── server.dart │ ├── servers │ │ └── shelf.dart │ └── validation_exception.dart └── utopia_http.dart ├── pubspec.yaml └── test ├── e2e ├── http_test.dart ├── public │ └── index.html └── server.dart └── unit ├── http_test.dart ├── route_test.dart └── router_test.dart /.github/workflows/analyze.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | analyze: 14 | runs-on: ubuntu-latest 15 | container: 16 | image: dart:latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Install dependencies 20 | run: dart pub get 21 | - name: analyze 22 | run: dart analyze 23 | -------------------------------------------------------------------------------- /.github/workflows/pub_publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+*' 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | id-token: write 12 | uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 13 | with: 14 | environment: pub.dev 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | container: 16 | image: dart:latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Install dependencies 20 | run: dart pub get 21 | - name: Test 22 | run: dart test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # Avoid committing generated Javascript files: 15 | *.dart.js 16 | *.info.json # Produced by the --dump-info flag. 17 | *.js # When generated by dart2js. Don't specify *.js if your 18 | # project includes source files written in JavaScript. 19 | *.js_ 20 | *.js.deps 21 | *.js.map 22 | 23 | **/*test.http 24 | coverage 25 | pubspec_overrides.yaml 26 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full:2022-06-20-19-54-55 2 | 3 | RUN sudo apt-get update \ 4 | && sudo apt-get install apt-transport-https \ 5 | && wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/dart.gpg \ 6 | && echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list 7 | 8 | RUN sudo apt-get update \ 9 | && sudo apt-get install dart 10 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | tasks: 4 | - init: dart pub get 5 | 6 | vscode: 7 | extensions: 8 | - dart-code.dart-code -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 2 | 3 | - Improve isolates handling 4 | - Update doc 5 | - Fix repository URL 6 | 7 | ## 0.1.0 8 | 9 | - Multi threaded HTTP server 10 | - Easy to use 11 | - Customizable 12 | - Inbuilt dependency injection 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 utopia-dart 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 | # Utopia HTTP Server 2 | 3 | Light and Fast Dart HTTP library to build awesome Dart server side applications. Inspired from [Utopia PHP ecosystem](https://github.com/utopia-php). 4 | 5 | ## Getting Started 6 | 7 | First add the dependency in your pubspec.yaml 8 | 9 | ```yaml 10 | dependencies: 11 | utopia_http: ^0.1.0 12 | ``` 13 | 14 | Now, in main.dart, you can 15 | 16 | ```dart 17 | import 'dart:io'; 18 | import 'package:utopia_http/utopia_http.dart'; 19 | 20 | void main() async { 21 | final address = InternetAddress.anyIPv4; 22 | final port = Http.getEnv('PORT', 8000); 23 | final app = Http(ShelfServer(address, port), threads: 8); 24 | 25 | app.get('/').inject('request').inject('response').action( 26 | (Request request, Response response) { 27 | response.text('Hello world'); 28 | return response; 29 | }, 30 | ); 31 | 32 | await app.start(); 33 | } 34 | 35 | ``` 36 | 37 | ## Features 38 | 39 | ### Parameters 40 | 41 | Parameters are used to receive input into endpoint action from the HTTP request. Parameters could be defined as URL parameters or in a body with a structure such as JSON. 42 | 43 | Every parameter must have a validator defined. Validators are simple classes that verify the input and ensure the security of inputs. You can define your own validators or use some of built-in validators. 44 | 45 | Define an endpoint with params: 46 | 47 | ```dart 48 | app 49 | .get('/hello-world') 50 | .param('name', 'World', Text(255), 'Name to greet. Optional', true) 51 | .inject('response').action((String name, Response response) { 52 | response.text('Hello $name'); 53 | return response; 54 | }); 55 | ``` 56 | 57 | ```bash 58 | curl http://localhost:8000/hello-world 59 | curl http://localhost:8000/hello-world?name=Utopia 60 | curl http://localhost:8000/hello-world?name=Appwrite 61 | ``` 62 | 63 | It's always recommended to use params instead of getting params or body directly from the request resource. If you do that intentionally, always make sure to run validation right after fetching such a raw input. 64 | 65 | ### Hooks 66 | 67 | There are three types of hooks: 68 | 69 | - Init hooks are executed before the route action is executed 70 | - Shutdown hooks are executed after route action is finished, but before application shuts down 71 | - Error hooks are executed whenever there's an error in the application lifecycle. 72 | 73 | You can provide multiple hooks for each stage. If you do not assign groups to the hook, by default, the hook will be executed for every route. If a group is defined on a hook, it will only run during the lifecycle of a request that belongs to the same group. 74 | 75 | 76 | ```dart 77 | app 78 | .init() 79 | .inject('request') 80 | .action((Request request) { 81 | print('Received: ${request.method} ${request.url}'); 82 | }); 83 | 84 | app 85 | .shutdown() 86 | .inject('response') 87 | .action((Response response) { 88 | print('Responding with status code: ${response.status}'); 89 | }); 90 | 91 | app 92 | .error() 93 | .inject('error') 94 | .inject('response') 95 | .action((Exception error, Response response) { 96 | response.text(error.toString(), status: HttpStatus.internalServerError); 97 | }); 98 | 99 | ``` 100 | 101 | Hooks are designed to be actions that run during the lifecycle of requests. Hooks should include functional logic. Hooks are not designed to prepare dependencies or context for the request. For such a use case, you should use resources. 102 | 103 | ### Groups 104 | 105 | Groups allow you to define common behavior for multiple endpoints. 106 | 107 | You can start by defining a group on an endpoint. Keep in mind you can also define multiple groups on a single endpoint. 108 | 109 | ```dart 110 | app 111 | .get('/login') 112 | .group(['api', 'public']) 113 | .inject('response') 114 | .action((Response response) { 115 | response.text('OK'); 116 | return response; 117 | }); 118 | ``` 119 | 120 | Now you can define hooks that would apply only to specific groups. Remember, hooks can also be assigned to multiple groups. 121 | 122 | ```dart 123 | app 124 | .init() 125 | .group(['api']) 126 | .inject('request') 127 | .action((Request request) { 128 | final apiKey = request.headers['x-api-key'] ?? ''; 129 | if (apiKey.isEmpty) { 130 | response.text('Api key missing.', status: HttpStatus.unauthorized); 131 | } 132 | }); 133 | ``` 134 | 135 | Groups are designed to be actions that run during the lifecycle of requests to endpoints that have some logic in common. Groups allow you to prevent code duplication and are designed to be defined anywhere in your source code to allow flexibility. 136 | 137 | ### Resources 138 | Resources allow you to prepare dependencies for requests such as database connection or the user who sent the request. A new instance of a resource is created for every request. 139 | 140 | Define a resource: 141 | 142 | ```dart 143 | app.resource('timestamp', () { 144 | return DateTime.now().millisecondsSinceEpoch; 145 | }); 146 | ``` 147 | 148 | Inject resource into endpoint action: 149 | 150 | ```dart 151 | app 152 | .get('/') 153 | .inject('timestamp') 154 | .inject('response') 155 | .action((int timestamp) { 156 | final diff = DateTime.now().millisecondsSinceEpoch - timestamp; 157 | print('Request took: $difference'); 158 | }); 159 | ``` 160 | 161 | Inject resource into a hook: 162 | 163 | ```dart 164 | app 165 | .init() 166 | .inject('timestamp') 167 | .action((int timestamp) { 168 | print('Request timestamp: ${timestamp.toString()}'); 169 | }); 170 | ``` 171 | 172 | In advanced scenarios, resources can also be injected into other resources or endpoint parameters. 173 | 174 | Resources are designed to prepare dependencies or context for the request. Resources are not meant to do functional logic or return callbacks. For such a use case, you should use hooks. 175 | 176 | ## Copyright and license 177 | 178 | The MIT License (MIT) [https://www.opensource.org/licenses/mit-license.php](https://www.opensource.org/licenses/mit-license.php) 179 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | linter: 3 | rules: 4 | - prefer_relative_imports 5 | - avoid_relative_lib_imports 6 | - require_trailing_commas 7 | - always_declare_return_types 8 | - directives_ordering 9 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: utopia_http_example 2 | description: A starting point for Dart libraries or applications. 3 | version: 1.0.0 4 | publish_to: none 5 | # homepage: https://www.example.com 6 | 7 | environment: 8 | sdk: '>=2.17.5 <4.0.0' 9 | 10 | dependencies: 11 | shelf: 12 | utopia_http: 13 | path: ../ 14 | 15 | dev_dependencies: 16 | lints: ^3.0.0 17 | test: ^1.25.2 18 | -------------------------------------------------------------------------------- /example/utopia_http_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:utopia_http/utopia_http.dart'; 3 | 4 | void main() async { 5 | final address = InternetAddress.anyIPv4; 6 | final port = Http.getEnv('PORT', 8080); 7 | final app = Http(ShelfServer(address, port), threads: 8); 8 | 9 | app.get('/').inject('request').inject('response').action( 10 | (Request request, Response response) { 11 | response.text('Hello world'); 12 | return response; 13 | }, 14 | ); 15 | app 16 | .get('/hello-world') 17 | .inject('request') 18 | .inject('response') 19 | .action((Request request, Response response) { 20 | response.text('Hello world'); 21 | return response; 22 | }); 23 | 24 | app 25 | .get('/users/:userId') 26 | .param(key: 'userId', defaultValue: '', description: 'Users unique ID') 27 | .inject('response') 28 | .action((String userId, Response response) { 29 | response.text(userId); 30 | return response; 31 | }); 32 | 33 | app 34 | .get('/users/:userId/jhyap/:messing') 35 | .param(key: 'userId', defaultValue: '', description: 'Users unique ID') 36 | .param(key: 'messing', defaultValue: 'messing') 37 | .inject('response') 38 | .action((String userId, String messing, Response response) { 39 | response.text('tap tap'); 40 | return response; 41 | }); 42 | 43 | app 44 | .post('/users') 45 | .param(key: 'userId') 46 | .param(key: 'name') 47 | .param(key: 'email') 48 | .inject('response') 49 | .inject('request') 50 | .action(( 51 | String userId, 52 | String name, 53 | String email, 54 | Response response, 55 | Request request, 56 | ) { 57 | response.json({ 58 | 'userId': userId, 59 | 'name': name, 60 | 'email': email, 61 | }); 62 | return response; 63 | }); 64 | 65 | app 66 | .get('/users/:userId/jhyap') 67 | .param(key: 'userId', defaultValue: '', description: 'Users unique ID') 68 | .inject('response') 69 | .action((String userId, Response response) { 70 | print(userId); 71 | response.text('Jhyap'); 72 | return response; 73 | }); 74 | 75 | await app.start(); 76 | print("server started at http://${address.address}:$port"); 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/app_mode.dart: -------------------------------------------------------------------------------- 1 | /// Application mode 2 | enum AppMode { development, stage, production } 3 | -------------------------------------------------------------------------------- /lib/src/http.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer' as dev; 3 | import 'dart:io'; 4 | import 'dart:isolate'; 5 | 6 | import 'package:utopia_di/utopia_di.dart'; 7 | 8 | import 'app_mode.dart'; 9 | import 'isolate_entry_point.dart'; 10 | import 'isolate_message.dart'; 11 | import 'isolate_supervisor.dart'; 12 | import 'request.dart'; 13 | import 'response.dart'; 14 | import 'route.dart'; 15 | import 'router.dart'; 16 | import 'server.dart'; 17 | import 'validation_exception.dart'; 18 | 19 | final List _supervisors = []; 20 | 21 | /// Http class used to bootstrap your Http server 22 | /// You need to use one of the server adapters. Currently only 23 | /// Shelf adapter is available 24 | /// 25 | /// Example: 26 | /// ```dart 27 | /// void main() async { 28 | /// final address = InternetAddress.anyIPv4; 29 | /// final port = Http.getEnv('PORT', 8080); 30 | /// final app = Http(ShelfServer(address, port), threads: 8); 31 | /// // setup routes 32 | /// app.get('/').inject('request').inject('response').action( 33 | /// (Request request, Response response) { 34 | /// response.text('Hello world'); 35 | /// return response; 36 | /// }, 37 | /// ); 38 | /// // sart the server 39 | /// await app.start(); 40 | /// } 41 | /// ``` 42 | class Http { 43 | Http( 44 | this.server, { 45 | this.path, 46 | this.threads = 1, 47 | }) { 48 | _di = DI(); 49 | _router = Router(); 50 | } 51 | 52 | List get supervisors => _supervisors; 53 | 54 | /// Server adapter, currently only shelf server is supported 55 | final Server server; 56 | 57 | /// Number of threads (isolates) to spawn 58 | final int threads; 59 | 60 | /// Path to server static files from 61 | final String? path; 62 | 63 | late DI _di; 64 | 65 | final Map> _routes = { 66 | Request.get: {}, 67 | Request.post: {}, 68 | Request.put: {}, 69 | Request.patch: {}, 70 | Request.delete: {}, 71 | Request.head: {}, 72 | }; 73 | 74 | /// Configured routes for different methods 75 | Map> get routes => _routes; 76 | 77 | final List _errors = []; 78 | final List _init = []; 79 | final List _shutdown = []; 80 | final List _options = []; 81 | 82 | late final Router _router; 83 | 84 | Route? _wildcardRoute; 85 | 86 | /// Application mode 87 | AppMode? mode; 88 | 89 | /// Is application running in production mode 90 | bool get isProduction => mode == AppMode.production; 91 | 92 | /// Is application running in development mode 93 | bool get isDevelopment => mode == AppMode.development; 94 | 95 | /// Is application running in staging mode 96 | bool get isStage => mode == AppMode.stage; 97 | 98 | /// Memory cached result for chosen route 99 | Route? route; 100 | 101 | /// Start the servers 102 | Future start() async { 103 | _supervisors.clear(); 104 | for (int i = 0; i < threads; i++) { 105 | final supervisor = await _spawn( 106 | context: i.toString(), 107 | handler: run, 108 | path: path, 109 | ); 110 | _supervisors.add(supervisor); 111 | supervisor.resume(); 112 | dev.log('Worker ${i.toString()} ready.', name: 'FINE'); 113 | } 114 | } 115 | 116 | Future _spawn({ 117 | required String context, 118 | required Handler handler, 119 | SecurityContext? securityContext, 120 | String? path, 121 | }) async { 122 | final receivePort = ReceivePort(); 123 | final message = IsolateMessage( 124 | server: server, 125 | context: context, 126 | handler: run, 127 | securityContext: securityContext, 128 | path: path, 129 | sendPort: receivePort.sendPort, 130 | ); 131 | final isolate = await Isolate.spawn( 132 | entrypoint, 133 | message, 134 | paused: true, 135 | debugName: 'isolate_$context', 136 | ); 137 | return IsolateSupervisor( 138 | isolate: isolate, 139 | receivePort: receivePort, 140 | context: message.context, 141 | ); 142 | } 143 | 144 | /// Initialize a GET route 145 | Route get(String url) { 146 | return addRoute(Request.get, url); 147 | } 148 | 149 | /// Initialize a POST route 150 | Route post(String url) { 151 | return addRoute(Request.post, url); 152 | } 153 | 154 | /// Initialize a PATCH route 155 | Route patch(String url) { 156 | return addRoute(Request.patch, url); 157 | } 158 | 159 | /// Initialize a PUT route 160 | Route put(String url) { 161 | return addRoute(Request.put, url); 162 | } 163 | 164 | /// Initialize a DELETE route 165 | Route delete(String url) { 166 | return addRoute(Request.delete, url); 167 | } 168 | 169 | /// Initialize a wildcard route 170 | Route wildcard() { 171 | _wildcardRoute = Route('', ''); 172 | return _wildcardRoute!; 173 | } 174 | 175 | /// Initialize a init hook 176 | /// Init hooks are ran before executing each request 177 | Hook init() { 178 | final hook = Hook()..groups(['*']); 179 | _init.add(hook); 180 | return hook; 181 | } 182 | 183 | /// Initialize shutdown hook 184 | /// Shutdown hooks are ran after executing the request, before the response is sent 185 | Hook shutdown() { 186 | final hook = Hook()..groups(['*']); 187 | _shutdown.add(hook); 188 | return hook; 189 | } 190 | 191 | /// Initialize options hook 192 | /// Options hooks are ran for OPTIONS requests 193 | Hook options() { 194 | final hook = Hook()..groups(['*']); 195 | _options.add(hook); 196 | return hook; 197 | } 198 | 199 | /// Initialize error hooks 200 | /// Error hooks are ran for each errors 201 | Hook error() { 202 | final hook = Hook()..groups(['*']); 203 | _errors.add(hook); 204 | return hook; 205 | } 206 | 207 | /// Get environment variable 208 | static dynamic getEnv(String key, [dynamic def]) { 209 | return Platform.environment[key] ?? def; 210 | } 211 | 212 | /// Initialize route 213 | Route addRoute(String method, String path) { 214 | final route = Route(method, path); 215 | _router.addRoute(route); 216 | return route; 217 | } 218 | 219 | /// Set resource 220 | /// Once set, you can use `inject` to inject 221 | /// these resources to set other resources or in the hooks 222 | /// and routes 223 | void setResource( 224 | String name, 225 | Function callback, { 226 | String context = 'utopia', 227 | List injections = const [], 228 | }) => 229 | _di.set(name, callback, injections: injections, context: context); 230 | 231 | /// Get a resource 232 | T getResource( 233 | String name, { 234 | bool fresh = false, 235 | String context = 'utopia', 236 | }) => 237 | _di.get(name, fresh: fresh, context: context); 238 | 239 | /// Match route based on request 240 | Route? match(Request request) { 241 | var method = request.method; 242 | method = (method == Request.head) ? Request.get : method; 243 | route = _router.match(method, request.url.path); 244 | return route; 245 | } 246 | 247 | /// Get arguments for hooks 248 | Map _getArguments( 249 | Hook hook, { 250 | required String context, 251 | required Map requestParams, 252 | Map values = const {}, 253 | }) { 254 | final args = {}; 255 | hook.params.forEach((key, param) { 256 | final arg = requestParams[key] ?? param.defaultValue; 257 | var value = values[key] ?? arg; 258 | value = value == '' || value == null ? param.defaultValue : value; 259 | _validate(key, param, value); 260 | args[key] = value; 261 | }); 262 | 263 | for (var injection in hook.injections) { 264 | args[injection] = getResource(injection, context: context); 265 | } 266 | return args; 267 | } 268 | 269 | /// Execute list of given hooks 270 | Future _executeHooks( 271 | List hooks, 272 | List groups, 273 | Future> Function(Hook) argsCallback, { 274 | bool globalHook = false, 275 | bool globalHooksFirst = true, 276 | }) async { 277 | Future executeGlobalHook() async { 278 | for (final hook in hooks) { 279 | if (hook.getGroups().contains('*')) { 280 | final arguments = await argsCallback.call(hook); 281 | Function.apply( 282 | hook.getAction(), 283 | [...hook.argsOrder.map((key) => arguments[key])], 284 | ); 285 | } 286 | } 287 | } 288 | 289 | Future executeGroupHooks() async { 290 | for (final group in groups) { 291 | for (final hook in _init) { 292 | if (hook.getGroups().contains(group)) { 293 | final arguments = await argsCallback.call(hook); 294 | Function.apply( 295 | hook.getAction(), 296 | [...hook.argsOrder.map((key) => arguments[key])], 297 | ); 298 | } 299 | } 300 | } 301 | } 302 | 303 | if (globalHooksFirst && globalHook) { 304 | await executeGlobalHook(); 305 | } 306 | await executeGroupHooks(); 307 | if (!globalHooksFirst && globalHook) { 308 | await executeGlobalHook(); 309 | } 310 | } 311 | 312 | /// Execute request 313 | FutureOr execute( 314 | Route route, 315 | Request request, 316 | String context, 317 | ) async { 318 | final groups = route.getGroups(); 319 | final pathValues = route.getPathValues(request); 320 | 321 | try { 322 | await _executeHooks( 323 | _init, 324 | groups, 325 | (hook) async => _getArguments( 326 | hook, 327 | context: context, 328 | requestParams: await request.getParams(), 329 | values: pathValues, 330 | ), 331 | globalHook: route.hook, 332 | ); 333 | 334 | final args = _getArguments( 335 | route, 336 | context: context, 337 | requestParams: await request.getParams(), 338 | values: pathValues, 339 | ); 340 | final response = await Function.apply( 341 | route.getAction(), 342 | [...route.argsOrder.map((key) => args[key])], 343 | ); 344 | await _executeHooks( 345 | _shutdown, 346 | groups, 347 | (hook) async => _getArguments( 348 | hook, 349 | context: context, 350 | requestParams: await request.getParams(), 351 | values: pathValues, 352 | ), 353 | globalHook: route.hook, 354 | globalHooksFirst: false, 355 | ); 356 | 357 | return response ?? getResource('response', context: context); 358 | } on Exception catch (e) { 359 | _di.set('error', () => e); 360 | await _executeHooks( 361 | _errors, 362 | groups, 363 | (hook) async => _getArguments( 364 | hook, 365 | context: context, 366 | requestParams: await request.getParams(), 367 | values: pathValues, 368 | ), 369 | globalHook: route.hook, 370 | globalHooksFirst: false, 371 | ); 372 | 373 | if (e is ValidationException) { 374 | final response = getResource('response', context: context); 375 | response.status = 400; 376 | } 377 | } 378 | return getResource('response', context: context); 379 | } 380 | 381 | /// Run the execution for given request 382 | FutureOr run(Request request, String context) async { 383 | setResource('context', () => context, context: context); 384 | setResource('request', () => request, context: context); 385 | 386 | try { 387 | getResource('response', context: context); 388 | } catch (e) { 389 | setResource('response', () => Response(''), context: context); 390 | } 391 | 392 | var method = request.method.toUpperCase(); 393 | var route = match(request); 394 | final groups = (route is Route) ? route.getGroups() : []; 395 | 396 | if (method == Request.head) { 397 | method = Request.get; 398 | } 399 | 400 | if (route == null && _wildcardRoute != null) { 401 | route = _wildcardRoute; 402 | route!.path = request.url.path; 403 | } 404 | 405 | if (route != null) { 406 | return execute(route, request, context); 407 | } else if (method == Request.options) { 408 | try { 409 | _executeHooks( 410 | _options, 411 | groups, 412 | (hook) async => _getArguments( 413 | hook, 414 | context: context, 415 | requestParams: await request.getParams(), 416 | ), 417 | globalHook: true, 418 | globalHooksFirst: false, 419 | ); 420 | return getResource('response', context: context); 421 | } on Exception catch (e) { 422 | for (final hook in _errors) { 423 | _di.set('error', () => e); 424 | if (hook.getGroups().contains('*')) { 425 | hook.getAction().call( 426 | _getArguments( 427 | hook, 428 | context: context, 429 | requestParams: await request.getParams(), 430 | ), 431 | ); 432 | } 433 | } 434 | return getResource('response', context: context); 435 | } 436 | } 437 | final response = getResource('response', context: context); 438 | response.text('Not Found'); 439 | response.status = 404; 440 | 441 | // for each run, resources should be re-generated from callbacks 442 | resetResources(context); 443 | 444 | return response; 445 | } 446 | 447 | void _validate(String key, Param param, dynamic value) { 448 | if ('' != value && value != null) { 449 | final validator = param.validator; 450 | if (validator != null) { 451 | if (!validator.isValid(value)) { 452 | throw ValidationException( 453 | 'Invalid $key: ${validator.getDescription()}', 454 | ); 455 | } 456 | } 457 | } else if (!param.optional) { 458 | throw ValidationException('Param "$key" is not optional.'); 459 | } 460 | } 461 | 462 | /// Reset dependencies 463 | void resetResources([String? context]) { 464 | _di.resetResources(context); 465 | } 466 | 467 | /// Reset various resources 468 | void reset() { 469 | _router.reset(); 470 | _di.reset(); 471 | _errors.clear(); 472 | _init.clear(); 473 | _shutdown.clear(); 474 | _options.clear(); 475 | mode = null; 476 | } 477 | 478 | /// Stop servers 479 | Future stop() async { 480 | for (final sup in supervisors) { 481 | sup.stop(); 482 | } 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /lib/src/isolate_entry_point.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as dev; 2 | import 'dart:isolate'; 3 | 4 | import 'isolate_message.dart'; 5 | import 'isolate_supervisor.dart'; 6 | 7 | Future entrypoint(IsolateMessage message) async { 8 | final ReceivePort receivePort = ReceivePort(); 9 | await message.server.start( 10 | message.handler, 11 | path: message.path, 12 | context: message.context, 13 | ); 14 | 15 | message.sendPort.send(receivePort.sendPort); 16 | receivePort.listen((data) async { 17 | if (data == IsolateSupervisor.messageClose) { 18 | dev.log( 19 | 'Received close message on isolate ${message.context}', 20 | name: 'FINE', 21 | ); 22 | await message.server.stop(); 23 | receivePort.close(); 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/isolate_message.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:isolate' as iso; 3 | 4 | import 'server.dart'; 5 | 6 | class IsolateMessage { 7 | final Handler handler; 8 | final SecurityContext? securityContext; 9 | final String? path; 10 | final String context; 11 | final iso.SendPort sendPort; 12 | final Server server; 13 | 14 | IsolateMessage({ 15 | required this.server, 16 | required this.handler, 17 | required this.context, 18 | this.path, 19 | this.securityContext, 20 | required this.sendPort, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/isolate_supervisor.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as dev; 2 | import 'dart:isolate' as iso; 3 | 4 | class IsolateSupervisor { 5 | final iso.Isolate isolate; 6 | final iso.ReceivePort receivePort; 7 | final String context; 8 | iso.SendPort? _isolateSendPort; 9 | 10 | static const String messageClose = '_CLOSE'; 11 | 12 | IsolateSupervisor({ 13 | required this.isolate, 14 | required this.receivePort, 15 | required this.context, 16 | }); 17 | 18 | void resume() { 19 | receivePort.listen(listen); 20 | isolate.resume(isolate.pauseCapability!); 21 | } 22 | 23 | void stop() { 24 | dev.log('Stopping isolate $context', name: 'FINE'); 25 | _isolateSendPort?.send(messageClose); 26 | receivePort.close(); 27 | } 28 | 29 | void listen(dynamic message) async { 30 | if (message is iso.SendPort) { 31 | _isolateSendPort = message; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/request.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:http_parser/http_parser.dart'; 3 | import 'package:mime/mime.dart'; 4 | import 'package:string_scanner/string_scanner.dart'; 5 | 6 | class Request { 7 | /// GET method 8 | static const String get = 'GET'; 9 | 10 | /// POST method 11 | static const String post = 'POST'; 12 | 13 | /// PUT method 14 | static const String put = 'PUT'; 15 | 16 | /// PATCH method 17 | static const String patch = 'PATCH'; 18 | 19 | /// DELETE method 20 | static const String delete = 'DELETE'; 21 | 22 | /// HEAD method 23 | static const String head = 'HEAD'; 24 | 25 | /// OPTIONS method 26 | static const String options = 'OPTIONS'; 27 | 28 | /// Uri 29 | final Uri url; 30 | 31 | /// Method 32 | final String method; 33 | 34 | /// Headers 35 | final Map headers; 36 | 37 | /// All headers 38 | final Map> headersAll; 39 | 40 | /// Encoding 41 | final Encoding? encoding; 42 | 43 | /// Content type 44 | final String? contentType; 45 | 46 | /// Body 47 | final Stream>? body; 48 | 49 | /// Payload 50 | Map? _payload; 51 | 52 | Request( 53 | this.method, 54 | this.url, { 55 | this.headers = const {}, 56 | this.headersAll = const {}, 57 | this.encoding, 58 | this.contentType, 59 | this.body, 60 | }); 61 | 62 | /// Get parameter with matching key or return default value 63 | dynamic getParam(String key, {dynamic defaultValue}) async { 64 | switch (method) { 65 | case put: 66 | case post: 67 | case patch: 68 | case delete: 69 | return getPayload(key, defaultValue: defaultValue); 70 | case get: 71 | default: 72 | return getQuery(key, defaultValue: defaultValue); 73 | } 74 | } 75 | 76 | /// Get all the parameters 77 | Future> getParams() async { 78 | switch (method) { 79 | case put: 80 | case post: 81 | case patch: 82 | case delete: 83 | return _generateInput(); 84 | case get: 85 | default: 86 | return url.queryParameters; 87 | } 88 | } 89 | 90 | /// Get payload 91 | dynamic getPayload(String key, {dynamic defaultValue}) async { 92 | await _generateInput(); 93 | return _payload![key] ?? defaultValue; 94 | } 95 | 96 | Future> _generateInput() async { 97 | if (_payload != null) return _payload!; 98 | 99 | final ctype = (contentType ?? 'text/plain').split(';').first; 100 | switch (ctype) { 101 | case 'application/json': 102 | final bodyString = await (encoding ?? utf8).decodeStream(body!); 103 | _payload = jsonDecode(bodyString) as Map; 104 | break; 105 | case 'multipart/form-data': 106 | _payload = await _multipartFormData(); 107 | break; 108 | case 'application/x-www-form-urlencoded': 109 | default: 110 | final bodyString = await (encoding ?? utf8).decodeStream(body!); 111 | _payload = Uri(query: bodyString).queryParameters; 112 | } 113 | return _payload!; 114 | } 115 | 116 | /// Get query parameter 117 | dynamic getQuery(String key, {dynamic defaultValue}) { 118 | return url.queryParameters[key] ?? defaultValue; 119 | } 120 | 121 | /// Parse multipart forma data 122 | Future> _multipartFormData() async { 123 | final data = await _parts 124 | .map<_FormData?>((part) { 125 | final rawDisposition = part.headers['content-disposition']; 126 | if (rawDisposition == null) return null; 127 | 128 | final formDataParams = 129 | _parseFormDataContentDisposition(rawDisposition); 130 | if (formDataParams == null) return null; 131 | 132 | final name = formDataParams['name']; 133 | if (name == null) return null; 134 | 135 | final filename = formDataParams['filename']; 136 | dynamic value; 137 | if (filename != null) { 138 | value = { 139 | "file": part, 140 | "filename": filename, 141 | "mimeType": part.headers['Content-Type'], 142 | }; 143 | } else { 144 | value = (encoding ?? utf8).decodeStream(part); 145 | } 146 | return _FormData._(name, filename, value); 147 | }) 148 | .where((data) => data != null) 149 | .toList(); 150 | final Map out = {}; 151 | for (final item in data) { 152 | if (item!.filename != null) { 153 | out[item.name] = await item.value; 154 | } else { 155 | out[item.name] = await item.value; 156 | } 157 | } 158 | return out; 159 | } 160 | 161 | Stream get _parts { 162 | final boundary = _extractBoundary(); 163 | if (boundary == null) { 164 | throw Exception('Not a multipart request'); 165 | } 166 | return MimeMultipartTransformer(boundary).bind(body!); 167 | } 168 | 169 | String? _extractBoundary() { 170 | if (!headers.containsKey('Content-Type')) return null; 171 | final contentType = MediaType.parse(headers['Content-Type']!); 172 | if (contentType.type != 'multipart') return null; 173 | 174 | return contentType.parameters['boundary']; 175 | } 176 | } 177 | 178 | final _token = RegExp(r'[^()<>@,;:"\\/[\]?={} \t\x00-\x1F\x7F]+'); 179 | final _whitespace = RegExp(r'(?:(?:\r\n)?[ \t]+)*'); 180 | final _quotedString = RegExp(r'"(?:[^"\x00-\x1F\x7F]|\\.)*"'); 181 | final _quotedPair = RegExp(r'\\(.)'); 182 | 183 | /// Parses a `content-disposition: form-data; arg1="val1"; ...` header. 184 | Map? _parseFormDataContentDisposition(String header) { 185 | final scanner = StringScanner(header); 186 | 187 | scanner 188 | ..scan(_whitespace) 189 | ..expect(_token); 190 | if (scanner.lastMatch![0] != 'form-data') return null; 191 | 192 | final params = {}; 193 | 194 | while (scanner.scan(';')) { 195 | scanner 196 | ..scan(_whitespace) 197 | ..scan(_token); 198 | final key = scanner.lastMatch![0]!; 199 | scanner.expect('='); 200 | 201 | String value; 202 | if (scanner.scan(_token)) { 203 | value = scanner.lastMatch![0]!; 204 | } else { 205 | scanner.expect(_quotedString, name: 'quoted string'); 206 | final string = scanner.lastMatch![0]!; 207 | 208 | value = string 209 | .substring(1, string.length - 1) 210 | .replaceAllMapped(_quotedPair, (match) => match[1]!); 211 | } 212 | 213 | scanner.scan(_whitespace); 214 | params[key] = value; 215 | } 216 | 217 | scanner.expectDone(); 218 | return params; 219 | } 220 | 221 | class _FormData { 222 | final String name; 223 | final dynamic value; 224 | final String? filename; 225 | 226 | _FormData._(this.name, this.filename, this.value); 227 | } 228 | -------------------------------------------------------------------------------- /lib/src/response.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | class Response { 5 | /// Response body 6 | String body; 7 | 8 | /// HTTP status 9 | int status = 200; 10 | 11 | /// Content type 12 | ContentType contentType = ContentType.text; 13 | 14 | /// Disable payload 15 | bool disablePayload = false; 16 | final Map _headers; 17 | final List _cookies = []; 18 | 19 | /// Get headers 20 | Map get headers { 21 | _headers[HttpHeaders.contentTypeHeader] = contentType.toString(); 22 | _headers[HttpHeaders.setCookieHeader] = 23 | _cookies.map((cookie) => cookie.toString()).join(','); 24 | return _headers; 25 | } 26 | 27 | /// Get cookies 28 | List get cookies => _cookies; 29 | 30 | Response(this.body, {this.status = 200, Map? headers}) 31 | : _headers = headers ?? {}; 32 | 33 | /// Add header 34 | Response addHeader(String key, String value) { 35 | _headers[key] = value; 36 | return this; 37 | } 38 | 39 | /// Remove header 40 | Response removeHeader(String key) { 41 | _headers.remove(key); 42 | return this; 43 | } 44 | 45 | /// Add cookie 46 | Response addCookie(Cookie cookie) { 47 | _cookies.add(cookie); 48 | return this; 49 | } 50 | 51 | /// Remove cookie 52 | Response removeCookie(Cookie cookie) { 53 | _cookies.removeWhere((element) => element.name == cookie.name); 54 | return this; 55 | } 56 | 57 | /// Set json response 58 | void json(Map data, {int status = HttpStatus.ok}) { 59 | contentType = ContentType.json; 60 | body = jsonEncode(data); 61 | } 62 | 63 | /// Set text response 64 | void text(String data, {int status = HttpStatus.ok}) { 65 | contentType = ContentType.text; 66 | this.status = status; 67 | body = data; 68 | } 69 | 70 | /// Set HTML response 71 | void html(String data, {int status = HttpStatus.ok}) { 72 | contentType = ContentType.html; 73 | this.status = status; 74 | body = data; 75 | } 76 | 77 | /// Set empty response 78 | void noContent() { 79 | status = HttpStatus.noContent; 80 | body = ''; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/route.dart: -------------------------------------------------------------------------------- 1 | import 'package:utopia_di/utopia_di.dart'; 2 | 3 | import 'request.dart'; 4 | 5 | /// Route 6 | /// 7 | /// A http route 8 | class Route extends Hook { 9 | /// HTTP method 10 | String method = ''; 11 | 12 | /// Whether or not hook is enabled 13 | bool hook = true; 14 | 15 | /// Route path 16 | String path; 17 | static int counter = 0; 18 | final List _aliases = []; 19 | final Map labels = {}; 20 | 21 | final Map _pathParams = {}; 22 | 23 | Route(this.method, this.path) : super() { 24 | Route.counter++; 25 | order = counter; 26 | } 27 | 28 | /// Get route aliases 29 | List get aliases => _aliases; 30 | Map get pathParams => _pathParams; 31 | 32 | /// Add a route alias 33 | Route alias(String path) { 34 | if (!_aliases.contains(path)) { 35 | _aliases.add(path); 36 | } 37 | 38 | return this; 39 | } 40 | 41 | /// Set path params 42 | void setPathParam(String key, int index) { 43 | _pathParams[key] = index; 44 | } 45 | 46 | /// Get values for path params 47 | Map getPathValues(Request request) { 48 | var pathValues = {}; 49 | var parts = request.url.path.split('/').where((part) => part.isNotEmpty); 50 | 51 | for (var entry in pathParams.entries) { 52 | if (entry.value < parts.length) { 53 | pathValues[entry.key] = parts.elementAt(entry.value); 54 | } 55 | } 56 | 57 | return pathValues; 58 | } 59 | 60 | /// Set route label 61 | Route label(String key, String value) { 62 | labels[key] = value; 63 | return this; 64 | } 65 | 66 | /// Get route label 67 | String? getLabel(String key, {String? defaultValue}) { 68 | return labels[key] ?? defaultValue; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/src/router.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | import 'route.dart'; 4 | 5 | /// Router 6 | class Router { 7 | /// Placeholder token for route 8 | static const String placeholderToken = ':::'; 9 | 10 | Map> _routes = { 11 | 'GET': {}, 12 | 'POST': {}, 13 | 'PUT': {}, 14 | 'PATCH': {}, 15 | 'DELETE': {}, 16 | }; 17 | 18 | List _params = []; 19 | 20 | /// Get list of all the routes 21 | UnmodifiableMapView> getRoutes() { 22 | return UnmodifiableMapView(_routes); 23 | } 24 | 25 | /// Add a route 26 | void addRoute(Route route) { 27 | List result = preparePath(route.path); 28 | String path = result[0]; 29 | Map params = result[1]; 30 | 31 | if (!_routes.containsKey(route.method)) { 32 | throw Exception("Method (${route.method}) not supported."); 33 | } 34 | 35 | if (_routes[route.method]!.containsKey(path)) { 36 | throw Exception("Route for (${route.method}:$path) already registered."); 37 | } 38 | 39 | params.forEach((key, index) { 40 | route.setPathParam(key, index); 41 | }); 42 | 43 | _routes[route.method]![path] = (route); 44 | 45 | for (String alias in route.aliases) { 46 | List aliasResult = preparePath(alias); 47 | String aliasPath = aliasResult[0]; 48 | _routes[route.method]![aliasPath] = route; 49 | } 50 | } 51 | 52 | /// Match a route for given method and path 53 | Route? match(String method, String path) { 54 | if (!_routes.containsKey(method)) { 55 | return null; 56 | } 57 | 58 | List parts = path.split('/').where((p) => p.isNotEmpty).toList(); 59 | int length = parts.length - 1; 60 | List filteredParams = _params.where((i) => i <= length).toList(); 61 | 62 | for (List sample in combinations(filteredParams)) { 63 | sample = sample.where((i) => i <= length).toList(); 64 | String match = parts 65 | .asMap() 66 | .entries 67 | .map( 68 | (entry) => 69 | sample.contains(entry.key) ? placeholderToken : entry.value, 70 | ) 71 | .join('/'); 72 | 73 | if (_routes[method]!.containsKey(match)) { 74 | return _routes[method]![match]!; 75 | } 76 | } 77 | 78 | return null; 79 | } 80 | 81 | Iterable> combinations(List set) { 82 | final result = >[[]]; 83 | 84 | for (final element in set) { 85 | final newCombinations = >[]; 86 | for (final combination in result) { 87 | final ret = [element, ...combination]; 88 | newCombinations.add(ret); 89 | } 90 | result.addAll(newCombinations); 91 | } 92 | 93 | return result; 94 | } 95 | 96 | /// Prepare path 97 | List preparePath(String path) { 98 | List parts = path.split('/').where((p) => p.isNotEmpty).toList(); 99 | String prepare = ''; 100 | Map params = {}; 101 | 102 | for (int key = 0; key < parts.length; key++) { 103 | String part = parts[key]; 104 | if (key != 0) { 105 | prepare += '/'; 106 | } 107 | 108 | if (part.startsWith(':')) { 109 | prepare += placeholderToken; 110 | params[part.substring(1)] = key; 111 | if (!_params.contains(key)) { 112 | _params.add(key); 113 | } 114 | } else { 115 | prepare += part; 116 | } 117 | } 118 | 119 | return [prepare, params]; 120 | } 121 | 122 | /// Reset router 123 | void reset() { 124 | _params = []; 125 | _routes = { 126 | 'GET': {}, 127 | 'POST': {}, 128 | 'PUT': {}, 129 | 'PATCH': {}, 130 | 'DELETE': {}, 131 | }; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lib/src/server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'request.dart'; 4 | import 'response.dart'; 5 | 6 | /// Server request handler 7 | typedef Handler = FutureOr Function(Request, String); 8 | 9 | /// Server adapter 10 | abstract class Server { 11 | /// Server port 12 | final int port; 13 | 14 | /// Server address 15 | final dynamic address; 16 | 17 | /// Server security context 18 | final SecurityContext? securityContext; 19 | 20 | Server(this.address, this.port, {this.securityContext}); 21 | 22 | /// Start the server 23 | Future start( 24 | Handler handler, { 25 | String context = 'utopia', 26 | String? path, 27 | }); 28 | 29 | /// Stop the server 30 | Future stop(); 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/servers/shelf.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:shelf/shelf.dart' as shelf; 5 | import 'package:shelf/shelf_io.dart' as shelf_io; 6 | import 'package:shelf_static/shelf_static.dart'; 7 | 8 | import '../request.dart'; 9 | import '../response.dart'; 10 | import '../server.dart'; 11 | 12 | /// ShelfServer 13 | /// 14 | /// Create a server 15 | class ShelfServer extends Server { 16 | HttpServer? _server; 17 | ShelfServer(super.address, super.port, {super.securityContext}); 18 | 19 | /// Start the server 20 | @override 21 | Future start( 22 | Handler handler, { 23 | String context = 'utopia', 24 | String? path, 25 | }) async { 26 | var shelfHandler = (shelf.Request request) => _handleRequest( 27 | request, 28 | context, 29 | handler, 30 | ); 31 | if (path != null) { 32 | shelfHandler = shelf.Cascade() 33 | .add(createStaticHandler(path)) 34 | .add( 35 | (request) => _handleRequest( 36 | request, 37 | context, 38 | handler, 39 | ), 40 | ) 41 | .handler; 42 | } 43 | 44 | _server = await shelf_io.serve( 45 | shelfHandler, 46 | address, 47 | port, 48 | securityContext: securityContext, 49 | shared: true, 50 | ); 51 | } 52 | 53 | /// Stop servers 54 | @override 55 | Future stop() async { 56 | await _server?.close(force: true); 57 | } 58 | 59 | FutureOr _handleRequest( 60 | shelf.Request sheflRequest, 61 | String context, 62 | Handler handler, 63 | ) async { 64 | final request = _fromShelfRequest(sheflRequest); 65 | final response = await handler.call(request, context); 66 | return _toShelfResponse(response); 67 | } 68 | 69 | Request _fromShelfRequest(shelf.Request shelfRequest) { 70 | return Request( 71 | shelfRequest.method, 72 | shelfRequest.url, 73 | body: shelfRequest.read(), 74 | headers: shelfRequest.headers, 75 | headersAll: shelfRequest.headersAll, 76 | contentType: shelfRequest.headers[HttpHeaders.contentTypeHeader], 77 | ); 78 | } 79 | 80 | shelf.Response _toShelfResponse(Response response) { 81 | final res = shelf.Response( 82 | response.status, 83 | body: response.body, 84 | headers: response.headers, 85 | ); 86 | return res; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/validation_exception.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | /// ValidationException 4 | class ValidationException extends HttpException { 5 | ValidationException(super.message); 6 | } 7 | -------------------------------------------------------------------------------- /lib/utopia_http.dart: -------------------------------------------------------------------------------- 1 | /// Utopia Framework is a Dart http framework with minimal must-have 2 | /// features for profressional, simple, advanced and secure web development 3 | /// 4 | library utopia_framework; 5 | 6 | export 'src/app_mode.dart'; 7 | export 'src/http.dart'; 8 | export 'src/request.dart'; 9 | export 'src/response.dart'; 10 | export 'src/route.dart'; 11 | export 'src/router.dart'; 12 | export 'src/server.dart'; 13 | export 'src/servers/shelf.dart'; 14 | export 'src/validation_exception.dart'; 15 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: utopia_http 2 | description: A light and easy to get started with HTTP server library for Dart 3 | version: 0.2.0 4 | homepage: https://github.com/utopia-dart/utopia_http 5 | 6 | environment: 7 | sdk: '>=2.18.0 <4.0.0' 8 | 9 | dependencies: 10 | mime: ^1.0.5 11 | http_parser: ^4.0.2 12 | string_scanner: ^1.2.0 13 | shelf: ^1.4.1 14 | shelf_static: ^1.1.2 15 | utopia_di: ^0.2.0 16 | 17 | dev_dependencies: 18 | lints: ^3.0.0 19 | test: ^1.25.2 20 | http: ^1.2.1 21 | -------------------------------------------------------------------------------- /test/e2e/http_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | import 'package:http/http.dart' as http; 4 | import 'package:test/test.dart'; 5 | import 'package:utopia_http/utopia_http.dart'; 6 | 7 | import 'server.dart' as server; 8 | 9 | void main() { 10 | group('Http Shelf Server', () { 11 | Http? http; 12 | setUp(() async { 13 | http = await server.shelfServer(); 14 | }); 15 | 16 | test('Basic Response', basicResponseTest); 17 | 18 | test('No param, injection', noParamInjectionTest); 19 | 20 | test('Param', paramsTest); 21 | 22 | test('Param Validation', paramValidationTest); 23 | 24 | test('JSON', jsonTest); 25 | 26 | test('static', staticFileTest); 27 | 28 | test('file upload', fileUpload); 29 | 30 | tearDown(() async { 31 | await http?.stop(); 32 | }); 33 | }); 34 | } 35 | 36 | void fileUpload() async { 37 | var uri = Uri.parse('http://localhost:3030/create'); 38 | var request = http.MultipartRequest('POST', uri) 39 | ..fields['userId'] = '1' 40 | ..files.add( 41 | await http.MultipartFile.fromPath( 42 | 'file', 43 | 'test/e2e/public/index.html', 44 | filename: 'index', 45 | ), 46 | ); 47 | var res = await request.send(); 48 | final out = await utf8.decodeStream(res.stream); 49 | expect(res.statusCode, 200); 50 | expect(out, 'index'); 51 | } 52 | 53 | void staticFileTest() async { 54 | final client = HttpClient(); 55 | final req = 56 | await client.getUrl(Uri.parse('http://localhost:3030/index.html')); 57 | final res = await req.close(); 58 | final output = await utf8.decodeStream(res); 59 | assert(res.headers.contentType.toString().contains('text/html')); 60 | expect(output, 'Hello World Html!'); 61 | } 62 | 63 | void jsonTest() async { 64 | final client = HttpClient(); 65 | final req = await client.postUrl(Uri.parse('http://localhost:3030/users')); 66 | req.headers.set(HttpHeaders.contentTypeHeader, ContentType.json.toString()); 67 | final data = { 68 | "userId": "myuserid", 69 | "email": "email@gmail.com", 70 | "name": "myname", 71 | }; 72 | req.write(jsonEncode(data)); 73 | final res = await req.close(); 74 | final output = await utf8.decodeStream(res); 75 | expect(res.headers.contentType.toString(), ContentType.json.toString()); 76 | expect( 77 | output, 78 | '{"userId":"myuserid","email":"email@gmail.com","name":"myname"}', 79 | ); 80 | } 81 | 82 | void paramValidationTest() async { 83 | final client = HttpClient(); 84 | final req = await client.getUrl( 85 | Uri.parse('http://localhost:3030/users/verylonguseridnotvalidate'), 86 | ); 87 | final res = await req.close(); 88 | final output = await utf8.decodeStream(res); 89 | expect( 90 | output, 91 | 'Invalid userId: Value must be a valid string and no longer than 10 chars', 92 | ); 93 | } 94 | 95 | void paramsTest() async { 96 | final client = HttpClient(); 97 | final req = 98 | await client.getUrl(Uri.parse('http://localhost:3030/users/myuserid')); 99 | final res = await req.close(); 100 | final output = await utf8.decodeStream(res); 101 | expect(output, 'myuserid'); 102 | } 103 | 104 | void noParamInjectionTest() async { 105 | final client = HttpClient(); 106 | final req = await client.getUrl(Uri.parse('http://localhost:3030')); 107 | final res = await req.close(); 108 | final output = await utf8.decodeStream(res); 109 | expect(output, 'Hello!'); 110 | } 111 | 112 | void actionNullReturnTest() async { 113 | final client = HttpClient(); 114 | final req = await client.getUrl(Uri.parse('http://localhost:3030/empty')); 115 | final res = await req.close(); 116 | final output = await utf8.decodeStream(res); 117 | expect(output, ''); 118 | } 119 | 120 | void basicResponseTest() async { 121 | final client = HttpClient(); 122 | final req = await client.getUrl(Uri.parse('http://localhost:3030/hello')); 123 | final res = await req.close(); 124 | final output = await utf8.decodeStream(res); 125 | expect(output, 'Hello World!'); 126 | } 127 | -------------------------------------------------------------------------------- /test/e2e/public/index.html: -------------------------------------------------------------------------------- 1 | Hello World Html! -------------------------------------------------------------------------------- /test/e2e/server.dart: -------------------------------------------------------------------------------- 1 | import 'package:utopia_di/utopia_validators.dart'; 2 | import 'package:utopia_http/utopia_http.dart'; 3 | 4 | void initHttp(Http http) { 5 | http 6 | .error() 7 | .inject('error') 8 | .inject('response') 9 | .action((Exception error, Response response) { 10 | if (error is ValidationException) { 11 | response.status = 400; 12 | response.body = error.message; 13 | } 14 | return response; 15 | }); 16 | 17 | http.get('/').action(() { 18 | return Response('Hello!'); 19 | }); 20 | 21 | http.get('/empty').action(() {}); 22 | 23 | http 24 | .post('/create') 25 | .param(key: 'userId') 26 | .param(key: 'file') 27 | .inject('request') 28 | .inject('response') 29 | .action( 30 | (String userId, dynamic file, Request request, Response response) { 31 | response.text(file['filename']); 32 | return response; 33 | }); 34 | 35 | http 36 | .get('/hello') 37 | .inject('request') 38 | .inject('response') 39 | .action((Request request, Response response) { 40 | response.text('Hello World!'); 41 | return response; 42 | }); 43 | 44 | http 45 | .get('/users/:userId') 46 | .param( 47 | key: 'userId', 48 | validator: Text(length: 10), 49 | defaultValue: '', 50 | description: 'Users unique ID', 51 | ) 52 | .inject('response') 53 | .action((String userId, Response response) { 54 | response.text(userId); 55 | return response; 56 | }); 57 | 58 | http 59 | .post('/users') 60 | .param(key: 'userId') 61 | .param(key: 'name') 62 | .param(key: 'email') 63 | .inject('response') 64 | .inject('request') 65 | .action(( 66 | String userId, 67 | String name, 68 | String email, 69 | Response response, 70 | Request request, 71 | ) { 72 | response.json({ 73 | "userId": userId, 74 | "email": email, 75 | "name": name, 76 | }); 77 | return response; 78 | }); 79 | } 80 | 81 | Future shelfServer() async { 82 | final http = Http(ShelfServer('localhost', 3030), path: 'test/e2e/public'); 83 | initHttp(http); 84 | await http.start(); 85 | return http; 86 | } 87 | -------------------------------------------------------------------------------- /test/unit/http_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:test/test.dart'; 4 | import 'package:utopia_di/utopia_validators.dart'; 5 | import 'package:utopia_http/utopia_http.dart'; 6 | 7 | void main() async { 8 | final http = Http(ShelfServer('localhost', 8080)); 9 | http.setResource('rand', () => Random().nextInt(100)); 10 | http.setResource( 11 | 'first', 12 | (String second) => 'first-$second', 13 | injections: ['second'], 14 | ); 15 | http.setResource('second', () => 'second'); 16 | 17 | group('Http', () { 18 | test('resource injection', () async { 19 | final resource = http.getResource('rand'); 20 | 21 | final route = Route('GET', '/path'); 22 | route 23 | .inject('rand') 24 | .param( 25 | key: 'x', 26 | defaultValue: 'x-def', 27 | description: 'x param', 28 | validator: Text(length: 200), 29 | ) 30 | .param( 31 | key: 'y', 32 | defaultValue: 'y-def', 33 | description: 'y param', 34 | validator: Text(length: 200), 35 | ) 36 | .action((int rand, String x, String y) => Response("$x-$y-$rand")); 37 | final res = await http.execute( 38 | route, 39 | Request( 40 | 'GET', 41 | Uri.parse('/path'), 42 | ), 43 | 'utopia', 44 | ); 45 | expect(res.body, 'x-def-y-def-$resource'); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/unit/route_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:utopia_http/utopia_http.dart'; 3 | 4 | void main() async { 5 | final route = Route('GET', '/'); 6 | test('method', () { 7 | expect('GET', route.method); 8 | }); 9 | 10 | test('path', () { 11 | expect('/', route.path); 12 | }); 13 | 14 | test('description', () { 15 | expect('', route.description); 16 | route.desc('new route'); 17 | expect('new route', route.description); 18 | }); 19 | 20 | test('params', () { 21 | route.param(key: 'x', defaultValue: '').param(key: 'y', defaultValue: ''); 22 | expect(2, route.params.length); 23 | }); 24 | 25 | test('resources', () { 26 | expect([], route.injections); 27 | 28 | route.inject('user').inject('time').action(() {}); 29 | 30 | expect(2, route.injections.length); 31 | expect('user', route.injections[0]); 32 | expect('time', route.injections[1]); 33 | }); 34 | 35 | test('label', () { 36 | expect(route.getLabel('key', defaultValue: 'default'), 'default'); 37 | route.label('key', 'value'); 38 | expect(route.getLabel('key', defaultValue: 'default'), 'value'); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /test/unit/router_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:utopia_http/utopia_http.dart'; 3 | 4 | void main() { 5 | final router = Router(); 6 | group('router', () { 7 | setUp(() { 8 | router.reset(); 9 | }); 10 | 11 | test('Can match URL', () { 12 | final routeIndex = Route(Request.get, '/'); 13 | final routeAbout = Route(Request.get, '/about'); 14 | final routeAboutMe = Route(Request.get, '/about/me'); 15 | 16 | router.addRoute(routeIndex); 17 | router.addRoute(routeAbout); 18 | router.addRoute(routeAboutMe); 19 | 20 | expect(router.match(Request.get, '/'), equals(routeIndex)); 21 | expect( 22 | router.match(Request.get, '/about'), 23 | equals(routeAbout), 24 | ); 25 | expect( 26 | router.match(Request.get, '/about/me'), 27 | equals(routeAboutMe), 28 | ); 29 | }); 30 | 31 | test('Can match URL with placeholder', () { 32 | final routeBlog = Route(Request.get, '/blog'); 33 | final routeBlogAuthors = Route(Request.get, '/blog/authors'); 34 | final routeBlogAuthorsComments = 35 | Route(Request.get, '/blog/authors/comments'); 36 | final routeBlogPost = Route(Request.get, '/blog/:post'); 37 | final routeBlogPostComments = Route(Request.get, '/blog/:post/comments'); 38 | final routeBlogPostCommentsSingle = 39 | Route(Request.get, '/blog/:post/comments/:comment'); 40 | 41 | router.addRoute(routeBlog); 42 | router.addRoute(routeBlogAuthors); 43 | router.addRoute(routeBlogAuthorsComments); 44 | router.addRoute(routeBlogPost); 45 | router.addRoute(routeBlogPostComments); 46 | router.addRoute(routeBlogPostCommentsSingle); 47 | 48 | expect(router.match(Request.get, '/blog'), equals(routeBlog)); 49 | expect( 50 | router.match(Request.get, '/blog/authors'), 51 | equals(routeBlogAuthors), 52 | ); 53 | expect( 54 | router.match(Request.get, '/blog/authors/comments'), 55 | equals(routeBlogAuthorsComments), 56 | ); 57 | expect( 58 | router.match(Request.get, '/blog/:post'), 59 | equals(routeBlogPost), 60 | ); 61 | expect( 62 | router.match(Request.get, '/blog/:post/comments'), 63 | equals(routeBlogPostComments), 64 | ); 65 | expect( 66 | router.match(Request.get, '/blog/:post/comments/:comment'), 67 | equals(routeBlogPostCommentsSingle), 68 | ); 69 | }); 70 | 71 | test('Can match HTTP method', () { 72 | final routeGET = Route(Request.get, '/'); 73 | final routePOST = Route(Request.post, '/'); 74 | 75 | router.addRoute(routeGET); 76 | router.addRoute(routePOST); 77 | 78 | expect(router.match(Request.get, '/'), equals(routeGET)); 79 | expect(router.match(Request.post, '/'), equals(routePOST)); 80 | 81 | expect(router.match(Request.post, '/'), isNot(routeGET)); 82 | expect(router.match(Request.get, '/'), isNot(routePOST)); 83 | }); 84 | 85 | test('Can match alias', () { 86 | final routeGET = Route(Request.get, '/target'); 87 | routeGET.alias('/alias').alias('/alias2'); 88 | 89 | router.addRoute(routeGET); 90 | 91 | expect(router.match(Request.get, '/target'), equals(routeGET)); 92 | expect(router.match(Request.get, '/alias'), equals(routeGET)); 93 | expect(router.match(Request.get, '/alias2'), equals(routeGET)); 94 | }); 95 | 96 | test('Cannot find unknown route by path', () { 97 | expect(router.match(Request.get, '/404'), isNull); 98 | }); 99 | 100 | test('Cannot find unknown route by method', () { 101 | final route = Route(Request.get, '/404'); 102 | 103 | router.addRoute(route); 104 | 105 | expect(router.match(Request.get, '/404'), equals(route)); 106 | }); 107 | }); 108 | } 109 | --------------------------------------------------------------------------------