diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..8b5bda2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,24 @@ +--- +name: Bug +about: Create a report to warn us about a bug +title: "fix: " +labels: bug +--- + +**What happened** + +A simple and clear description of what the bug is. + +**Code** + +```dart +void main() { + + /// your code goes here + +} +``` + +**What should have happened** + +A simple and clear description of what you expected to happen. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ec4bb386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..5e372f21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,15 @@ +--- +name: Feature +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled +- [ ] Another requirement diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5c101cf1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ +# Description + +Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules + diff --git a/.github/workflows/serinus_docs_deploy.yml b/.github/workflows/serinus_docs_deploy.yml index f87b662e..bf92be3f 100644 --- a/.github/workflows/serinus_docs_deploy.yml +++ b/.github/workflows/serinus_docs_deploy.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'docs/**' pull_request: types: [opened, synchronize, reopened, closed] branches: diff --git a/.github/workflows/serinus_tests.yml b/.github/workflows/serinus_tests.yml index f8b6d537..337219d8 100644 --- a/.github/workflows/serinus_tests.yml +++ b/.github/workflows/serinus_tests.yml @@ -29,14 +29,15 @@ jobs: run: | dart pub global activate coverage export SERINUS_TEST=true - dart test --coverage=coverage test/serinus.dart && dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=${{inputs.report_on}} + dart test --coverage=coverage && dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=${{inputs.report_on}} - name: 📊 Check Code Coverage uses: VeryGoodOpenSource/very_good_coverage@v2 with: path: ./packages/serinus/coverage/lcov.info - min_coverage: 70 + min_coverage: 80 - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.website/.vitepress/config.mts b/.website/.vitepress/config.mts index 3694caee..38103cac 100644 --- a/.website/.vitepress/config.mts +++ b/.website/.vitepress/config.mts @@ -11,18 +11,20 @@ export default defineConfig({ ], lastUpdated: true, themeConfig: { + footer: { + copyright: 'Copyright © 2024 Francesco Vallone', + message: 'Built with 💙 and Dart 🎯 | One of the 🐤 of Serinus Nest' + }, // https://vitepress.dev/reference/default-theme-config logo: '/serinus-logo.png', nav: [ { text: 'pub.dev', link: 'https://pub.dev/packages/serinus' - } + }, ], sidebar: [ { - text: 'Serinus - Dart Backend Framework', - link: '/', items: [ { text: 'Introduction', link: '/introduction' }, { @@ -44,6 +46,15 @@ export default defineConfig({ { text: 'Model View Controller', link: '/techniques/mvc' }, { text: 'Versioning', link: '/techniques/versioning' }, ] + }, + { + text: 'Plugins', + items: [ + { text: 'Configuration', link: '/plugins/configuration' }, + { text: 'Serve Static Files [WIP]' }, + { text: 'Swagger [WIP]' } + ], + link: '/plugins/' } ] }, @@ -54,7 +65,8 @@ export default defineConfig({ ], socialLinks: [ { icon: 'github', link: 'https://github.com/francescovallone/serinus' }, - { icon: 'twitter', link: 'https://twitter.com/serinus_nest'} + { icon: 'twitter', link: 'https://twitter.com/serinus_nest'}, + { icon: 'discord', link: 'https://discord.gg/zydgnJ3ksJ' } ] } }) \ No newline at end of file diff --git a/.website/.vitepress/theme/style.css b/.website/.vitepress/theme/style.css index e1ca0654..37741b9b 100644 --- a/.website/.vitepress/theme/style.css +++ b/.website/.vitepress/theme/style.css @@ -92,7 +92,7 @@ } .image-bg { - background-image: linear-gradient(-45deg, hsla(36, 100%, 75%, 0.5) 50%, hsla(36, 100%, 50%, 0.5) 50%) !important; - background-image: -moz-linear-gradient(-45deg, hsla(36, 100%, 75%, 0.5) 50%, hsla(36, 100%, 50%, 0.5) 50%) !important; - background-image: -webkit-linear-gradient(-45deg, hsla(36, 100%, 75%, 0.5) 50%, hsla(36, 100%, 50%, 0.5) 50%) !important; + background-image: linear-gradient(-45deg, rgba(255, 152, 0, 0.6) 50%, rgba(245, 124, 0, 0.6) 50%) !important; + background-image: -moz-linear-gradient(-45deg, rgba(255, 152, 0, 0.6) 50%, rgba(245, 124, 0, 0.6) 50%) !important; + background-image: -webkit-linear-gradient(-45deg, rgba(255, 152, 0, 0.6) 50%, rgba(245, 124, 0, 0.6) 50%) !important; } \ No newline at end of file diff --git a/.website/overview/pipes.md b/.website/overview/pipes.md index 73a527db..9d5686f8 100644 --- a/.website/overview/pipes.md +++ b/.website/overview/pipes.md @@ -26,7 +26,7 @@ import 'package:serinus/serinus.dart'; class MyController extends Controller { MyController({super.path = '/'}) { - on(GetRoute(path: '/'), (context, request) { + on(GetRoute(path: '/'), (context) { return Response.text( data: 'Hello World!', ); diff --git a/.website/overview/providers.md b/.website/overview/providers.md index 14fce08a..e7eb1588 100644 --- a/.website/overview/providers.md +++ b/.website/overview/providers.md @@ -56,15 +56,14 @@ If you want to use a provider from a submodule, you must add the `Type` of the p import 'package:serinus/serinus.dart'; class MyController extends Controller { - MyController({super.path = '/'}); - - @override - Future handle(Request request) async { - final myProvider = context.get(); - return Response.text( - data: 'Hello World!', - ); + MyController({super.path = '/'}){ + on(GetRoute(path: '/'), (context) { + return Response.text( + data: context.use().myMethod(), + ); + }); } + } ``` diff --git a/.website/overview/routes.md b/.website/overview/routes.md index d7b76f95..01788e2e 100644 --- a/.website/overview/routes.md +++ b/.website/overview/routes.md @@ -8,6 +8,7 @@ They only exposes the endpoint and the method that the route will respond to so To add routes you first need to create a class that extends the `Route` class and then add it to the controller using the `on` method. ::: code-group + ```dart [my_controller.dart] import 'package:serinus/serinus.dart'; import 'my_routes.dart'; @@ -22,6 +23,7 @@ class MyController extends Controller { } } ``` + ```dart [my_routes.dart] import 'package:serinus/serinus.dart'; @@ -34,6 +36,7 @@ class GetRoute extends Route { } ``` + ::: ## Query Parameters @@ -61,6 +64,7 @@ class GetRoute extends Route { To define a path parameter you need to add the parameter name between `<` and `>` in the path of the route. ::: code-group + ```dart [my_controller.dart] import 'package:serinus/serinus.dart'; @@ -76,6 +80,7 @@ class MyController extends Controller { } } ``` + ```dart [my_routes.dart] import 'package:serinus/serinus.dart'; @@ -88,6 +93,7 @@ class GetRoute extends Route { } ``` + ::: ## Adding Pipes diff --git a/.website/plugins/configuration.md b/.website/plugins/configuration.md new file mode 100644 index 00000000..ac624e70 --- /dev/null +++ b/.website/plugins/configuration.md @@ -0,0 +1,45 @@ +# Config + +Serinus Config is a plugin that allows to load .env files in your Serinus application. + +::: warning +This plugin uses the [dotenv](https://pub.dev/packages/dotenv) package to load the .env files. +::: + +## Installation + +The installation of the plugin is immediate and can be done using the following command: + +```bash +dart pub add serinus_config +``` + +## Usage + +To use the plugin, you need to import it in your entrypoint module: + +```dart +import 'package:serinus_config/serinus_config.dart'; +import 'package:serinus/serinus.dart'; + +class AppModule extends Module { + + AppModule() : super(imports: [ConfigModule()]); + +} +``` + +### Options + +The plugin allows you to specify the path of the .env file to load using the `dotEnvPath` parameter. The default value is `.env`. + +```dart +import 'package:serinus_config/serinus_config.dart'; +import 'package:serinus/serinus.dart'; + +class AppModule extends Module { + + AppModule() : super(imports: [ConfigModule(dotEnvPath: '.env')]); + +} +``` diff --git a/.website/plugins/index.md b/.website/plugins/index.md new file mode 100644 index 00000000..76d71676 --- /dev/null +++ b/.website/plugins/index.md @@ -0,0 +1,7 @@ +# Plugins + +This is a list of plugins that are available for Serinus. If you would like to add your own plugin, please submit a pull request to the [Serinus GitHub repository](https://github.com/francescovallone/serinus). + +## Official Plugins + +- [Configuration](/plugins/configuration) diff --git a/.website/techniques/mvc.md b/.website/techniques/mvc.md index 0a03d4ae..ee212152 100644 --- a/.website/techniques/mvc.md +++ b/.website/techniques/mvc.md @@ -76,7 +76,7 @@ import 'package:serinus/serinus.dart'; class MyController extends Controller { MyController({super.path = '/'}) { - on(GetRoute(path: '/'), (context, request) { + on(GetRoute(path: '/'), (context) { // This refers to the view file `views/index.mustache` return Response.render(View(view: 'index', variables: {'name': 'Serinus'})); }); @@ -89,7 +89,7 @@ import 'package:serinus/serinus.dart'; class MyController extends Controller { MyController({super.path = '/'}) { - on(GetRoute(path: '/'), (context, request) { + on(GetRoute(path: '/'), (context) { return Response.renderString(ViewString(viewData: 'Hello {{name}}', variables: {'name': 'Serinus'})); }); } diff --git a/.website/techniques/versioning.md b/.website/techniques/versioning.md index 2c78fbff..5dbd85dc 100644 --- a/.website/techniques/versioning.md +++ b/.website/techniques/versioning.md @@ -77,7 +77,7 @@ class MyController extends Controller { int get version => 2; MyController({super.path = '/'}) { - on(GetRoute(path: '/'), (context, request) { + on(GetRoute(path: '/'), (context) { return Response.text('Hello, World!'); }); } diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..67fe8cee --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5475d264 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing to Serinus + +First off, thank you for considering contributing to Serinus. I'm glad that you want to help us grow this project. 🎉 + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [Francesco Vallone](mailto:vallonefrancesco587@gmail.com). + +## How Can I Contribute? + +### Reporting Bugs + +This section guides you through submitting a bug report for Serinus. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports. + +Before creating bug reports, please check the [issues list](https://github.com/francescovallone/serinus/issues) as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. Fill out the required template since the information it asks for helps us resolve issues faster. Please be aware that I'm a one-man team and I might not be able to address your issue immediately but I'll do my best to help you. + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Serinus, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions. + +Before creating enhancement suggestions, please check the [issues list](https://github.com/francescovallone/serinus/issues) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please include as many details as possible. Fill in the template, including the steps that you imagine you would take if the feature you're requesting existed. Please be aware that not all suggestions will be accepted because there are a lot of factors to consider but regardless I'll look into it and will try to find a solution. diff --git a/benchmarks/all_results.md b/benchmarks/all_results.md index 503f3abb..5f7a7ca5 100644 --- a/benchmarks/all_results.md +++ b/benchmarks/all_results.md @@ -1,6 +1,6 @@ | | Req/sec | Trans/sec | Req/sec DIFF | Avg Latency | |----------------|---------|-----------|-------------|-----------| - | dart_frog (no_cli) | 28649.98 | 7.08MB | +0.00% | 44.57 | - | shelf | 28770.78 | 7.00MB | +0.42% | 39.46 | - | serinus | 30169.86 | 6.24MB | +5.30% | 37.69 | - | pharaoh | 31479.41 | 4.32MB | +9.88% | 38.33 | \ No newline at end of file + | shelf | 29758.27 | 7.24MB | +0.00% | 39.4 | + | dart_frog (no_cli) | 29766.17 | 7.35MB | +0.03% | 42.08 | + | serinus | 31844.3 | 6.01MB | +7.01% | 36.07 | + | pharaoh | 32743.85 | 4.50MB | +10.03% | 37.62 | \ No newline at end of file diff --git a/benchmarks/angel3_result.md b/benchmarks/angel3_result.md new file mode 100644 index 00000000..7905de60 --- /dev/null +++ b/benchmarks/angel3_result.md @@ -0,0 +1,15 @@ +## angel3 +### Requests per second +Requests/sec: 21782.05 +Transfer/sec: 5.07MB +Requests/sec DIFF: +0.00% +### Latency +Avg Latency: 58.24 +Stdev Latency: 107.41 +Max Latency: 1.43 +Stdev Perc: 97.31 +### Req/Sec +Rps Avg: 2.74 +Rps Max: 3.23 +Rps Stdev: 405.3 +Rps Perc: 92.25 \ No newline at end of file diff --git a/benchmarks/bin/benchmarks.dart b/benchmarks/bin/benchmarks.dart index 3ed0be60..30a718b6 100644 --- a/benchmarks/bin/benchmarks.dart +++ b/benchmarks/bin/benchmarks.dart @@ -10,7 +10,7 @@ Future main(List arguments) async { results['serinus'] = await benchmarks.SerinusAppBenchmark().report(); // results['vania (no_cli)'] = await benchmarks.VaniaAppBenchmark().report(); results['pharaoh'] = await benchmarks.PharaohAppBenchmark().report(); - results['angel3'] = await benchmarks.Angel3AppBenchmark().report(); + // results['angel3'] = await benchmarks.Angel3AppBenchmark().report(); results['dart_frog (no_cli)'] = await benchmarks.DartFrogAppBenchmark().report(); await saveToFile(); diff --git a/benchmarks/dart_frog_result.md b/benchmarks/dart_frog_result.md index 15cc412d..05d77f76 100644 --- a/benchmarks/dart_frog_result.md +++ b/benchmarks/dart_frog_result.md @@ -1,15 +1,15 @@ ## dart_frog (no_cli) ### Requests per second -Requests/sec: 28649.98 -Transfer/sec: 7.08MB -Requests/sec DIFF: +0.00% +Requests/sec: 29766.17 +Transfer/sec: 7.35MB +Requests/sec DIFF: +0.03% ### Latency -Avg Latency: 44.57 -Stdev Latency: 81.17 -Max Latency: 1.12 -Stdev Perc: 97.66 +Avg Latency: 42.08 +Stdev Latency: 73.87 +Max Latency: 993.67 +Stdev Perc: 97.76 ### Req/Sec -Rps Avg: 3.6 -Rps Max: 4.77 -Rps Stdev: 477.96 -Rps Perc: 92.38 \ No newline at end of file +Rps Avg: 3.74 +Rps Max: 4.87 +Rps Stdev: 481.07 +Rps Perc: 95.0 \ No newline at end of file diff --git a/benchmarks/pharaoh_result.md b/benchmarks/pharaoh_result.md index 92f3741f..3c5df3df 100644 --- a/benchmarks/pharaoh_result.md +++ b/benchmarks/pharaoh_result.md @@ -1,15 +1,15 @@ ## pharaoh ### Requests per second -Requests/sec: 31479.41 -Transfer/sec: 4.32MB -Requests/sec DIFF: +9.88% +Requests/sec: 32743.85 +Transfer/sec: 4.50MB +Requests/sec DIFF: +10.03% ### Latency -Avg Latency: 38.33 -Stdev Latency: 62.71 -Max Latency: 964.26 -Stdev Perc: 98.04 +Avg Latency: 37.62 +Stdev Latency: 64.91 +Max Latency: 918.12 +Stdev Perc: 98.06 ### Req/Sec -Rps Avg: 3.96 -Rps Max: 4.45 -Rps Stdev: 528.26 -Rps Perc: 95.75 \ No newline at end of file +Rps Avg: 4.12 +Rps Max: 4.85 +Rps Stdev: 546.83 +Rps Perc: 95.38 \ No newline at end of file diff --git a/benchmarks/serinus_result.md b/benchmarks/serinus_result.md index 1386e5e9..ffc836a5 100644 --- a/benchmarks/serinus_result.md +++ b/benchmarks/serinus_result.md @@ -1,15 +1,15 @@ ## serinus ### Requests per second -Requests/sec: 30169.86 -Transfer/sec: 6.24MB -Requests/sec DIFF: +5.30% +Requests/sec: 31844.3 +Transfer/sec: 6.01MB +Requests/sec DIFF: +7.01% ### Latency -Avg Latency: 37.69 -Stdev Latency: 53.96 -Max Latency: 973.26 -Stdev Perc: 98.36 +Avg Latency: 36.07 +Stdev Latency: 52.8 +Max Latency: 941.4 +Stdev Perc: 98.01 ### Req/Sec -Rps Avg: 3.8 -Rps Max: 4.27 -Rps Stdev: 541.0 -Rps Perc: 94.12 \ No newline at end of file +Rps Avg: 4.01 +Rps Max: 4.46 +Rps Stdev: 556.36 +Rps Perc: 95.38 \ No newline at end of file diff --git a/benchmarks/shelf_result.md b/benchmarks/shelf_result.md index ad1f0c68..46542d68 100644 --- a/benchmarks/shelf_result.md +++ b/benchmarks/shelf_result.md @@ -1,15 +1,15 @@ ## shelf ### Requests per second -Requests/sec: 28770.78 -Transfer/sec: 7.00MB -Requests/sec DIFF: +0.42% +Requests/sec: 29758.27 +Transfer/sec: 7.24MB +Requests/sec DIFF: +0.00% ### Latency -Avg Latency: 39.46 -Stdev Latency: 60.49 -Max Latency: 1.03 -Stdev Perc: 97.98 +Avg Latency: 39.4 +Stdev Latency: 63.89 +Max Latency: 989.15 +Stdev Perc: 97.9 ### Req/Sec -Rps Avg: 3.62 -Rps Max: 4.22 -Rps Stdev: 618.21 -Rps Perc: 91.5 \ No newline at end of file +Rps Avg: 3.74 +Rps Max: 4.3 +Rps Stdev: 644.75 +Rps Perc: 92.75 \ No newline at end of file diff --git a/examples/ping_pong/pubspec.yaml b/examples/ping_pong/pubspec.yaml index 426baa01..e2f864f5 100644 --- a/examples/ping_pong/pubspec.yaml +++ b/examples/ping_pong/pubspec.yaml @@ -6,5 +6,5 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - serinus: 0.2.0 + serinus: 0.2.2 \ No newline at end of file diff --git a/packages/serinus/CHANGELOG.md b/packages/serinus/CHANGELOG.md index 173be645..08279560 100644 --- a/packages/serinus/CHANGELOG.md +++ b/packages/serinus/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.2 + +- Add enableCompression flag in the SerinusApplication +- Clean up code and add more tests +- Start documenting the code + ## 0.2.1 - Fix support for Serinus CLI run command environment variables diff --git a/packages/serinus/analysis_options.yaml b/packages/serinus/analysis_options.yaml index 9b348441..b2addb49 100644 --- a/packages/serinus/analysis_options.yaml +++ b/packages/serinus/analysis_options.yaml @@ -34,4 +34,6 @@ linter: - always_declare_return_types - always_put_control_body_on_new_line - always_put_required_named_parameters_first - - directives_ordering \ No newline at end of file + - directives_ordering + - prefer_relative_imports + - prefer_single_quotes \ No newline at end of file diff --git a/packages/serinus/bin/serinus.dart b/packages/serinus/bin/serinus.dart index 29c3e0f8..ae520d37 100644 --- a/packages/serinus/bin/serinus.dart +++ b/packages/serinus/bin/serinus.dart @@ -64,7 +64,7 @@ class GetRoute extends Route { int? get version => 2; @override - List get guards => [TestGuard()]; + List get guards => []; } class PostRoute extends Route { @@ -119,6 +119,9 @@ class AppModule extends Module { List get pipes => [ TestPipe(), ]; + + @override + List get guards => [TestGuard()]; } class TestPipe extends Pipe { diff --git a/packages/serinus/bin/serve_static/serve_static_controller.dart b/packages/serinus/bin/serve_static/serve_static_controller.dart index 43e3c79e..e340c140 100644 --- a/packages/serinus/bin/serve_static/serve_static_controller.dart +++ b/packages/serinus/bin/serve_static/serve_static_controller.dart @@ -28,7 +28,7 @@ class ServeStaticController extends Controller { // } // } if (!file.existsSync()) { - throw NotFoundException(message: "The file $path does not exist"); + throw NotFoundException(message: 'The file $path does not exist'); } // final byteSink = ByteAccumulatorSink(); // await file.openRead().listen(byteSink.add).asFuture(); diff --git a/packages/serinus/example/bin/echo.dart b/packages/serinus/example/bin/echo.dart index ef8c2a71..ebd9cf4c 100644 --- a/packages/serinus/example/bin/echo.dart +++ b/packages/serinus/example/bin/echo.dart @@ -1,4 +1,4 @@ -import 'package:echo/echo.dart'; +import 'package:echo/serinus.dart'; Future main(List arguments) async { await bootstrap(); diff --git a/packages/serinus/example/lib/echo.dart b/packages/serinus/example/lib/serinus.dart similarity index 100% rename from packages/serinus/example/lib/echo.dart rename to packages/serinus/example/lib/serinus.dart diff --git a/packages/serinus/example/pubspec.lock b/packages/serinus/example/pubspec.lock index b6e992a4..5abf08b1 100644 --- a/packages/serinus/example/pubspec.lock +++ b/packages/serinus/example/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: meta - sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.14.0" + version: "1.15.0" mime: dependency: transitive description: diff --git a/packages/serinus/example/pubspec.yaml b/packages/serinus/example/pubspec.yaml index 9be4bd44..3df77164 100644 --- a/packages/serinus/example/pubspec.yaml +++ b/packages/serinus/example/pubspec.yaml @@ -1,10 +1,10 @@ name: echo description: '' - +publish_to: none environment: sdk: '>=3.0.0 <4.0.0' dependencies: - serinus: + serinus: 0.2.2 \ No newline at end of file diff --git a/packages/serinus/lib/serinus.dart b/packages/serinus/lib/serinus.dart index d2de55e4..c85c2353 100644 --- a/packages/serinus/lib/serinus.dart +++ b/packages/serinus/lib/serinus.dart @@ -1,4 +1,14 @@ library serinus; -export 'src/commons/commons.dart'; +export 'src/adapters/serinus_http_server.dart'; +export 'src/adapters/server_adapter.dart'; +export 'src/containers/module_container.dart'; +export 'src/contexts/contexts.dart'; export 'src/core/core.dart'; +export 'src/enums/enums.dart'; +export 'src/errors/initialization_error.dart'; +export 'src/exceptions/exceptions.dart'; +export 'src/global_prefix.dart'; +export 'src/http/http.dart'; +export 'src/mixins/mixins.dart'; +export 'src/versioning.dart'; diff --git a/packages/serinus/lib/src/commons/adapters/serinus_http_server.dart b/packages/serinus/lib/src/adapters/serinus_http_server.dart similarity index 59% rename from packages/serinus/lib/src/commons/adapters/serinus_http_server.dart rename to packages/serinus/lib/src/adapters/serinus_http_server.dart index 577f2342..8699c14e 100644 --- a/packages/serinus/lib/src/commons/adapters/serinus_http_server.dart +++ b/packages/serinus/lib/src/adapters/serinus_http_server.dart @@ -1,9 +1,16 @@ import 'dart:io' as io; -import '../internal_request.dart'; +import '../http/internal_request.dart'; import 'server_adapter.dart'; class SerinusHttpServer extends HttpServerAdapter { + late final String host; + late final int port; + late final String poweredByHeader; + late final io.SecurityContext? securityContext; + bool get isSecure => securityContext != null; + bool _enableCompression = true; + factory SerinusHttpServer() { return _singleton; } @@ -17,12 +24,19 @@ class SerinusHttpServer extends HttpServerAdapter { {String host = 'localhost', int port = 3000, String poweredByHeader = 'Powered by Serinus', - io.SecurityContext? securityContext}) async { + io.SecurityContext? securityContext, + bool enableCompression = true}) async { if (securityContext == null) { - server = await io.HttpServer.bind(host, port); + server = await io.HttpServer.bind(host, port, shared: true); } else { - server = await io.HttpServer.bindSecure(host, port, securityContext); + server = await io.HttpServer.bindSecure(host, port, securityContext, + shared: true); } + this.host = host; + this.port = port; + this.poweredByHeader = poweredByHeader; + this.securityContext = securityContext; + _enableCompression = enableCompression; server?.defaultResponseHeaders.add('X-Powered-By', poweredByHeader); } @@ -35,10 +49,11 @@ class SerinusHttpServer extends HttpServerAdapter { Future listen(RequestCallback requestCallback, {ErrorHandler? errorHandler}) async { try { - server?.listen((req) async { + server?.autoCompress = _enableCompression; + server?.listen((req) { final request = InternalRequest.from(req, baseUrl: ''); final response = request.response; - await requestCallback.call(request, response); + requestCallback.call(request, response); }, onError: errorHandler); } catch (e) { if (errorHandler == null) { diff --git a/packages/serinus/lib/src/commons/adapters/server_adapter.dart b/packages/serinus/lib/src/adapters/server_adapter.dart similarity index 90% rename from packages/serinus/lib/src/commons/adapters/server_adapter.dart rename to packages/serinus/lib/src/adapters/server_adapter.dart index 79a21a7f..faf6ad67 100644 --- a/packages/serinus/lib/src/commons/adapters/server_adapter.dart +++ b/packages/serinus/lib/src/adapters/server_adapter.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import '../internal_request.dart'; -import '../internal_response.dart'; +import '../http/internal_request.dart'; +import '../http/internal_response.dart'; typedef RequestCallback = Future Function( InternalRequest request, InternalResponse response); diff --git a/packages/serinus/lib/src/commons/body.dart b/packages/serinus/lib/src/commons/body.dart deleted file mode 100644 index 1f6ddb5f..00000000 --- a/packages/serinus/lib/src/commons/body.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'dart:io'; - -import '../commons/form_data.dart'; - -class Body { - final FormData? formData; - final ContentType contentType; - final String? text; - final List? bytes; - final Map? json; - - Body(this.contentType, {this.formData, this.text, this.bytes, this.json}); -} diff --git a/packages/serinus/lib/src/commons/commons.dart b/packages/serinus/lib/src/commons/commons.dart deleted file mode 100644 index eb6a3ba7..00000000 --- a/packages/serinus/lib/src/commons/commons.dart +++ /dev/null @@ -1,18 +0,0 @@ -export 'adapters/serinus_http_server.dart'; -export 'adapters/server_adapter.dart'; -export 'body.dart'; -export 'cors.dart'; -export 'engines/view_engine.dart'; -export 'enums/http_method.dart'; -export 'enums/log_level.dart'; -export 'exceptions/exceptions.dart'; -export 'form_data.dart'; -export 'global_prefix.dart'; -export 'internal_response.dart'; -export 'mixins/application_mixins.dart'; -export 'mixins/object_mixins.dart'; -export 'request.dart'; -export 'response.dart'; -export 'services/logger_service.dart'; -export 'session.dart'; -export 'versioning.dart'; diff --git a/packages/serinus/lib/src/commons/extensions/content_type_extensions.dart b/packages/serinus/lib/src/commons/extensions/content_type_extensions.dart deleted file mode 100644 index 092769ac..00000000 --- a/packages/serinus/lib/src/commons/extensions/content_type_extensions.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'dart:io'; - -extension ContentTypeExtensions on ContentType { - bool isUrlEncoded() => subType == "x-www-form-urlencoded"; - bool isMultipart() => mimeType == "multipart/form-data"; -} diff --git a/packages/serinus/lib/src/commons/extensions/dynamic_extensions.dart b/packages/serinus/lib/src/commons/extensions/dynamic_extensions.dart deleted file mode 100644 index ada28608..00000000 --- a/packages/serinus/lib/src/commons/extensions/dynamic_extensions.dart +++ /dev/null @@ -1,31 +0,0 @@ -import '../extensions/string_extensions.dart'; - -extension JsonParsing on dynamic { - String toJsonString() { - final stringifiedObject = toString(); - if (stringifiedObject.isJson()) { - return stringifiedObject; - } - try { - return jsonEncode(this); - } catch (e) { - throw StateError("Error while parsing json"); - } - } - - Map convertMap() { - Map convertedMap = {}; - for (var key in this.keys) { - if (this[key] is Map) { - convertedMap[key.toString()] = this[key].convertMap(); - // }else if(this[key] is UploadedFile){ - // convertedMap[key.toString()] = this[key].toString(); - // }else if(this[key] is FormData){ - // convertedMap[key.toString()] = this[key].convertMap(); - } else { - convertedMap[key.toString()] = this[key]; - } - } - return Map.from(convertedMap); - } -} diff --git a/packages/serinus/lib/src/commons/extensions/uri_extensions.dart b/packages/serinus/lib/src/commons/extensions/uri_extensions.dart deleted file mode 100644 index d27bca9f..00000000 --- a/packages/serinus/lib/src/commons/extensions/uri_extensions.dart +++ /dev/null @@ -1,21 +0,0 @@ -extension CombineUriPath on Uri { - List combinePath() { - List pathSegments = this.pathSegments; - - if (pathSegments.isEmpty) { - return []; - } - - List combinedPath = []; - - for (int i = 0; i < pathSegments.length; i++) { - if (i == 0) { - combinedPath.add(pathSegments[i]); - } else { - combinedPath.add('${combinedPath[i - 1]}/${pathSegments[i]}'); - } - } - - return combinedPath; - } -} diff --git a/packages/serinus/lib/src/commons/global_prefix.dart b/packages/serinus/lib/src/commons/global_prefix.dart deleted file mode 100644 index 94067f9e..00000000 --- a/packages/serinus/lib/src/commons/global_prefix.dart +++ /dev/null @@ -1,5 +0,0 @@ -final class GlobalPrefix { - final String prefix; - - const GlobalPrefix({required this.prefix}); -} diff --git a/packages/serinus/lib/src/commons/mixins/application_mixins.dart b/packages/serinus/lib/src/commons/mixins/application_mixins.dart deleted file mode 100644 index 3c9202f8..00000000 --- a/packages/serinus/lib/src/commons/mixins/application_mixins.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:serinus/serinus.dart'; - -mixin OnApplicationInit on Provider { - Future onApplicationInit(); -} - -mixin OnApplicationShutdown on Provider { - Future onApplicationShutdown(); -} diff --git a/packages/serinus/lib/src/commons/mixins/object_mixins.dart b/packages/serinus/lib/src/commons/mixins/object_mixins.dart deleted file mode 100644 index 168ff2a3..00000000 --- a/packages/serinus/lib/src/commons/mixins/object_mixins.dart +++ /dev/null @@ -1,3 +0,0 @@ -mixin JsonObject { - Map toJson(); -} diff --git a/packages/serinus/lib/src/commons/response.dart b/packages/serinus/lib/src/commons/response.dart deleted file mode 100644 index e84cda59..00000000 --- a/packages/serinus/lib/src/commons/response.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:serinus/src/commons/engines/view_engine.dart'; -import 'package:serinus/src/commons/mixins/object_mixins.dart'; - -class Response { - final dynamic _value; - int statusCode; - final ContentType _contentType; - final bool _shouldRedirect; - - Response._(this._value, this.statusCode, this._contentType, - {bool shouldRedirect = false}) - : _shouldRedirect = shouldRedirect; - - dynamic get data => _value; - - ContentType get contentType => _contentType; - - bool get shouldRedirect => _shouldRedirect; - - final Map _headers = {}; - - Map get headers => _headers; - - factory Response.json(dynamic data, - {int statusCode = 200, ContentType? contentType}) { - dynamic responseData; - if (data is Map || data is List>) { - responseData = data; - } else if (data is JsonObject) { - responseData = data.toJson(); - } else { - throw FormatException( - 'The data must be a Map or a JsonSerializableMixin'); - } - return Response._( - jsonEncode(responseData), statusCode, contentType ?? ContentType.json); - } - - factory Response.html(String data, - {int statusCode = 200, ContentType? contentType}) { - return Response._(data, statusCode, contentType ?? ContentType.html); - } - - factory Response.render(View view, - {int statusCode = 200, ContentType? contentType}) { - return Response._(view, statusCode, contentType ?? ContentType.html); - } - - factory Response.renderString(ViewString view, - {int statusCode = 200, ContentType? contentType}) { - return Response._(view, statusCode, contentType ?? ContentType.html); - } - - factory Response.text(String data, - {int statusCode = 200, ContentType? contentType}) { - return Response._(data, statusCode, contentType ?? ContentType.text); - } - - factory Response.bytes(Uint8List data, - {int statusCode = 200, ContentType? contentType}) { - return Response._(data, statusCode, contentType ?? ContentType.binary); - } - - factory Response.file(File file, - {int statusCode = 200, ContentType? contentType}) { - return Response._( - file.readAsBytesSync(), statusCode, contentType ?? ContentType.binary); - } - - factory Response.redirect( - String path, { - int statusCode = 302, - }) { - return Response._(path, statusCode, ContentType.text, shouldRedirect: true); - } - - void addHeaders(Map headers) { - headers.forEach((key, value) { - _headers[key] = value; - }); - } -} diff --git a/packages/serinus/lib/src/commons/session.dart b/packages/serinus/lib/src/commons/session.dart deleted file mode 100644 index 232b2d6d..00000000 --- a/packages/serinus/lib/src/commons/session.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -class Session { - final HttpSession _original; - - Session(this._original); - - dynamic get(String key) { - return _original[key]; - } - - void put(String key, dynamic value) { - _original[key] = value; - } - - void remove(String key) { - _original.remove(key); - } - - String get id => _original.id; - - bool get isNew => _original.isNew; - - void destroy() { - _original.destroy(); - } -} diff --git a/packages/serinus/lib/src/core/consumers/consumer.dart b/packages/serinus/lib/src/consumers/consumer.dart similarity index 68% rename from packages/serinus/lib/src/core/consumers/consumer.dart rename to packages/serinus/lib/src/consumers/consumer.dart index 54cae61f..87195379 100644 --- a/packages/serinus/lib/src/core/consumers/consumer.dart +++ b/packages/serinus/lib/src/consumers/consumer.dart @@ -1,7 +1,10 @@ import 'dart:async'; -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/core/containers/router.dart'; +import '../containers/router.dart'; +import '../contexts/contexts.dart'; +import '../core/core.dart'; +import '../http/body.dart'; +import '../http/request.dart'; abstract class Consumer { Future consume(Iterable consumables); @@ -12,9 +15,10 @@ abstract class ExecutionContextConsumer extends Consumer { final RouteData routeData; final Iterable providers; final Body? body; + ExecutionContext? context; ExecutionContextConsumer(this.request, this.routeData, this.providers, - {this.body}); + {this.body, this.context}); @override Future consume(Iterable consumables); diff --git a/packages/serinus/lib/src/core/consumers/guards_consumer.dart b/packages/serinus/lib/src/consumers/guards_consumer.dart similarity index 57% rename from packages/serinus/lib/src/core/consumers/guards_consumer.dart rename to packages/serinus/lib/src/consumers/guards_consumer.dart index 52e5d6d2..d0c9b282 100644 --- a/packages/serinus/lib/src/core/consumers/guards_consumer.dart +++ b/packages/serinus/lib/src/consumers/guards_consumer.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/core/consumers/consumer.dart'; -import 'package:serinus/src/core/contexts/execution_context.dart'; +import '../contexts/execution_context.dart'; +import '../core/core.dart'; +import '../exceptions/exceptions.dart'; +import 'consumer.dart'; class GuardsConsumer extends ExecutionContextConsumer { - GuardsConsumer(super.request, super.routeData, super.providers, {super.body}); + GuardsConsumer(super.request, super.routeData, super.providers, + {super.body, super.context}); @override ExecutionContext createContext() { @@ -19,11 +21,13 @@ class GuardsConsumer extends ExecutionContextConsumer { @override Future consume(Iterable consumables) async { - final context = createContext(); + context ??= createContext(); for (final consumable in consumables) { - final canActivate = await consumable.canActivate(context); + final canActivate = await consumable.canActivate(context!); if (!canActivate) { - return canActivate; + throw ForbiddenException( + message: + '${consumable.runtimeType} block the access to the route ${request.path}'); } } return true; diff --git a/packages/serinus/lib/src/core/consumers/pipes_consumer.dart b/packages/serinus/lib/src/consumers/pipes_consumer.dart similarity index 66% rename from packages/serinus/lib/src/core/consumers/pipes_consumer.dart rename to packages/serinus/lib/src/consumers/pipes_consumer.dart index 87a90237..1f591024 100644 --- a/packages/serinus/lib/src/core/consumers/pipes_consumer.dart +++ b/packages/serinus/lib/src/consumers/pipes_consumer.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/core/consumers/consumer.dart'; -import 'package:serinus/src/core/contexts/execution_context.dart'; +import '../contexts/execution_context.dart'; +import '../core/core.dart'; +import 'consumer.dart'; class PipesConsumer extends ExecutionContextConsumer { - PipesConsumer(super.request, super.routeData, super.providers, {super.body}); + PipesConsumer(super.request, super.routeData, super.providers, + {super.body, super.context}); @override ExecutionContext createContext() { @@ -19,9 +20,9 @@ class PipesConsumer extends ExecutionContextConsumer { @override Future consume(Iterable consumables) async { - final context = createContext(); + context ??= createContext(); for (final consumable in consumables) { - await consumable.transform(context); + await consumable.transform(context!); } } } diff --git a/packages/serinus/lib/src/consumers/request_consumer.dart b/packages/serinus/lib/src/consumers/request_consumer.dart new file mode 100644 index 00000000..3366f716 --- /dev/null +++ b/packages/serinus/lib/src/consumers/request_consumer.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import '../containers/module_container.dart'; +import '../containers/router.dart'; +import '../contexts/request_context.dart'; +import '../core/core.dart'; +import '../enums/http_method.dart'; +import '../exceptions/exceptions.dart'; +import '../extensions/iterable_extansions.dart'; +import '../http/http.dart'; +import '../http/internal_request.dart'; +import 'guards_consumer.dart'; +import 'pipes_consumer.dart'; + +class RequestConsumer { + final Router router; + final ModulesContainer modulesContainer; + final ApplicationConfig config; + + const RequestConsumer(this.router, this.modulesContainer, this.config); + + /// Handles the request and sends the response + /// + /// This method is responsible for handling the request and sending the response. + /// It will get the route data from the [RoutesContainer] and then it will get the controller + /// from the [ModulesContainer]. Then it will get the route from the controller and execute the + /// route handler. It will also execute the middlewares and guards. + /// + /// Request lifecycle: + /// + /// 1. Incoming request + /// 2. [Middleware] execution + /// 3. [Guard] execution + /// 4. [Route] handler execution + /// 5. Outgoing response + Future handleRequest( + InternalRequest request, InternalResponse response) async { + if (request.method == 'OPTIONS') { + await config.cors?.call(request, Request(request), null, null); + return; + } + Response? result; + try { + final routeLookup = router.getRouteByPathAndMethod( + request.path.endsWith('/') + ? request.path.substring(0, request.path.length - 1) + : request.path, + request.method.toHttpMethod()); + final routeData = routeLookup.route; + if (routeData == null) { + throw NotFoundException( + message: + 'No route found for path ${request.path} and method ${request.method}'); + } + final controller = routeData.controller; + final routeSpec = + controller.get(routeData, config.versioningOptions?.version); + if (routeSpec == null) { + throw InternalServerErrorException( + message: 'Route spec not found for route ${routeData.path}'); + } + final route = routeSpec.route; + final handler = controller.routes[routeSpec]; + final injectables = + modulesContainer.getModuleInjectablesByToken(routeData.moduleToken); + final scopedProviders = (injectables.providers + .addAllIfAbsent(modulesContainer.globalProviders)); + final wrappedRequest = Request( + request, + params: routeLookup.params, + ); + await wrappedRequest.parseBody(); + final body = wrappedRequest.body!; + final context = _buildContext(scopedProviders, wrappedRequest, body); + await _executeMiddlewares( + context, + wrappedRequest, + response, + injectables.filterMiddlewaresByRoute( + routeData.path, wrappedRequest.params)); + final guardsConsumer = GuardsConsumer( + wrappedRequest, routeData, scopedProviders, + body: body); + if (injectables.guards.isNotEmpty) { + await guardsConsumer.consume([...injectables.guards]); + } + if (controller.guards.isNotEmpty) { + await guardsConsumer.consume([...controller.guards]); + } + if (route.guards.isNotEmpty) { + await guardsConsumer.consume([...route.guards]); + } + final pipesConsumer = PipesConsumer( + wrappedRequest, routeData, scopedProviders, + body: body, context: guardsConsumer.context); + if (injectables.pipes.isNotEmpty) { + await pipesConsumer.consume([...injectables.pipes]); + } + if (controller.pipes.isNotEmpty) { + await pipesConsumer.consume(controller.pipes); + } + if (route.pipes.isNotEmpty) { + await pipesConsumer.consume(route.pipes); + } + if (handler == null) { + throw InternalServerErrorException( + message: 'Route handler not found for route ${routeData.path}'); + } + if (config.cors != null) { + result = await config.cors?.call(request, wrappedRequest, context, + handler, config.cors?.allowedOrigins ?? ['*']); + } else { + result = await handler.call(context); + } + } on SerinusException catch (e) { + response.headers(config.cors?.responseHeaders ?? {}); + response.status(e.statusCode); + await response.send(e.toString()); + return; + } + if (result == null) { + throw InternalServerErrorException( + message: 'The route handler returned null'); + } + await response.finalize(result, viewEngine: config.viewEngine); + } + + RequestContext _buildContext( + Iterable providers, Request request, Body body) { + RequestContextBuilder builder = + RequestContextBuilder().addProviders(providers); + return builder.build(request)..body = body; + } + + Future _executeMiddlewares(RequestContext context, Request request, + InternalResponse response, Iterable middlewares) async { + if (middlewares.isEmpty) { + return; + } + final completer = Completer(); + for (int i = 0; i < middlewares.length; i++) { + final middleware = middlewares.elementAt(i); + await middleware.use(context, response, () async { + if (i == middlewares.length - 1) { + completer.complete(); + } + }); + } + return completer.future; + } +} diff --git a/packages/serinus/lib/src/core/containers/module_container.dart b/packages/serinus/lib/src/containers/module_container.dart similarity index 58% rename from packages/serinus/lib/src/core/containers/module_container.dart rename to packages/serinus/lib/src/containers/module_container.dart index 1a85f260..ddc8d96e 100644 --- a/packages/serinus/lib/src/core/containers/module_container.dart +++ b/packages/serinus/lib/src/containers/module_container.dart @@ -1,6 +1,12 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/errors/initialization_error.dart'; -import 'package:serinus/src/commons/extensions/iterable_extansions.dart'; +import 'package:collection/collection.dart'; + +import '../contexts/contexts.dart'; +import '../core/core.dart'; +import '../errors/initialization_error.dart'; +import '../extensions/iterable_extansions.dart'; + +import '../mixins/mixins.dart'; +import '../services/logger_service.dart'; /// A container for all the modules of the application /// @@ -29,6 +35,8 @@ final class ModulesContainer { /// The list of all the modules registered in the application List get modules => _modules.values.toList(); + final Map _moduleInjectables = {}; + /// The config of the application final ApplicationConfig config; @@ -42,7 +50,8 @@ final class ModulesContainer { /// The method registers the module in the application and initializes /// all the "eager" providers of the module and saves them in the [_providers] /// map. It also saves the deferred providers in the [_deferredProviders] map. - Future registerModule(Module module, Type entrypoint) async { + Future registerModule(Module module, Type entrypoint, + [ModuleInjectables? moduleInjectables]) async { final logger = Logger('InstanceLoader'); final token = module.token.isEmpty ? module.runtimeType.toString() : module.token; @@ -52,6 +61,17 @@ final class ModulesContainer { throw InitializationError('The entrypoint module cannot have exports'); } _modules[token] = initializedModule; + if (_moduleInjectables.containsKey(token)) { + _moduleInjectables[token] = + moduleInjectables!.concatTo(_moduleInjectables[token]); + } else { + _moduleInjectables[token] = moduleInjectables ?? + ModuleInjectables( + guards: {...module.guards}, + pipes: {...module.pipes}, + middlewares: {...module.middlewares}, + ); + } _providers[token] = []; for (final provider in initializedModule.providers .where((element) => element is! DeferredProvider)) { @@ -100,27 +120,38 @@ final class ModulesContainer { /// from the entrypoint module. It also registers all the submodules. /// /// It first initialize the "eager" submodules and then the deferred submodules. - Future registerModules(Module module, Type entrypoint) async { + Future registerModules(Module module, Type entrypoint, + [ModuleInjectables? moduleInjectables]) async { final eagerSubModules = module.imports.where((element) => element is! DeferredModule); final deferredSubModules = module.imports.whereType(); + final currentModuleInjectables = + _moduleInjectables[module.token] ??= ModuleInjectables( + guards: {...module.guards}, + pipes: {...module.pipes}, + middlewares: {...module.middlewares}, + ); for (var subModule in eagerSubModules) { - subModule.middlewares.addAllIfAbsent(module.middlewares); - subModule.guards.addAllIfAbsent(module.guards); - subModule.pipes.addAllIfAbsent(module.pipes); - await _callForRecursiveRegistration(subModule, module, entrypoint); + await _callForRecursiveRegistration( + subModule, module, entrypoint, currentModuleInjectables); + if (_moduleInjectables.containsKey(subModule.token)) { + _moduleInjectables[subModule.token] = currentModuleInjectables + .concatTo(_moduleInjectables[subModule.token]); + } } for (var deferredModule in deferredSubModules) { final subModule = await deferredModule .init(_getApplicationContext(deferredModule.inject)); - subModule.middlewares.addAllIfAbsent(module.middlewares); - subModule.guards.addAllIfAbsent(module.guards); - subModule.pipes.addAllIfAbsent(module.pipes); - await _callForRecursiveRegistration(subModule, module, entrypoint); + await _callForRecursiveRegistration( + subModule, module, entrypoint, currentModuleInjectables); + if (_moduleInjectables.containsKey(subModule.token)) { + _moduleInjectables[subModule.token] = currentModuleInjectables + .concatTo(_moduleInjectables[subModule.token]); + } module.imports.remove(deferredModule); module.imports.add(subModule); } - await registerModule(module, entrypoint); + await registerModule(module, entrypoint, _moduleInjectables[module.token]); } /// Calls the recursive registration of the submodules @@ -132,16 +163,17 @@ final class ModulesContainer { /// The method calls the recursive registration of the submodules /// /// Throws a [StateError] if a module tries to import itself - Future _callForRecursiveRegistration( - Module subModule, Module module, Type entrypoint) async { + Future _callForRecursiveRegistration(Module subModule, Module module, + Type entrypoint, ModuleInjectables moduleInjectables) async { if (subModule.runtimeType == module.runtimeType) { throw InitializationError('A module cannot import itself'); } - await registerModules(subModule, entrypoint); + + await registerModules(subModule, entrypoint, moduleInjectables); } /// Finalizes the registration of the deferred providers - Future finalize() async { + Future finalize(Module entrypoint) async { for (final entry in _deferredProviders.entries) { final token = entry.key; final providers = entry.value; @@ -161,6 +193,24 @@ final class ModulesContainer { 'All the exported providers must be registered in the module'); } } + _moduleInjectables[entrypoint.token] = + _moduleInjectables[entrypoint.token]!.copyWith( + providers: getModuleScopedProviders(entrypoint), + ); + } + + Set getModuleScopedProviders(Module module) { + Set providers = Set.from(module.providers); + for (final subModule in module.imports) { + final providersInjectable = subModule.exportedProviders + .addAllIfAbsent(getModuleScopedProviders(subModule)); + providers.addAll(providersInjectable); + _moduleInjectables[subModule.token] = + _moduleInjectables[subModule.token]!.copyWith( + providers: {...providersInjectable}, + ); + } + return providers; } /// Initializes a provider if it is not registered otherwise throws a [InitializationError] @@ -188,6 +238,14 @@ final class ModulesContainer { return module; } + ModuleInjectables getModuleInjectablesByToken(String token) { + ModuleInjectables? moduleInjectables = _moduleInjectables[token]; + if (moduleInjectables == null) { + throw ArgumentError('Module with token $token not found'); + } + return moduleInjectables; + } + /// Gets the parents of a module List getParents(Module module) { final parents = []; @@ -207,3 +265,69 @@ final class ModulesContainer { as T?; } } + +class ModuleInjectables { + final Set guards; + final Set pipes; + final Set middlewares; + final Set providers; + + ModuleInjectables({ + required this.guards, + required this.pipes, + required this.middlewares, + this.providers = const {}, + }); + + ModuleInjectables concatTo(ModuleInjectables? moduleInjectables) { + return ModuleInjectables( + guards: guards..addAllIfAbsent(moduleInjectables?.guards ?? {}), + pipes: pipes..addAllIfAbsent(moduleInjectables?.pipes ?? {}), + middlewares: middlewares + ..addAllIfAbsent(moduleInjectables?.middlewares ?? {}), + providers: providers..addAllIfAbsent(moduleInjectables?.providers ?? {}), + ); + } + + ModuleInjectables copyWith({ + Set? guards, + Set? pipes, + Set? middlewares, + Set? providers, + }) { + return ModuleInjectables( + guards: guards ?? this.guards, + pipes: pipes ?? this.pipes, + middlewares: middlewares ?? this.middlewares, + providers: providers ?? this.providers, + ); + } + + Set filterMiddlewaresByRoute( + String path, Map params) { + Set executedMiddlewares = {}; + for (Middleware middleware in middlewares) { + for (final route in middleware.routes) { + final segments = route.split('/'); + final routeSegments = path.split('/'); + if (routeSegments.length > segments.length && segments.last == '*') { + executedMiddlewares.add(middleware); + } + if (routeSegments.length == segments.length) { + bool match = true; + for (int i = 0; i < segments.length; i++) { + if (segments[i] != routeSegments[i] && + segments[i] != '*' && + params.isEmpty) { + match = false; + } + } + if (match) { + executedMiddlewares.add(middleware); + } + } + } + } + return executedMiddlewares; + } +} diff --git a/packages/serinus/lib/src/core/containers/router.dart b/packages/serinus/lib/src/containers/router.dart similarity index 93% rename from packages/serinus/lib/src/core/containers/router.dart rename to packages/serinus/lib/src/containers/router.dart index 18f54e1e..0e0bd3c9 100644 --- a/packages/serinus/lib/src/core/containers/router.dart +++ b/packages/serinus/lib/src/containers/router.dart @@ -1,6 +1,9 @@ -import 'package:serinus/serinus.dart'; import 'package:spanner/spanner.dart'; +import '../core/controller.dart'; +import '../enums/http_method.dart'; +import '../versioning.dart'; + class Router { final VersioningOptions? versioningOptions; diff --git a/packages/serinus/lib/src/core/contexts/application_context.dart b/packages/serinus/lib/src/contexts/application_context.dart similarity index 91% rename from packages/serinus/lib/src/core/contexts/application_context.dart rename to packages/serinus/lib/src/contexts/application_context.dart index d296a29b..7ca24afa 100644 --- a/packages/serinus/lib/src/core/contexts/application_context.dart +++ b/packages/serinus/lib/src/contexts/application_context.dart @@ -1,4 +1,4 @@ -import 'package:serinus/serinus.dart'; +import '../core/core.dart'; class ApplicationContext { final Map providers; diff --git a/packages/serinus/lib/src/core/contexts/contexts.dart b/packages/serinus/lib/src/contexts/contexts.dart similarity index 100% rename from packages/serinus/lib/src/core/contexts/contexts.dart rename to packages/serinus/lib/src/contexts/contexts.dart diff --git a/packages/serinus/lib/src/core/contexts/execution_context.dart b/packages/serinus/lib/src/contexts/execution_context.dart similarity index 95% rename from packages/serinus/lib/src/core/contexts/execution_context.dart rename to packages/serinus/lib/src/contexts/execution_context.dart index c0d5b643..cd9caf0d 100644 --- a/packages/serinus/lib/src/core/contexts/execution_context.dart +++ b/packages/serinus/lib/src/contexts/execution_context.dart @@ -1,4 +1,5 @@ -import 'package:serinus/serinus.dart'; +import '../core/core.dart'; +import '../http/http.dart'; sealed class ExecutionContext { final Map providers; diff --git a/packages/serinus/lib/src/core/contexts/request_context.dart b/packages/serinus/lib/src/contexts/request_context.dart similarity index 96% rename from packages/serinus/lib/src/core/contexts/request_context.dart rename to packages/serinus/lib/src/contexts/request_context.dart index 4562d733..ebf75d0e 100644 --- a/packages/serinus/lib/src/core/contexts/request_context.dart +++ b/packages/serinus/lib/src/contexts/request_context.dart @@ -1,4 +1,5 @@ -import 'package:serinus/serinus.dart'; +import '../core/core.dart'; +import '../http/http.dart'; sealed class RequestContext { final Map providers; diff --git a/packages/serinus/lib/src/core/application.dart b/packages/serinus/lib/src/core/application.dart index b59767d3..5104c774 100644 --- a/packages/serinus/lib/src/core/application.dart +++ b/packages/serinus/lib/src/core/application.dart @@ -1,14 +1,22 @@ import 'dart:io'; import 'package:meta/meta.dart'; -import 'package:serinus/src/commons/commons.dart'; -import 'package:serinus/src/commons/errors/initialization_error.dart'; -import 'package:serinus/src/commons/extensions/iterable_extansions.dart'; -import 'package:serinus/src/core/consumers/request_handler.dart'; -import 'package:serinus/src/core/containers/module_container.dart'; -import 'package:serinus/src/core/containers/router.dart'; -import 'package:serinus/src/core/core.dart'; -import 'package:serinus/src/core/injector/explorer.dart'; + +import '../adapters/serinus_http_server.dart'; +import '../consumers/request_consumer.dart'; +import '../containers/module_container.dart'; +import '../containers/router.dart'; +import '../engines/view_engine.dart'; +import '../enums/enums.dart'; +import '../errors/initialization_error.dart'; +import '../extensions/iterable_extansions.dart'; +import '../global_prefix.dart'; +import '../http/http.dart'; +import '../injector/explorer.dart'; +import '../mixins/mixins.dart'; +import '../services/logger_service.dart'; +import '../versioning.dart'; +import 'core.dart'; sealed class Application { final LogLevel level; @@ -32,10 +40,6 @@ sealed class Application { String get url; - T? get() { - return modulesContainer.get(); - } - HttpServer get server => config.serverAdapter.server; SerinusHttpServer get adapter => config.serverAdapter as SerinusHttpServer; @@ -56,8 +60,6 @@ sealed class Application { @internal Future shutdown(); - Future preview(); - Future serve(); Future close(); @@ -94,26 +96,15 @@ class SerinusApplication extends Application { config.globalPrefix = prefix; } - @override - Future preview() async { - final explorer = Explorer(modulesContainer, router, config); - explorer.resolveRoutes(); - } - @override Future serve() async { - await preview(); + await initialize(); try { - _logger.info("Starting server on $url"); - final handler = RequestHandler(router, modulesContainer, config); + _logger.info('Starting server on $url'); + final handler = RequestConsumer(router, modulesContainer, config); await config.serverAdapter.listen( - (request, response) async { - await handler.handleRequest(request, response); - }, - errorHandler: (e, stackTrace) { - print(e); - print(stackTrace); - }, + handler.handleRequest, + errorHandler: (e, stackTrace) => _logger.severe(e, stackTrace), ); } on SocketException catch (_) { _logger.severe('Failed to start server on $url'); @@ -134,7 +125,9 @@ class SerinusApplication extends Application { 'The entry point of the application cannot be a DeferredModule'); } await modulesContainer.registerModules(entrypoint, entrypoint.runtimeType); - await modulesContainer.finalize(); + final explorer = Explorer(modulesContainer, router, config); + explorer.resolveRoutes(); + await modulesContainer.finalize(entrypoint); } @override diff --git a/packages/serinus/lib/src/core/application_config.dart b/packages/serinus/lib/src/core/application_config.dart index 9bae3a2a..c40160e7 100644 --- a/packages/serinus/lib/src/core/application_config.dart +++ b/packages/serinus/lib/src/core/application_config.dart @@ -1,8 +1,13 @@ import 'dart:io'; -import 'package:serinus/serinus.dart'; import 'package:uuid/v4.dart'; +import '../adapters/server_adapter.dart'; +import '../engines/view_engine.dart'; +import '../global_prefix.dart'; +import '../http/cors.dart'; +import '../versioning.dart'; + /// The configuration for the application /// This is used to configure the application final class ApplicationConfig { diff --git a/packages/serinus/lib/src/core/consumers/request_handler.dart b/packages/serinus/lib/src/core/consumers/request_handler.dart deleted file mode 100644 index ab10e8f1..00000000 --- a/packages/serinus/lib/src/core/consumers/request_handler.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:async'; - -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/internal_request.dart'; -import 'package:serinus/src/core/consumers/guards_consumer.dart'; -import 'package:serinus/src/core/consumers/pipes_consumer.dart'; -import 'package:serinus/src/core/containers/module_container.dart'; -import 'package:serinus/src/core/containers/router.dart'; -import 'package:serinus/src/core/contexts/request_context.dart'; - -class RequestHandler { - final Router router; - final ModulesContainer modulesContainer; - final ApplicationConfig config; - - const RequestHandler(this.router, this.modulesContainer, this.config); - - /// Handles the request and sends the response - /// - /// This method is responsible for handling the request and sending the response. - /// It will get the route data from the [RoutesContainer] and then it will get the controller - /// from the [ModulesContainer]. Then it will get the route from the controller and execute the - /// route handler. It will also execute the middlewares and guards. - /// - /// Request lifecycle: - /// - /// 1. Incoming request - /// 2. [Middleware] execution - /// 3. [Guard] execution - /// 4. [Route] handler execution - /// 5. Outgoing response - Future handleRequest( - InternalRequest request, InternalResponse response) async { - if (request.method == 'OPTIONS') { - await config.cors?.call(request, Request(request), null, null); - return; - } - Response? result; - try { - final routeLookup = router.getRouteByPathAndMethod( - request.path.endsWith('/') - ? request.path.substring(0, request.path.length - 1) - : request.path, - request.method.toHttpMethod()); - final routeData = routeLookup.route; - if (routeData == null) { - throw NotFoundException( - message: - 'No route found for path ${request.path} and method ${request.method}'); - } - final controller = routeData.controller; - final routeSpec = - controller.get(routeData, config.versioningOptions?.version); - if (routeSpec == null) { - throw InternalServerErrorException( - message: 'Route spec not found for route ${routeData.path}'); - } - final route = routeSpec.route; - final handler = controller.routes[routeSpec]; - Module module = modulesContainer.getModuleByToken(routeData.moduleToken); - final scopedProviders = (_recursiveGetProviders(module) - ..addAll(modulesContainer.globalProviders)); - final wrappedRequest = Request( - request, - params: routeLookup.params, - ); - await wrappedRequest.parseBody(); - final body = wrappedRequest.body!; - final context = _buildContext(scopedProviders, wrappedRequest, body); - await _executeMiddlewares(context, routeData, wrappedRequest, response, - module, routeLookup.params); - final moduleGuards = _recursiveGetModuleGuards(module, routeData); - final guardsConsumer = GuardsConsumer( - wrappedRequest, routeData, scopedProviders, - body: body); - if (moduleGuards.isNotEmpty) { - await _executeGuards(guardsConsumer, moduleGuards, wrappedRequest); - } - if (controller.guards.isNotEmpty) { - await _executeGuards(guardsConsumer, controller.guards, wrappedRequest); - } - if (route.guards.isNotEmpty) { - await _executeGuards(guardsConsumer, route.guards, wrappedRequest); - } - final pipesConsumer = - PipesConsumer(wrappedRequest, routeData, scopedProviders, body: body); - final modelPipes = _recursiveGetModulePipes(module, routeData); - if (modelPipes.isNotEmpty) { - await pipesConsumer.consume([...modelPipes]); - } - if (controller.pipes.isNotEmpty) { - await pipesConsumer.consume(controller.pipes); - } - if (route.pipes.isNotEmpty) { - await pipesConsumer.consume(route.pipes); - } - if (handler == null) { - throw InternalServerErrorException( - message: 'Route handler not found for route ${routeData.path}'); - } - if (config.cors != null) { - result = await config.cors?.call(request, wrappedRequest, context, - handler, config.cors?.allowedOrigins ?? ['*']); - } else { - result = await handler.call(context); - } - } on SerinusException catch (e) { - response.headers(config.cors?.responseHeaders ?? {}); - response.status(e.statusCode); - await response.send(e.toString()); - return; - } - if (result == null) { - throw InternalServerErrorException( - message: 'Route handler did not return a response'); - } - await response.finalize(result, viewEngine: config.viewEngine); - } - - Future _executeGuards( - GuardsConsumer consumer, Iterable guards, Request request) async { - final canActivate = - await consumer.consume(Set.from(guards).toList()); - if (!canActivate) { - throw ForbiddenException( - message: 'You are not allowed to access the route ${request.path}'); - } - } - - RequestContext _buildContext( - Iterable providers, Request request, Body body) { - RequestContextBuilder builder = - RequestContextBuilder().addProviders(providers); - return builder.build(request)..body = body; - } - - Set _recursiveGetProviders(Module module) { - Set providers = Set.from(module.providers); - for (final subModule in module.imports) { - providers.addAll(subModule.exportedProviders - ..addAll(_recursiveGetProviders(subModule))); - } - return providers; - } - - Set _recursiveGetMiddlewares( - Module module, RouteData routeData, Map params) { - Set middlewares = Set.from(module.middlewares); - Set executedMiddlewares = {}; - for (Middleware middleware in middlewares) { - for (final route in middleware.routes) { - final segments = route.split('/'); - final routeSegments = routeData.path.split('/'); - if (routeSegments.length > segments.length && segments.last == '*') { - executedMiddlewares.add(middleware); - } - if (routeSegments.length == segments.length) { - bool match = true; - for (int i = 0; i < segments.length; i++) { - if (segments[i] != routeSegments[i] && - segments[i] != '*' && - params.isEmpty) { - match = false; - } - } - if (match) { - executedMiddlewares.add(middleware); - } - } - } - } - return executedMiddlewares; - } - - Future _executeMiddlewares( - RequestContext context, - RouteData routeData, - Request request, - InternalResponse response, - Module module, - Map params) async { - final middlewares = _recursiveGetMiddlewares(module, routeData, params); - if (middlewares.isEmpty) { - return; - } - final completer = Completer(); - for (int i = 0; i < middlewares.length; i++) { - final middleware = middlewares.elementAt(i); - await middleware.use(context, response, () async { - if (i == middlewares.length - 1) { - completer.complete(); - } - }); - } - return completer.future; - } - - Set _recursiveGetModuleGuards(Module module, RouteData routeData) { - Set guards = Set.from(module.guards); - final parents = modulesContainer.getParents(module); - for (final parent in parents) { - guards.addAll( - parent.guards..addAll(_recursiveGetModuleGuards(parent, routeData))); - } - return guards; - } - - Set _recursiveGetModulePipes(Module module, RouteData routeData) { - Set pipes = Set.from(module.pipes); - final parents = modulesContainer.getParents(module); - for (final parent in parents) { - pipes.addAll( - parent.pipes..addAll(_recursiveGetModulePipes(parent, routeData))); - } - return pipes; - } -} diff --git a/packages/serinus/lib/src/core/controller.dart b/packages/serinus/lib/src/core/controller.dart index b7947f3e..37beddb3 100644 --- a/packages/serinus/lib/src/core/controller.dart +++ b/packages/serinus/lib/src/core/controller.dart @@ -1,10 +1,13 @@ import 'dart:async'; -import 'dart:collection'; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/extensions/iterable_extansions.dart'; -import 'package:serinus/src/core/containers/router.dart'; + +import '../containers/router.dart'; +import '../contexts/request_context.dart'; +import '../enums/http_method.dart'; +import '../http/response.dart'; +import 'core.dart'; typedef ReqResHandler = Future Function(RequestContext context); diff --git a/packages/serinus/lib/src/core/core.dart b/packages/serinus/lib/src/core/core.dart index 1fcdaa2f..8909c06c 100644 --- a/packages/serinus/lib/src/core/core.dart +++ b/packages/serinus/lib/src/core/core.dart @@ -1,6 +1,5 @@ export 'application.dart'; export 'application_config.dart'; -export 'contexts/contexts.dart'; export 'controller.dart'; export 'factory.dart' hide SerinusFactory; export 'guard.dart'; diff --git a/packages/serinus/lib/src/core/factory.dart b/packages/serinus/lib/src/core/factory.dart index 2d341e35..801a47a3 100644 --- a/packages/serinus/lib/src/core/factory.dart +++ b/packages/serinus/lib/src/core/factory.dart @@ -1,6 +1,9 @@ import 'dart:io'; -import 'package:serinus/serinus.dart'; +import '../adapters/serinus_http_server.dart'; +import '../enums/log_level.dart'; +import '../services/logger_service.dart'; +import 'core.dart'; class SerinusFactory { const SerinusFactory(); @@ -12,7 +15,8 @@ class SerinusFactory { LogLevel loggingLevel = LogLevel.debug, LoggerService? loggerService, String poweredByHeader = 'Powered by Serinus', - SecurityContext? securityContext}) async { + SecurityContext? securityContext, + bool enableCompression = true}) async { final server = SerinusHttpServer(); final serverPort = int.tryParse(Platform.environment['PORT'] ?? '') ?? port; final serverHost = Platform.environment['HOST'] ?? host; @@ -20,7 +24,8 @@ class SerinusFactory { securityContext: securityContext, poweredByHeader: poweredByHeader, port: serverPort, - host: serverHost); + host: serverHost, + enableCompression: enableCompression); final app = SerinusApplication( entrypoint: entrypoint, config: ApplicationConfig( @@ -31,7 +36,6 @@ class SerinusFactory { serverAdapter: server), level: loggingLevel, loggerService: loggerService); - await app.initialize(); return app; } } diff --git a/packages/serinus/lib/src/core/guard.dart b/packages/serinus/lib/src/core/guard.dart index 9019a412..f5ba06a6 100644 --- a/packages/serinus/lib/src/core/guard.dart +++ b/packages/serinus/lib/src/core/guard.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'contexts/execution_context.dart'; +import '../contexts/execution_context.dart'; abstract class Guard { const Guard(); diff --git a/packages/serinus/lib/src/core/middleware.dart b/packages/serinus/lib/src/core/middleware.dart index ca2ad9e3..3d4a4ba6 100644 --- a/packages/serinus/lib/src/core/middleware.dart +++ b/packages/serinus/lib/src/core/middleware.dart @@ -1,4 +1,5 @@ -import 'package:serinus/serinus.dart'; +import '../contexts/request_context.dart'; +import '../http/http.dart'; typedef NextFunction = Future Function(); diff --git a/packages/serinus/lib/src/core/module.dart b/packages/serinus/lib/src/core/module.dart index 8f2a3696..78dd2da7 100644 --- a/packages/serinus/lib/src/core/module.dart +++ b/packages/serinus/lib/src/core/module.dart @@ -1,4 +1,5 @@ -import 'package:serinus/serinus.dart'; +import '../contexts/contexts.dart'; +import 'core.dart'; abstract class Module { final String token; diff --git a/packages/serinus/lib/src/core/pipe.dart b/packages/serinus/lib/src/core/pipe.dart index 38d45d6d..acffcb7e 100644 --- a/packages/serinus/lib/src/core/pipe.dart +++ b/packages/serinus/lib/src/core/pipe.dart @@ -1,6 +1,4 @@ -import 'dart:async'; - -import 'package:serinus/serinus.dart'; +import '../contexts/execution_context.dart'; abstract class Pipe { Future transform(ExecutionContext context); diff --git a/packages/serinus/lib/src/core/provider.dart b/packages/serinus/lib/src/core/provider.dart index d9567cba..fa239736 100644 --- a/packages/serinus/lib/src/core/provider.dart +++ b/packages/serinus/lib/src/core/provider.dart @@ -1,4 +1,4 @@ -import 'package:serinus/src/core/contexts/application_context.dart'; +import '../contexts/contexts.dart'; abstract class Provider { final bool isGlobal; diff --git a/packages/serinus/lib/src/core/route.dart b/packages/serinus/lib/src/core/route.dart index 5d343807..31d18060 100644 --- a/packages/serinus/lib/src/core/route.dart +++ b/packages/serinus/lib/src/core/route.dart @@ -1,7 +1,6 @@ -import 'package:serinus/src/core/pipe.dart' as p; - -import '../commons/commons.dart'; +import '../enums/http_method.dart'; import 'guard.dart'; +import 'pipe.dart'; abstract class Route { final String path; @@ -9,7 +8,7 @@ abstract class Route { final Map queryParameters; List get guards => []; - List get pipes => []; + List get pipes => []; int? get version => null; diff --git a/packages/serinus/lib/src/commons/engines/view_engine.dart b/packages/serinus/lib/src/engines/view_engine.dart similarity index 100% rename from packages/serinus/lib/src/commons/engines/view_engine.dart rename to packages/serinus/lib/src/engines/view_engine.dart diff --git a/packages/serinus/lib/src/enums/enums.dart b/packages/serinus/lib/src/enums/enums.dart new file mode 100644 index 00000000..c606e6f8 --- /dev/null +++ b/packages/serinus/lib/src/enums/enums.dart @@ -0,0 +1,3 @@ +export 'http_method.dart'; +export 'log_level.dart'; +export 'versioning_type.dart'; diff --git a/packages/serinus/lib/src/commons/enums/http_method.dart b/packages/serinus/lib/src/enums/http_method.dart similarity index 100% rename from packages/serinus/lib/src/commons/enums/http_method.dart rename to packages/serinus/lib/src/enums/http_method.dart diff --git a/packages/serinus/lib/src/commons/enums/log_level.dart b/packages/serinus/lib/src/enums/log_level.dart similarity index 100% rename from packages/serinus/lib/src/commons/enums/log_level.dart rename to packages/serinus/lib/src/enums/log_level.dart diff --git a/packages/serinus/lib/src/enums/versioning_type.dart b/packages/serinus/lib/src/enums/versioning_type.dart new file mode 100644 index 00000000..0914f3a0 --- /dev/null +++ b/packages/serinus/lib/src/enums/versioning_type.dart @@ -0,0 +1,4 @@ +enum VersioningType { + header, + uri, +} diff --git a/packages/serinus/lib/src/commons/errors/initialization_error.dart b/packages/serinus/lib/src/errors/initialization_error.dart similarity index 83% rename from packages/serinus/lib/src/commons/errors/initialization_error.dart rename to packages/serinus/lib/src/errors/initialization_error.dart index e0998895..b0e4cac9 100644 --- a/packages/serinus/lib/src/commons/errors/initialization_error.dart +++ b/packages/serinus/lib/src/errors/initialization_error.dart @@ -10,8 +10,8 @@ class InitializationError extends Error { @override String toString() { if (message != null) { - return "Initialization failed: $message"; + return 'Initialization failed: $message'; } - return "Initialization failed"; + return 'Initialization failed'; } } diff --git a/packages/serinus/lib/src/commons/exceptions/built_in_exceptions.dart b/packages/serinus/lib/src/exceptions/built_in_exceptions.dart similarity index 88% rename from packages/serinus/lib/src/commons/exceptions/built_in_exceptions.dart rename to packages/serinus/lib/src/exceptions/built_in_exceptions.dart index e7e9a90b..dd05ada9 100644 --- a/packages/serinus/lib/src/commons/exceptions/built_in_exceptions.dart +++ b/packages/serinus/lib/src/exceptions/built_in_exceptions.dart @@ -13,7 +13,7 @@ import 'exceptions.dart'; /// /// The [statusCode] is 502 class BadGatewayException extends SerinusException { - const BadGatewayException({super.message = "Bad Gateway!", super.uri}) + const BadGatewayException({super.message = 'Bad Gateway!', super.uri}) : super(statusCode: 502); } @@ -30,7 +30,7 @@ class BadGatewayException extends SerinusException { /// /// The [statusCode] is 400 class BadRequestException extends SerinusException { - const BadRequestException({super.message = "Bad Request!", super.uri}) + const BadRequestException({super.message = 'Bad Request!', super.uri}) : super(statusCode: 400); } @@ -48,7 +48,7 @@ class BadRequestException extends SerinusException { /// /// The [statusCode] is 409 class ConflictException extends SerinusException { - const ConflictException({super.message = "Conflict!", super.uri}) + const ConflictException({super.message = 'Conflict!', super.uri}) : super(statusCode: 409); } @@ -65,7 +65,7 @@ class ConflictException extends SerinusException { /// /// The [statusCode] is 403 class ForbiddenException extends SerinusException { - const ForbiddenException({super.message = "Forbidden!", super.uri}) + const ForbiddenException({super.message = 'Forbidden!', super.uri}) : super(statusCode: 403); } @@ -82,7 +82,7 @@ class ForbiddenException extends SerinusException { /// /// The [statusCode] is 504 class GatewayTimeoutException extends SerinusException { - const GatewayTimeoutException({super.message = "Gateway Timeout!", super.uri}) + const GatewayTimeoutException({super.message = 'Gateway Timeout!', super.uri}) : super(statusCode: 504); } @@ -99,7 +99,7 @@ class GatewayTimeoutException extends SerinusException { /// /// The [statusCode] is 410 class GoneException extends SerinusException { - const GoneException({super.message = "Gone!", super.uri}) + const GoneException({super.message = 'Gone!', super.uri}) : super(statusCode: 410); } @@ -117,7 +117,7 @@ class GoneException extends SerinusException { /// The [statusCode] is 505 class HttpVersionNotSupportedException extends SerinusException { const HttpVersionNotSupportedException( - {super.message = "HTTP Version Not Supported!", super.uri}) + {super.message = 'HTTP Version Not Supported!', super.uri}) : super(statusCode: 505); } @@ -135,7 +135,7 @@ class HttpVersionNotSupportedException extends SerinusException { /// The [statusCode] is 500 class InternalServerErrorException extends SerinusException { const InternalServerErrorException( - {super.message = "Internal server error!", super.uri}) + {super.message = 'Internal server error!', super.uri}) : super(statusCode: 500); } @@ -153,7 +153,7 @@ class InternalServerErrorException extends SerinusException { /// The [statusCode] is 405 class MethodNotAllowedException extends SerinusException { const MethodNotAllowedException( - {super.message = "Method not allowed!", super.uri}) + {super.message = 'Method not allowed!', super.uri}) : super(statusCode: 405); } @@ -170,7 +170,7 @@ class MethodNotAllowedException extends SerinusException { /// /// The [statusCode] is 406 class NotAcceptableException extends SerinusException { - const NotAcceptableException({super.message = "Not acceptable!", super.uri}) + const NotAcceptableException({super.message = 'Not acceptable!', super.uri}) : super(statusCode: 406); } @@ -187,7 +187,7 @@ class NotAcceptableException extends SerinusException { /// /// The [statusCode] is 404 class NotFoundException extends SerinusException { - const NotFoundException({super.message = "Not Found!", super.uri}) + const NotFoundException({super.message = 'Not Found!', super.uri}) : super(statusCode: 404); } @@ -204,7 +204,7 @@ class NotFoundException extends SerinusException { /// /// The [statusCode] is 501 class NotImplementedException extends SerinusException { - const NotImplementedException({super.message = "Not Implemented!", super.uri}) + const NotImplementedException({super.message = 'Not Implemented!', super.uri}) : super(statusCode: 501); } @@ -222,7 +222,7 @@ class NotImplementedException extends SerinusException { /// The [statusCode] is 413 class PayloadTooLargeException extends SerinusException { const PayloadTooLargeException( - {super.message = "Payload too large!", super.uri}) + {super.message = 'Payload too large!', super.uri}) : super(statusCode: 413); } @@ -240,7 +240,7 @@ class PayloadTooLargeException extends SerinusException { /// The [statusCode] is 412 class PreconditionFailedException extends SerinusException { const PreconditionFailedException( - {super.message = "Precondition failed!", super.uri}) + {super.message = 'Precondition failed!', super.uri}) : super(statusCode: 412); } @@ -257,7 +257,7 @@ class PreconditionFailedException extends SerinusException { /// /// The [statusCode] is 408 class RequestTimeoutException extends SerinusException { - const RequestTimeoutException({super.message = "Request timeout!", super.uri}) + const RequestTimeoutException({super.message = 'Request timeout!', super.uri}) : super(statusCode: 408); } @@ -275,7 +275,7 @@ class RequestTimeoutException extends SerinusException { /// The [statusCode] is 503 class ServiceUnavailableException extends SerinusException { const ServiceUnavailableException( - {super.message = "Service unavailable!", super.uri}) + {super.message = 'Service unavailable!', super.uri}) : super(statusCode: 503); } @@ -292,7 +292,7 @@ class ServiceUnavailableException extends SerinusException { /// /// The [statusCode] is 401 class UnauthorizedException extends SerinusException { - const UnauthorizedException({super.message = "Not authorized!", super.uri}) + const UnauthorizedException({super.message = 'Not authorized!', super.uri}) : super(statusCode: 401); } @@ -310,7 +310,7 @@ class UnauthorizedException extends SerinusException { /// The [statusCode] is 422 class UnprocessableEntityException extends SerinusException { const UnprocessableEntityException( - {super.message = "Unprocessable entity!", super.uri}) + {super.message = 'Unprocessable entity!', super.uri}) : super(statusCode: 422); } @@ -328,6 +328,6 @@ class UnprocessableEntityException extends SerinusException { /// The [statusCode] is 415 class UnsupportedMediaTypeException extends SerinusException { const UnsupportedMediaTypeException( - {super.message = "Unsupported media type!", super.uri}) + {super.message = 'Unsupported media type!', super.uri}) : super(statusCode: 415); } diff --git a/packages/serinus/lib/src/commons/exceptions/exceptions.dart b/packages/serinus/lib/src/exceptions/exceptions.dart similarity index 100% rename from packages/serinus/lib/src/commons/exceptions/exceptions.dart rename to packages/serinus/lib/src/exceptions/exceptions.dart diff --git a/packages/serinus/lib/src/commons/exceptions/serinus_exception.dart b/packages/serinus/lib/src/exceptions/serinus_exception.dart similarity index 89% rename from packages/serinus/lib/src/commons/exceptions/serinus_exception.dart rename to packages/serinus/lib/src/exceptions/serinus_exception.dart index da7d442f..82aee9d1 100644 --- a/packages/serinus/lib/src/commons/exceptions/serinus_exception.dart +++ b/packages/serinus/lib/src/exceptions/serinus_exception.dart @@ -32,9 +32,9 @@ class SerinusException implements HttpException { @override String toString() { return jsonEncode({ - "message": message, - "statusCode": statusCode, - "uri": uri != null ? uri!.path : "No Uri" + 'message': message, + 'statusCode': statusCode, + 'uri': uri != null ? uri!.path : 'No Uri' }); } } diff --git a/packages/serinus/lib/src/extensions/content_type_extensions.dart b/packages/serinus/lib/src/extensions/content_type_extensions.dart new file mode 100644 index 00000000..35cd67df --- /dev/null +++ b/packages/serinus/lib/src/extensions/content_type_extensions.dart @@ -0,0 +1,6 @@ +import 'dart:io'; + +extension ContentTypeExtensions on ContentType { + bool isUrlEncoded() => subType == 'x-www-form-urlencoded'; + bool isMultipart() => mimeType == 'multipart/form-data'; +} diff --git a/packages/serinus/lib/src/extensions/dynamic_extensions.dart b/packages/serinus/lib/src/extensions/dynamic_extensions.dart new file mode 100644 index 00000000..46a66ab4 --- /dev/null +++ b/packages/serinus/lib/src/extensions/dynamic_extensions.dart @@ -0,0 +1,21 @@ +extension JsonParsing on dynamic { + String parseJson() { + try { + return jsonEncode(this); + } catch (e) { + throw StateError('Error while parsing json'); + } + } + + Map convertMap() { + Map convertedMap = {}; + for (var key in this.keys) { + if (this[key] is Map) { + convertedMap[key.toString()] = this[key].convertMap(); + } else { + convertedMap[key.toString()] = this[key]; + } + } + return Map.from(convertedMap); + } +} diff --git a/packages/serinus/lib/src/commons/extensions/int_extensions.dart b/packages/serinus/lib/src/extensions/int_extensions.dart similarity index 100% rename from packages/serinus/lib/src/commons/extensions/int_extensions.dart rename to packages/serinus/lib/src/extensions/int_extensions.dart diff --git a/packages/serinus/lib/src/commons/extensions/iterable_extansions.dart b/packages/serinus/lib/src/extensions/iterable_extansions.dart similarity index 56% rename from packages/serinus/lib/src/commons/extensions/iterable_extansions.dart rename to packages/serinus/lib/src/extensions/iterable_extansions.dart index 794647ff..21156e4d 100644 --- a/packages/serinus/lib/src/commons/extensions/iterable_extansions.dart +++ b/packages/serinus/lib/src/extensions/iterable_extansions.dart @@ -1,26 +1,7 @@ -extension FirstWhereOrNull on Iterable { - T? firstWhereOrNull(bool Function(T) test) { - try { - return firstWhere(test); - } catch (e) { - return null; - } - } -} - extension Flatten on Iterable> { Iterable flatten() => [for (var element in this) ...element]; } -extension SegmentedPathMap on Iterable { - Iterable<({bool isLast, String value})> get pathMap { - final segments = toList(); - return segments.asMap().entries.map((e) { - return (isLast: e.key == segments.length - 1, value: e.value); - }); - } -} - extension AddIfAbsent on Iterable { Iterable addIfAbsent(T element) { final elementsType = map((e) => e.runtimeType); diff --git a/packages/serinus/lib/src/commons/extensions/string_extensions.dart b/packages/serinus/lib/src/extensions/string_extensions.dart similarity index 58% rename from packages/serinus/lib/src/commons/extensions/string_extensions.dart rename to packages/serinus/lib/src/extensions/string_extensions.dart index 33cdfb1d..7d18eeae 100644 --- a/packages/serinus/lib/src/commons/extensions/string_extensions.dart +++ b/packages/serinus/lib/src/extensions/string_extensions.dart @@ -1,15 +1,6 @@ import 'dart:convert'; extension JsonString on String { - bool isJson() { - try { - jsonDecode(this); - return true; - } catch (e) { - return false; - } - } - dynamic tryParse() { try { return jsonDecode(this); diff --git a/packages/serinus/lib/src/global_prefix.dart b/packages/serinus/lib/src/global_prefix.dart new file mode 100644 index 00000000..9cd43cd0 --- /dev/null +++ b/packages/serinus/lib/src/global_prefix.dart @@ -0,0 +1,10 @@ +/// Global prefix for all routes in the controller. +/// +/// This annotation is used to define a global prefix for all routes in the application. +final class GlobalPrefix { + /// The prefix to be used. + final String prefix; + + /// The [GlobalPrefix] constructor is used to create a [GlobalPrefix] object. + const GlobalPrefix({required this.prefix}); +} diff --git a/packages/serinus/lib/src/http/body.dart b/packages/serinus/lib/src/http/body.dart new file mode 100644 index 00000000..b7d02272 --- /dev/null +++ b/packages/serinus/lib/src/http/body.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'form_data.dart'; + +/// The class [Body] is used to create a body for the request. +class Body { + /// If the content type is [multipart/form-data] or [x-www-form-urlencoded], the [formData] will be used. + final FormData? formData; + + /// The content type of the body. + final ContentType contentType; + + /// The content of the body if it is text. + final String? text; + + /// The content of the body if it is binary. + final List? bytes; + + /// The content of the body if it is json. + final Map? json; + + Body(this.contentType, {this.formData, this.text, this.bytes, this.json}); + + /// Factory constructor to create an empty body. + factory Body.empty() => Body(ContentType.text); +} diff --git a/packages/serinus/lib/src/commons/cors.dart b/packages/serinus/lib/src/http/cors.dart similarity index 70% rename from packages/serinus/lib/src/commons/cors.dart rename to packages/serinus/lib/src/http/cors.dart index 423fddd8..d80a0f95 100644 --- a/packages/serinus/lib/src/commons/cors.dart +++ b/packages/serinus/lib/src/http/cors.dart @@ -1,10 +1,16 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/internal_request.dart'; +import '../contexts/contexts.dart'; +import '../core/core.dart'; +import 'internal_request.dart'; +import 'request.dart'; +import 'response.dart'; +/// The class [Cors] is used to handle the CORS requests. class Cors { + /// The list of allowed origins. final List allowedOrigins; Cors({this.allowedOrigins = const ['*']}) { + /// The default headers for the CORS requests. _defaultHeaders = { 'Access-Control-Expose-Headers': '', 'Access-Control-Allow-Credentials': 'true', @@ -39,35 +45,56 @@ class Cors { ]; Map _defaultHeaders = {}; + + /// The response headers. Map responseHeaders = {}; + /// The class behaves as a callable class. Future call(InternalRequest request, Request wrappedRequest, RequestContext? context, ReqResHandler? handler, [List allowedOrigins = const ['*']]) async { + /// Get the origin from the request headers. final origin = request.headers['origin']; + + /// Check if the origin is allowed. if (origin == null || (!allowedOrigins.contains('*') && !allowedOrigins.contains(origin))) { return handler!(context!); } + + /// Set the response headers. final headers = >{ ..._defaultHeadersAll, }; + + /// Add the origin to the response headers. headers['Access-Control-Allow-Origin'] = [origin]; + + /// Stringify the headers. final stringHeaders = headers.map((key, value) => MapEntry(key, value.join(','))); responseHeaders = { ...stringHeaders, }; + + /// Check if the request method is OPTIONS. if (request.method == 'OPTIONS') { + /// If the request method is OPTIONS, return a response with status 200. request.response.status(200); request.response.headers(stringHeaders); request.response.send(null); return null; } + + /// Call the handler. final response = await handler!(context!); + + /// Add the headers to the response. response.addHeaders({ ...stringHeaders, }); + + /// Return the response. return response; } } diff --git a/packages/serinus/lib/src/commons/form_data.dart b/packages/serinus/lib/src/http/form_data.dart similarity index 96% rename from packages/serinus/lib/src/commons/form_data.dart rename to packages/serinus/lib/src/http/form_data.dart index 0ddc4dbf..eb848de7 100644 --- a/packages/serinus/lib/src/commons/form_data.dart +++ b/packages/serinus/lib/src/http/form_data.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:http_parser/http_parser.dart'; import 'package:mime/mime.dart'; -import 'package:serinus/serinus.dart'; + +import '../exceptions/exceptions.dart'; +import '../mixins/object_mixins.dart'; /// The class FormData is used to parse multipart/form-data and application/x-www-form-urlencoded class FormData { @@ -18,7 +20,7 @@ class FormData { _files = files; Map get values => - Map.unmodifiable({"fields": fields, "files": files}); + Map.unmodifiable({'fields': fields, 'files': files}); Map get fields => Map.unmodifiable(_fields); Map get files => Map.unmodifiable(_files); @@ -93,7 +95,7 @@ class UploadedFile with JsonObject { final ContentType contentType; final Stream> stream; final String name; - String _data = ""; + String _data = ''; UploadedFile(this.stream, this.contentType, this.name); diff --git a/packages/serinus/lib/src/http/http.dart b/packages/serinus/lib/src/http/http.dart new file mode 100644 index 00000000..f0002b22 --- /dev/null +++ b/packages/serinus/lib/src/http/http.dart @@ -0,0 +1,7 @@ +export 'body.dart'; +export 'cors.dart'; +export 'form_data.dart'; +export 'internal_response.dart'; +export 'request.dart'; +export 'response.dart'; +export 'session.dart'; diff --git a/packages/serinus/lib/src/commons/internal_request.dart b/packages/serinus/lib/src/http/internal_request.dart similarity index 92% rename from packages/serinus/lib/src/commons/internal_request.dart rename to packages/serinus/lib/src/http/internal_request.dart index c7ec32c7..2cf341f1 100644 --- a/packages/serinus/lib/src/commons/internal_request.dart +++ b/packages/serinus/lib/src/http/internal_request.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'exceptions/exceptions.dart'; +import '../exceptions/exceptions.dart'; import 'internal_response.dart'; /// The class Request is used to handle the request @@ -40,7 +40,7 @@ class InternalRequest { ContentType contentType; /// The [webSocketKey] property contains the key of the web socket - String webSocketKey = ""; + String webSocketKey = ''; /// The [Request.from] constructor is used to create a [Request] object from a [HttpRequest] object factory InternalRequest.from(HttpRequest request, {String baseUrl = ''}) { @@ -63,6 +63,14 @@ class InternalRequest { baseUrl: baseUrl); } + Encoding? get encoding { + var contentType = this.contentType; + if (!contentType.parameters.containsKey('charset')) { + return null; + } + return Encoding.getByName(contentType.parameters['charset']); + } + InternalRequest({ required this.path, required this.uri, @@ -88,10 +96,10 @@ class InternalRequest { Future body() async { final data = await bytes(); if (data.isEmpty) { - return ""; + return ''; } - String stringData = utf8.decode(data); - return stringData; + final en = encoding ?? utf8; + return en.decode(data); } /// This method is used to get the body of the request as a [dynamic] json object @@ -110,7 +118,7 @@ class InternalRequest { contentType = ContentType('application', 'json'); return jsonData; } catch (e) { - throw BadRequestException(message: "The json body is malformed"); + throw BadRequestException(message: 'The json body is malformed'); } } diff --git a/packages/serinus/lib/src/commons/internal_response.dart b/packages/serinus/lib/src/http/internal_response.dart similarity index 53% rename from packages/serinus/lib/src/commons/internal_response.dart rename to packages/serinus/lib/src/http/internal_response.dart index f1b607c4..135520a3 100644 --- a/packages/serinus/lib/src/commons/internal_response.dart +++ b/packages/serinus/lib/src/http/internal_response.dart @@ -1,13 +1,20 @@ import 'dart:io'; -import 'package:serinus/serinus.dart'; +import 'package:collection/collection.dart'; + +import '../engines/view_engine.dart'; +import '../enums/enums.dart'; +import '../versioning.dart'; +import 'response.dart'; class InternalResponse { final HttpResponse _original; bool _statusChanged = false; final String? baseUrl; - InternalResponse(this._original, {this.baseUrl}); + InternalResponse(this._original, {this.baseUrl}) { + _original.headers.chunkedTransferEncoding = false; + } Future send(dynamic data) async { if (!_statusChanged) { @@ -59,6 +66,29 @@ class InternalResponse { _original.headers.add(versioning.header!, versioning.version.toString()); } contentType(result.contentType); - await send(result.data); + _original.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); + if (result.contentLength != null) { + _original.headers.contentLength = result.contentLength!; + } + var data = result.data; + var coding = _original.headers['transfer-encoding']?.join(';'); + if (coding != null && !equalsIgnoreAsciiCase(coding, 'identity')) { + // If the response is already in a chunked encoding, de-chunk it because + // otherwise `dart:io` will try to add another layer of chunking. + // + _original.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); + } else if (result.statusCode >= 200 && + result.statusCode != 204 && + result.statusCode != 304 && + result.contentLength == null && + result.contentType.mimeType != 'multipart/byteranges') { + // If the response isn't chunked yet and there's no other way to tell its + // length, enable `dart:io`'s chunked encoding. + _original.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); + } + if (!result.headers.containsKey(HttpHeaders.dateHeader)) { + _original.headers.set(HttpHeaders.dateHeader, DateTime.now().toUtc()); + } + await send(data); } } diff --git a/packages/serinus/lib/src/commons/request.dart b/packages/serinus/lib/src/http/request.dart similarity index 50% rename from packages/serinus/lib/src/commons/request.dart rename to packages/serinus/lib/src/http/request.dart index 4a876559..7f673bc5 100644 --- a/packages/serinus/lib/src/commons/request.dart +++ b/packages/serinus/lib/src/http/request.dart @@ -1,16 +1,23 @@ import 'dart:convert'; import 'dart:io'; -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/extensions/content_type_extensions.dart'; -import 'package:serinus/src/commons/extensions/string_extensions.dart'; - +import '../extensions/content_type_extensions.dart'; +import '../extensions/string_extensions.dart'; +import 'body.dart'; +import 'form_data.dart'; import 'internal_request.dart'; +import 'session.dart'; +/// The class [Request] is used to create a request object. +/// +/// It is a wrapper around the [InternalRequest] object. class Request { + /// The original [InternalRequest] object. final InternalRequest _original; Request(this._original, {this.params = const {}}) { + /// This loop is used to parse the query parameters of the request. + /// It will try to parse the query parameters to the correct type. for (final entry in _original.queryParameters.entries) { switch (entry.value.runtimeType) { case == int: @@ -30,60 +37,96 @@ class Request { final Map _queryParamters = {}; + /// The path of the request. String get path => _original.path; + /// The method of the request. String get method => _original.method; + /// The headers of the request. Map get headers => _original.headers; + /// The query parameters of the request. Map get queryParameters => _queryParamters; + /// The session of the request. Session get session => Session(_original.original.session); + /// The params of the request. final Map params; final Map _data = {}; Body? _body; + /// The body of the request. Body? get body => _body; + /// This method is used to parse the body of the request. + /// + /// It will try to parse the body of the request to the correct type. Future parseBody() async { + /// If the body is already parsed, it will return. if (_body != null) { return; } + + /// The content type of the request. final contentType = _original.contentType; + + /// If the content type is multipart, it will parse the body as a multipart form data. if (contentType.isMultipart()) { final formData = await FormData.parseMultipart(request: _original.original); _body = Body(contentType, formData: formData); return; } + + /// If the body is empty, it will return an empty body. final body = await _original.body(); + if (body.isEmpty) { + _body = Body.empty(); + return; + } + + /// If the content type is url encoded, it will parse the body as a url encoded form data. if (contentType.isUrlEncoded()) { final formData = FormData.parseUrlEncoded(body); _body = Body(contentType, formData: formData); return; } - if (body.isJson() || contentType == ContentType.json) { - final json = jsonDecode(body); + + /// If the content type is json, it will parse the body as a json object. + final parsedJson = body.tryParse(); + if (parsedJson != null || contentType == ContentType.json) { + final json = parsedJson ?? jsonDecode(body); _body = Body(contentType, json: json); return; } + + /// If the content type is binary, it will parse the body as a binary data. if (contentType == ContentType.binary) { _body = Body(contentType, bytes: body.codeUnits); return; } + + /// If the content type is text, it will parse the body as a text data. _body = Body( contentType, text: body, ); } + /// This method is used to add data to the request. + /// + /// Helper function to pass information between [Pipe]s, [Guard]s, [Middleware]s and [Route]s. void addData(String key, dynamic value) { _data[key] = value; } + /// This method is used to get data from the request. + /// + /// Helper function to pass information between [Pipe]s, [Guard]s, [Middleware]s and [Route]s. dynamic getData(String key) { return _data[key]; } diff --git a/packages/serinus/lib/src/http/response.dart b/packages/serinus/lib/src/http/response.dart new file mode 100644 index 00000000..e57deaa0 --- /dev/null +++ b/packages/serinus/lib/src/http/response.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../engines/view_engine.dart'; +import '../mixins/mixins.dart'; + +/// The class [Response] is used to create a response for the request. +/// +/// It behaves as a template for the response of the request. +/// It can be used to create a response with different content types. +class Response { + /// The value to be sent as a response. + /// + /// It can be a [String], [List], [Map], [Uint8List], [File], [View], [ViewString], [JsonObject] + final dynamic _value; + + /// The status code of the response. + int statusCode; + + /// The content type of the response. + final ContentType _contentType; + + /// A boolean value to check if the response should redirect. + final bool _shouldRedirect; + + /// The response object will try to calculate the content length of the response. + int? _contentLength; + + /// The content length of the response. + int? get contentLength => _contentLength; + + Response._(this._value, this.statusCode, this._contentType, + {bool shouldRedirect = false}) + : _shouldRedirect = shouldRedirect; + + /// The data of the response. + dynamic get data => _value; + + /// The content type of the response. + ContentType get contentType => _contentType; + + /// A boolean value to check if the response should redirect. + bool get shouldRedirect => _shouldRedirect; + + /// The headers of the response. + final Map _headers = {}; + + /// The headers of the response. + Map get headers => _headers; + + /// Factory constructor to create a response with a JSON content type. + /// It accepts a [Map], [List>], [JsonObject] + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.json]. + /// + /// Throws a [FormatException] if the data is not a [Map>] or a [JsonObject]. + factory Response.json(dynamic data, + {int statusCode = 200, ContentType? contentType}) { + dynamic responseData; + if (data is Map || data is List>) { + responseData = data; + } else if (data is JsonObject) { + responseData = data.toJson(); + } else { + throw FormatException( + 'The data must be a Map or a JsonSerializableMixin'); + } + final value = jsonEncode(responseData); + return Response._(value, statusCode, contentType ?? ContentType.json) + .._contentLength = value.length; + } + + /// Factory constructor to create a response with a HTML content type. + /// + /// It accepts a [String] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.html]. + factory Response.html(String data, + {int statusCode = 200, ContentType? contentType}) { + return Response._(data, statusCode, contentType ?? ContentType.html) + .._contentLength = data.length; + } + + /// Factory constructor to create a response that will be rendered by [ViewEngine] if available. + /// + /// It accepts a [View] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.html]. + factory Response.render(View view, + {int statusCode = 200, ContentType? contentType}) { + return Response._(view, statusCode, contentType ?? ContentType.html); + } + + /// Factory constructor to create a response that will be rendered by [ViewEngine] if available. + /// + /// It accepts a [ViewString] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.html]. + factory Response.renderString(ViewString view, + {int statusCode = 200, ContentType? contentType}) { + return Response._(view, statusCode, contentType ?? ContentType.html); + } + + /// Factory constructor to create a response with a text content type. + /// + /// It accepts a [String] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.text]. + factory Response.text(String data, + {int statusCode = 200, ContentType? contentType}) { + return Response._(data, statusCode, contentType ?? ContentType.text) + .._contentLength = data.length; + } + + /// Factory constructor to create a response with a binary content type. + /// + /// It accepts a [Uint8List] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.binary]. + factory Response.bytes(Uint8List data, + {int statusCode = 200, ContentType? contentType}) { + return Response._(data, statusCode, contentType ?? ContentType.binary); + } + + /// Factory constructor to create a response with a file content type. + /// + /// It accepts a [File] value. + /// + /// The [statusCode] is optional and defaults to 200. + /// + /// If the [contentType] is not provided, it defaults to [ContentType.binary]. + factory Response.file(File file, + {int statusCode = 200, ContentType? contentType}) { + return Response._( + file.readAsBytesSync(), statusCode, contentType ?? ContentType.binary); + } + + /// Factory constructor to create a response with a redirect status code. + /// + /// It accepts a [String] value. The value is the path to redirect to. + factory Response.redirect(String path) { + return Response._(path, 302, ContentType.text, shouldRedirect: true); + } + + /// Methods to add headers to the response. + /// + /// It accepts a [Map] value. + void addHeaders(Map headers) { + headers.forEach((key, value) { + _headers[key] = value; + }); + } +} diff --git a/packages/serinus/lib/src/http/session.dart b/packages/serinus/lib/src/http/session.dart new file mode 100644 index 00000000..6c574bb1 --- /dev/null +++ b/packages/serinus/lib/src/http/session.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +/// The class [Session] exposes the methods to interact with the session of the request. +/// +/// [Session] is a wrapper around the [HttpSession] class. +class Session { + /// The original [HttpSession] object. + final HttpSession _original; + + Session(this._original); + + /// This method is used to get a value from the session. + /// + /// Returns a value from the session. (dynamic, it can be null) + dynamic get(String key) { + return _original[key]; + } + + /// This method is used to put a value in the session. + /// + /// Puts a value in the session. + void put(String key, dynamic value) { + _original[key] = value; + } + + /// This method is used to remove a value from the session. + void remove(String key) { + _original.remove(key); + } + + /// The id of the session. + String get id => _original.id; + + /// It returns true if the session is new. + bool get isNew => _original.isNew; + + /// This method is used to destroy the session. + void destroy() { + _original.destroy(); + } +} diff --git a/packages/serinus/lib/src/core/injector/explorer.dart b/packages/serinus/lib/src/injector/explorer.dart similarity index 88% rename from packages/serinus/lib/src/core/injector/explorer.dart rename to packages/serinus/lib/src/injector/explorer.dart index e919ea42..a037da6f 100644 --- a/packages/serinus/lib/src/core/injector/explorer.dart +++ b/packages/serinus/lib/src/injector/explorer.dart @@ -1,9 +1,8 @@ -import 'package:serinus/src/commons/versioning.dart'; - -import '../../commons/services/logger_service.dart'; import '../containers/module_container.dart'; import '../containers/router.dart'; -import '../core.dart'; +import '../core/core.dart'; +import '../enums/versioning_type.dart'; +import '../services/logger_service.dart'; class Explorer { final ModulesContainer _modulesContainer; @@ -51,15 +50,15 @@ class Explorer { : module.token, queryParameters: spec.route.queryParameters), ); - logger.info("Mapped {$routePath, $routeMethod} route"); + logger.info('Mapped {$routePath, $routeMethod} route'); } } String normalizePath(String path) { - if (!path.startsWith("/")) { - path = "/$path"; + if (!path.startsWith('/')) { + path = '/$path'; } - if (path.endsWith("/") && path.length > 1) { + if (path.endsWith('/') && path.length > 1) { path = path.substring(0, path.length - 1); } if (path.contains(RegExp('([/]{2,})'))) { diff --git a/packages/serinus/lib/src/mixins/mixins.dart b/packages/serinus/lib/src/mixins/mixins.dart new file mode 100644 index 00000000..2992d9c7 --- /dev/null +++ b/packages/serinus/lib/src/mixins/mixins.dart @@ -0,0 +1,2 @@ +export 'object_mixins.dart'; +export 'provider_mixins.dart'; diff --git a/packages/serinus/lib/src/mixins/object_mixins.dart b/packages/serinus/lib/src/mixins/object_mixins.dart new file mode 100644 index 00000000..06488131 --- /dev/null +++ b/packages/serinus/lib/src/mixins/object_mixins.dart @@ -0,0 +1,6 @@ +/// Mixin for objects that can be converted to JSON. +/// +/// It is mostly used in the [Response] class to convert the data to JSON. +mixin JsonObject { + Map toJson(); +} diff --git a/packages/serinus/lib/src/mixins/provider_mixins.dart b/packages/serinus/lib/src/mixins/provider_mixins.dart new file mode 100644 index 00000000..0e217d5f --- /dev/null +++ b/packages/serinus/lib/src/mixins/provider_mixins.dart @@ -0,0 +1,15 @@ +import '../core/core.dart'; + +/// The mixin [OnApplicationInit] is used to define the method [onApplicationInit]. +/// +/// The method [onApplicationInit] is called in the providers when the application is initializing itself. +mixin OnApplicationInit on Provider { + Future onApplicationInit(); +} + +/// The mixin [OnApplicationShutdown] is used to define the method [onApplicationShutdown]. +/// +/// The method [onApplicationShutdown] is called in the providers when the application is shutting down. +mixin OnApplicationShutdown on Provider { + Future onApplicationShutdown(); +} diff --git a/packages/serinus/lib/src/commons/services/logger_service.dart b/packages/serinus/lib/src/services/logger_service.dart similarity index 84% rename from packages/serinus/lib/src/commons/services/logger_service.dart rename to packages/serinus/lib/src/services/logger_service.dart index 2b9c95c0..8df3ce30 100644 --- a/packages/serinus/lib/src/commons/services/logger_service.dart +++ b/packages/serinus/lib/src/services/logger_service.dart @@ -2,14 +2,19 @@ import 'dart:io' as io; import 'package:intl/intl.dart'; import 'package:logging/logging.dart' as logging; -import 'package:serinus/serinus.dart'; + +import '../enums/log_level.dart'; typedef LogCallback = void Function(logging.LogRecord record, double deltaTime); -/// The class Logger is used to create a logger +/// The [LoggerService] is used to bootstrap the logging in the application. class LoggerService { + /// The [onLog] callback is used to style the logs. LogCallback? onLog; + int _time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + /// The [level] of the logger. LogLevel level; factory LoggerService({ @@ -23,11 +28,14 @@ class LoggerService { this.onLog, this.level = LogLevel.debug, }) { + /// The root level of the logger. logging.Logger.root.level = switch (level) { LogLevel.debug => logging.Level.ALL, LogLevel.errors => logging.Level.SEVERE, LogLevel.none => logging.Level.OFF, }; + + /// The listener for the logs. logging.Logger.root.onRecord.listen((record) { double delta = DateTime.now().millisecondsSinceEpoch / 1000 - _time.toDouble(); @@ -44,11 +52,15 @@ class LoggerService { }); } + /// The [getLogger] method is used to get a logger with a specific name. Logger getLogger(String name) { return Logger(name); } } +/// The [Logger] class is a wrapper around the [logging.Logger] class. +/// +/// It is used to log messages in the application. class Logger { final String name; late logging.Logger _logger; diff --git a/packages/serinus/lib/src/commons/versioning.dart b/packages/serinus/lib/src/versioning.dart similarity index 91% rename from packages/serinus/lib/src/commons/versioning.dart rename to packages/serinus/lib/src/versioning.dart index 2bc78454..524441ff 100644 --- a/packages/serinus/lib/src/commons/versioning.dart +++ b/packages/serinus/lib/src/versioning.dart @@ -1,4 +1,6 @@ -class VersioningOptions { +import 'enums/enums.dart'; + +final class VersioningOptions { /// The global version of the API final int version; @@ -21,8 +23,3 @@ class VersioningOptions { } } } - -enum VersioningType { - header, - uri, -} diff --git a/packages/serinus/pubspec.lock b/packages/serinus/pubspec.lock index e9906bb9..0d5dab0d 100644 --- a/packages/serinus/pubspec.lock +++ b/packages/serinus/pubspec.lock @@ -82,7 +82,7 @@ packages: source: hosted version: "4.10.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: coverage - sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" url: "https://pub.dev" source: hosted - version: "1.7.2" + version: "1.8.0" crypto: dependency: transitive description: @@ -237,10 +237,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.14.0" + version: "1.15.0" mime: dependency: "direct main" description: @@ -325,10 +325,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" source_gen: dependency: transitive description: @@ -413,10 +413,10 @@ packages: dependency: "direct dev" description: name: test - sha256: d87214d19fb311997d8128ec501a980f77cb240ac4e7e219accf452813ff473c + sha256: d11b55850c68c1f6c0cf00eabded4e66c4043feaf6c0d7ce4a36785137df6331 url: "https://pub.dev" source: hosted - version: "1.25.3" + version: "1.25.5" test_api: dependency: transitive description: @@ -429,10 +429,10 @@ packages: dependency: transitive description: name: test_core - sha256: "2236f70be1e5ab405c675e88c36935a87dad9e05a506b57dd5c0f617f5aebcb2" + sha256: "4d070a6bc36c1c4e89f20d353bfd71dc30cdf2bd0e14349090af360a029ab292" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.2" typed_data: dependency: transitive description: @@ -453,10 +453,10 @@ packages: dependency: transitive description: name: vm_service - sha256: a75f83f14ad81d5fe4b3319710b90dec37da0e22612326b696c9e1b8f34bbf48 + sha256: "7475cb4dd713d57b6f7464c0e13f06da0d535d8b2067e188962a59bac2cf280b" url: "https://pub.dev" source: hosted - version: "14.2.0" + version: "14.2.2" watcher: dependency: transitive description: @@ -473,14 +473,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: bfe704c186c6e32a46f6607f94d079cd0b747b9a489fceeecc93cd3adb98edd5 + url: "https://pub.dev" + source: hosted + version: "0.1.3" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "3.0.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/packages/serinus/pubspec.yaml b/packages/serinus/pubspec.yaml index 976260b5..b07f1fa1 100644 --- a/packages/serinus/pubspec.yaml +++ b/packages/serinus/pubspec.yaml @@ -4,7 +4,7 @@ description: Serinus is a framework written in Dart documentation: https://docs.serinus.app homepage: https://docs.serinus.app repository: https://github.com/francescovallone/serinus -version: 0.2.1 +version: 0.2.2 topics: - server - httpserver @@ -25,7 +25,8 @@ dependencies: spanner: ^1.0.1+4 stream_channel: ^2.1.1 uuid: ^4.3.1 - web_socket_channel: ^2.4.4 + web_socket_channel: ^3.0.0 + collection: ^1.18.0 dev_dependencies: test: ^1.16.0 diff --git a/packages/serinus/test/commons/form_data_test.dart b/packages/serinus/test/commons/form_data_test.dart index bb3adc99..5b62bc82 100644 --- a/packages/serinus/test/commons/form_data_test.dart +++ b/packages/serinus/test/commons/form_data_test.dart @@ -13,10 +13,9 @@ class TestRoute extends Route { class TestController extends Controller { TestController({super.path = '/'}) { - on( - TestRoute(path: '/form', method: HttpMethod.post), - (context) async => - Response.json(context.request.body?.formData?.values ?? {})); + on(TestRoute(path: '/form', method: HttpMethod.post), (context) async { + return Response.json(context.request.body?.formData?.values ?? {}); + }); } } @@ -25,63 +24,61 @@ class TestModule extends Module { {super.controllers, super.imports, super.providers, super.exports}); } -class FormDataTestSuites { - static void runTests() { - group('$FormData', () { - group('UrlEncoded', () { - test( - '''when create a UrlEncoded FormData with an empty string, then the fields should be an empty map''', - () { - final body = FormData.parseUrlEncoded(''); - expect(body.fields, equals({})); - }); - test( - '''when create a UrlEncoded FormData with a key-value pair, then the fields should contains the key-value pair''', - () { - final body = FormData.parseUrlEncoded('foo=bar'); - expect(body.fields, equals({'foo': 'bar'})); - }); - test( - '''when create a UrlEncoded FormData with multiples key-value pairs, then the fields should contains the key-value pairs''', - () { - final body = FormData.parseUrlEncoded('foo=bar&bar=foo'); - expect(body.fields, equals({'foo': 'bar', 'bar': 'foo'})); - }); +void main() async { + group('$FormData', () { + group('UrlEncoded', () { + test( + '''when create a UrlEncoded FormData with an empty string, then the fields should be an empty map''', + () { + final body = FormData.parseUrlEncoded(''); + expect(body.fields, equals({})); }); - group('Multipart', () { - SerinusApplication? app; - setUpAll(() async { - app = await serinus.createApplication( - entrypoint: TestModule(controllers: [TestController()]), - loggingLevel: LogLevel.none); - await app?.serve(); - }); - tearDownAll(() async => await app?.close()); + test( + '''when create a UrlEncoded FormData with a key-value pair, then the fields should contains the key-value pair''', + () { + final body = FormData.parseUrlEncoded('foo=bar'); + expect(body.fields, equals({'foo': 'bar'})); + }); + test( + '''when create a UrlEncoded FormData with multiples key-value pairs, then the fields should contains the key-value pairs''', + () { + final body = FormData.parseUrlEncoded('foo=bar&bar=foo'); + expect(body.fields, equals({'foo': 'bar', 'bar': 'foo'})); + }); + }); + group('Multipart', () { + SerinusApplication? app; + setUpAll(() async { + app = await serinus.createApplication( + entrypoint: TestModule(controllers: [TestController()]), + loggingLevel: LogLevel.none); + await app?.serve(); + }); + tearDownAll(() async => await app?.close()); - test( - 'when the request sends a multipart form, then it should be divided in files and fields', - () async { - final request = http.MultipartRequest( - 'POST', - Uri.parse('http://localhost:3000/form'), - ); - request.fields['foo'] = 'bar'; - request.files.add(http.MultipartFile.fromString('file', 'file.txt', - filename: 'file.txt')); - final response = await request.send(); - final body = await response.stream.transform(Utf8Decoder()).join(); - final json = jsonDecode(body); + test( + 'when the request sends a multipart form, then it should be divided in files and fields', + () async { + final request = http.MultipartRequest( + 'POST', + Uri.parse('http://localhost:3000/form'), + ); + request.fields['foo'] = 'bar'; + request.files.add(http.MultipartFile.fromString('file', 'file.txt', + filename: 'file.txt')); + final response = await request.send(); + final body = await response.stream.transform(Utf8Decoder()).join(); + final json = jsonDecode(body); - expect(json['fields'], {'foo': 'bar'}); - expect(json['files'], { - 'file': { - 'name': 'file.txt', - 'contentType': 'text/plain; charset=utf-8', - 'data': 'file.txt' - } - }); + expect(json['fields'], {'foo': 'bar'}); + expect(json['files'], { + 'file': { + 'name': 'file.txt', + 'contentType': 'text/plain; charset=utf-8', + 'data': 'file.txt' + } }); }); }); - } + }); } diff --git a/packages/serinus/test/commons/versioning_test.dart b/packages/serinus/test/commons/versioning_test.dart index da11badc..13363a3b 100644 --- a/packages/serinus/test/commons/versioning_test.dart +++ b/packages/serinus/test/commons/versioning_test.dart @@ -1,15 +1,20 @@ import 'package:serinus/serinus.dart'; import 'package:test/test.dart'; -class VersioningTestsSuite { - static void runTests() { - group('$VersioningOptions', () { - test( - 'when the type is set to ${VersioningType.header}, and the "header" field is not set, then an $AssertionError should be thrown', - () { - expect(() => VersioningOptions(type: VersioningType.header), - throwsA(isA())); - }); +void main() { + group('$VersioningOptions', () { + test( + 'when the type is set to ${VersioningType.header}, and the "header" field is not set, then an $ArgumentError should be thrown', + () { + expect(() => VersioningOptions(type: VersioningType.header), + throwsA(isA())); }); - } + + test( + 'when the version is lower than 1, then an $ArgumentError should be thrown', + () { + expect(() => VersioningOptions(type: VersioningType.uri, version: 0), + throwsA(isA())); + }); + }); } diff --git a/packages/serinus/test/containers/router_test.dart b/packages/serinus/test/containers/router_test.dart new file mode 100644 index 00000000..3353f2a5 --- /dev/null +++ b/packages/serinus/test/containers/router_test.dart @@ -0,0 +1,69 @@ +import 'package:serinus/serinus.dart'; +import 'package:serinus/src/containers/router.dart'; +import 'package:spanner/spanner.dart'; +import 'package:test/test.dart'; + +class TestController extends Controller { + TestController({super.path = '/'}); +} + +void main() async { + group('$Router', () { + test('''when the function 'getHttpMethod' is called, + then it should return the correct HTTP method from Spanner + ''', () { + final router = Router(); + expect(router.getHttpMethod(HttpMethod.get), HTTPMethod.GET); + expect(router.getHttpMethod(HttpMethod.post), HTTPMethod.POST); + expect(router.getHttpMethod(HttpMethod.put), HTTPMethod.PUT); + expect(router.getHttpMethod(HttpMethod.delete), HTTPMethod.DELETE); + expect(router.getHttpMethod(HttpMethod.patch), HTTPMethod.PATCH); + }); + + test('''when the function 'registerRoute' is called, + then it should add a route to the route tree + ''', () { + final router = Router(); + final routeData = RouteData( + path: '/test', + method: HttpMethod.get, + controller: TestController(), + routeCls: Type, + moduleToken: 'moduleToken'); + router.registerRoute(routeData); + expect(router.routes.length, 1); + }); + + test('''when the function 'getRouteByPathAndMethod' is called, + and the route exists, + then it should return the correct route + ''', () { + final router = Router(); + final routeData = RouteData( + path: '/test', + method: HttpMethod.get, + controller: TestController(), + routeCls: Type, + moduleToken: 'moduleToken'); + router.registerRoute(routeData); + final result = router.getRouteByPathAndMethod('/test', HttpMethod.get); + expect(result.route, routeData); + }); + + test('''when the function 'getRouteByPathAndMethod' is called, + and the route does not exists, + then it should return null + ''', () { + final router = Router(); + final routeData = RouteData( + path: '/test', + method: HttpMethod.get, + controller: TestController(), + routeCls: Type, + moduleToken: 'moduleToken'); + router.registerRoute(routeData); + final result = router.getRouteByPathAndMethod('/test', HttpMethod.post); + expect(result.route, isNull); + }); + }); +} diff --git a/packages/serinus/test/core/containers/router.dart b/packages/serinus/test/core/containers/router.dart deleted file mode 100644 index 4b3d5cf8..00000000 --- a/packages/serinus/test/core/containers/router.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/core/containers/router.dart'; -import 'package:spanner/spanner.dart'; -import 'package:test/test.dart'; - -class TestController extends Controller { - TestController({super.path = '/'}); -} - -class RouterTestSuite { - static void runTests() { - group('$Router', () { - test('''when the function 'getHttpMethod' is called, - then it should return the correct HTTP method from Spanner - ''', () { - final router = Router(); - expect(router.getHttpMethod(HttpMethod.get), HTTPMethod.GET); - expect(router.getHttpMethod(HttpMethod.post), HTTPMethod.POST); - expect(router.getHttpMethod(HttpMethod.put), HTTPMethod.PUT); - expect(router.getHttpMethod(HttpMethod.delete), HTTPMethod.DELETE); - expect(router.getHttpMethod(HttpMethod.patch), HTTPMethod.PATCH); - }); - - test('''when the function 'registerRoute' is called, - then it should add a route to the route tree - ''', () { - final router = Router(); - final routeData = RouteData( - path: '/test', - method: HttpMethod.get, - controller: TestController(), - routeCls: Type, - moduleToken: 'moduleToken'); - router.registerRoute(routeData); - expect(router.routes.length, 1); - }); - - test('''when the function 'getRouteByPathAndMethod' is called, - and the route exists, - then it should return the correct route - ''', () { - final router = Router(); - final routeData = RouteData( - path: '/test', - method: HttpMethod.get, - controller: TestController(), - routeCls: Type, - moduleToken: 'moduleToken'); - router.registerRoute(routeData); - final result = router.getRouteByPathAndMethod('/test', HttpMethod.get); - expect(result.route, routeData); - }); - - test('''when the function 'getRouteByPathAndMethod' is called, - and the route does not exists, - then it should return null - ''', () { - final router = Router(); - final routeData = RouteData( - path: '/test', - method: HttpMethod.get, - controller: TestController(), - routeCls: Type, - moduleToken: 'moduleToken'); - router.registerRoute(routeData); - final result = router.getRouteByPathAndMethod('/test', HttpMethod.post); - expect(result.route, isNull); - }); - }); - } -} diff --git a/packages/serinus/test/core/contexts.dart b/packages/serinus/test/core/contexts.dart deleted file mode 100644 index bc596330..00000000 --- a/packages/serinus/test/core/contexts.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:test/test.dart'; - -class TestProvider extends Provider { - TestProvider(); -} - -class ContextsTestSuite { - static void runTests() { - group('$ApplicationContext', () { - test( - 'when a $ApplicationContext is created, then it should have a list of providers', - () { - final context = ApplicationContext({}, 'test'); - - expect(context.providers, {}); - }); - - test( - 'when a provider is added to the context, then it should be available to be used', - () { - final context = ApplicationContext({}, 'test'); - final provider = TestProvider(); - - context.addProviderToContext(provider); - - expect(context.use(), provider); - }); - - test( - 'when a provider is not available in the context, then a StateError should be thrown', - () { - final context = ApplicationContext({}, 'test'); - - expect(() => context.use(), throwsStateError); - }); - }); - } -} diff --git a/packages/serinus/test/core/contexts_test.dart b/packages/serinus/test/core/contexts_test.dart new file mode 100644 index 00000000..0eec30c2 --- /dev/null +++ b/packages/serinus/test/core/contexts_test.dart @@ -0,0 +1,37 @@ +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestProvider extends Provider { + TestProvider(); +} + +void main() async { + group('$ApplicationContext', () { + test( + 'when a $ApplicationContext is created, then it should have a list of providers', + () { + final context = ApplicationContext({}, 'test'); + + expect(context.providers, {}); + }); + + test( + 'when a provider is added to the context, then it should be available to be used', + () { + final context = ApplicationContext({}, 'test'); + final provider = TestProvider(); + + context.addProviderToContext(provider); + + expect(context.use(), provider); + }); + + test( + 'when a provider is not available in the context, then a StateError should be thrown', + () { + final context = ApplicationContext({}, 'test'); + + expect(() => context.use(), throwsStateError); + }); + }); +} diff --git a/packages/serinus/test/core/controller.dart b/packages/serinus/test/core/controller.dart deleted file mode 100644 index 6e18290b..00000000 --- a/packages/serinus/test/core/controller.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:test/test.dart'; - -class TestController extends Controller { - TestController({super.path = '/'}); -} - -class GetRoute extends Route { - const GetRoute({ - required super.path, - super.method = HttpMethod.get, - }); -} - -class ControllerTestSuite { - static void runTests() { - group('$Controller', () { - test( - 'when to a controller is added a route, then it should be saved on the "routes" map', - () { - final controller = TestController(); - final route = GetRoute(path: '/test'); - controller.on(route, (context) async => Response.text('ok!')); - expect(controller.routes.keys.map((e) => e.route), contains(route)); - }); - test( - 'when two routes with the same type and path are added to a controller, then it should throw an error', - () { - final controller = TestController(); - final route = GetRoute(path: '/test'); - controller.on(route, (context) async => Response.text('ok!')); - expect( - () => controller.on(route, (context) async => Response.text('ok!')), - throwsStateError); - }); - }); - } -} diff --git a/packages/serinus/test/core/controller_test.dart b/packages/serinus/test/core/controller_test.dart new file mode 100644 index 00000000..7ec2df30 --- /dev/null +++ b/packages/serinus/test/core/controller_test.dart @@ -0,0 +1,36 @@ +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestController extends Controller { + TestController({super.path = '/'}); +} + +class GetRoute extends Route { + const GetRoute({ + required super.path, + super.method = HttpMethod.get, + }); +} + +void main() async { + group('$Controller', () { + test( + 'when to a controller is added a route, then it should be saved on the "routes" map', + () { + final controller = TestController(); + final route = GetRoute(path: '/test'); + controller.on(route, (context) async => Response.text('ok!')); + expect(controller.routes.keys.map((e) => e.route), contains(route)); + }); + test( + 'when two routes with the same type and path are added to a controller, then it should throw an error', + () { + final controller = TestController(); + final route = GetRoute(path: '/test'); + controller.on(route, (context) async => Response.text('ok!')); + expect( + () => controller.on(route, (context) async => Response.text('ok!')), + throwsStateError); + }); + }); +} diff --git a/packages/serinus/test/core/guards_test.dart b/packages/serinus/test/core/guards_test.dart new file mode 100644 index 00000000..c57e6031 --- /dev/null +++ b/packages/serinus/test/core/guards_test.dart @@ -0,0 +1,68 @@ +import 'package:http/http.dart' as http; +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestRoute extends Route { + const TestRoute({ + required super.path, + super.method = HttpMethod.get, + }); +} + +class TestJsonObject with JsonObject { + @override + Map toJson() { + return {'id': 'json-obj'}; + } +} + +class TestController extends Controller { + TestController({super.path = '/'}) { + on( + TestRoute(path: '/guards'), + (context) async => Response.text('ok!') + ..headers['x-guard'] = context.request.headers['x-guard']); + } +} + +class TestModule extends Module { + TestModule( + {super.controllers, super.imports, super.providers, super.exports}); + + @override + List get guards => [TestModuleGuard()]; +} + +class TestModuleGuard extends Guard { + @override + Future canActivate(ExecutionContext context) async { + context.request.headers['x-guard'] = 'ok!'; + return true; + } +} + +void main() { + group('$Guard', () { + SerinusApplication? app; + setUpAll(() async { + app = await serinus.createApplication( + entrypoint: TestModule(controllers: [TestController()]), + port: 3002, + loggingLevel: LogLevel.none); + app?.enableCors(Cors()); + await app?.serve(); + }); + tearDownAll(() async { + await app?.close(); + }); + test( + '''when a request is made to a route with a guard, then the guard should be executed''', + () async { + final response = await http.get( + Uri.parse('http://localhost:3002/guards'), + ); + expect(response.statusCode, 200); + expect(response.headers.containsKey('x-guard'), true); + }); + }); +} diff --git a/packages/serinus/test/core/injector/explorer_test.dart b/packages/serinus/test/core/injector/explorer_test.dart deleted file mode 100644 index c581f5fe..00000000 --- a/packages/serinus/test/core/injector/explorer_test.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/core/containers/module_container.dart'; -import 'package:serinus/src/core/containers/router.dart'; -import 'package:serinus/src/core/injector/explorer.dart'; -import 'package:test/test.dart'; - -import '../../mocks/controller_mock.dart'; -import '../../mocks/module_mock.dart'; - -final config = ApplicationConfig( - host: 'localhost', - port: 3000, - poweredByHeader: 'Powered by Serinus', - securityContext: null, - serverAdapter: SerinusHttpServer()); - -class ExplorerTestsSuite { - static void runTests() { - group('$Explorer', () { - test( - 'when the application startup, then the controller can be walked through to register all the routes', - () async { - final router = Router(); - final modulesContainer = ModulesContainer(config); - await modulesContainer.registerModule( - SimpleMockModule(controllers: [MockController()]), - SimpleMockModule); - final explorer = Explorer(modulesContainer, router, config); - explorer.resolveRoutes(); - expect(router.routes.length, 1); - }); - - test( - 'when the application startup, and a controller has not a static path, then the explorer will throw an error', - () async { - final router = Router(); - final modulesContainer = ModulesContainer(config); - await modulesContainer.registerModule( - SimpleMockModule(controllers: [MockControllerWithWrongPath()]), - SimpleMockModule); - final explorer = Explorer(modulesContainer, router, config); - expect(() => explorer.resolveRoutes(), throwsException); - }); - - test( - 'when a path without leading slash is passed, then the path will be normalized', - () { - final explorer = Explorer(ModulesContainer(config), Router(), config); - final path = 'test'; - final normalizedPath = explorer.normalizePath(path); - expect(normalizedPath, '/test'); - }); - - test( - 'when a path with multiple slashes is passed, then the path will be normalized', - () { - final explorer = Explorer(ModulesContainer(config), Router(), config); - final path = '/test//test'; - final normalizedPath = explorer.normalizePath(path); - expect(normalizedPath, '/test/test'); - }); - - test( - 'when the $VersioningOptions is set to uri, then the route path will be prefixed with the version', - () async { - config.versioningOptions = - VersioningOptions(type: VersioningType.uri, version: 1); - final router = Router(); - final modulesContainer = ModulesContainer(config); - await modulesContainer.registerModule( - SimpleMockModule(controllers: [MockController()]), - SimpleMockModule); - final explorer = Explorer(modulesContainer, router, config); - explorer.resolveRoutes(); - final result = router.getRouteByPathAndMethod('/v1', HttpMethod.get); - expect(result.route?.path, '/v1/'); - }); - - test( - 'when the $GlobalPrefix is set, then the route path will be prefixed with the global prefix', - () async { - final config = ApplicationConfig( - host: 'localhost', - port: 3000, - poweredByHeader: 'Powered by Serinus', - securityContext: null, - serverAdapter: SerinusHttpServer()); - config.globalPrefix = GlobalPrefix(prefix: 'api'); - final router = Router(); - final modulesContainer = ModulesContainer(config); - await modulesContainer.registerModule( - SimpleMockModule(controllers: [MockController()]), - SimpleMockModule); - final explorer = Explorer(modulesContainer, router, config); - explorer.resolveRoutes(); - final result = router.getRouteByPathAndMethod('/api', HttpMethod.get); - expect(result.route?.path, '/api/'); - }); - - test( - 'when the $GlobalPrefix and $VersioningOptions are set, then the route path will be prefixed with the global prefix and the version', - () async { - final config = ApplicationConfig( - host: 'localhost', - port: 3000, - poweredByHeader: 'Powered by Serinus', - securityContext: null, - serverAdapter: SerinusHttpServer()); - config.globalPrefix = GlobalPrefix(prefix: 'api'); - config.versioningOptions = - VersioningOptions(type: VersioningType.uri, version: 1); - final router = Router(); - final modulesContainer = ModulesContainer(config); - await modulesContainer.registerModule( - SimpleMockModule(controllers: [MockController()]), - SimpleMockModule); - final explorer = Explorer(modulesContainer, router, config); - explorer.resolveRoutes(); - final result = - router.getRouteByPathAndMethod('/api/v1', HttpMethod.get); - expect(result.route?.path, '/api/v1/'); - }); - }); - } -} diff --git a/packages/serinus/test/core/middlewares_test.dart b/packages/serinus/test/core/middlewares_test.dart new file mode 100644 index 00000000..6bff661c --- /dev/null +++ b/packages/serinus/test/core/middlewares_test.dart @@ -0,0 +1,69 @@ +import 'package:http/http.dart' as http; +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestRoute extends Route { + const TestRoute({ + required super.path, + super.method = HttpMethod.get, + }); +} + +class TestJsonObject with JsonObject { + @override + Map toJson() { + return {'id': 'json-obj'}; + } +} + +class TestController extends Controller { + TestController({super.path = '/'}) { + on( + TestRoute(path: '/middleware'), + (context) async => Response.text('ok!') + ..headers['x-middleware'] = context.request.headers['x-middleware']); + } +} + +class TestModule extends Module { + TestModule( + {super.controllers, super.imports, super.providers, super.exports}); + + @override + List get middlewares => [TestModuleMiddleware()]; +} + +class TestModuleMiddleware extends Middleware { + @override + Future use(RequestContext context, InternalResponse response, + NextFunction next) async { + context.request.headers['x-middleware'] = 'ok!'; + return next(); + } +} + +void main() { + group('$Middleware', () { + SerinusApplication? app; + setUpAll(() async { + app = await serinus.createApplication( + entrypoint: TestModule(controllers: [TestController()]), + port: 3003, + loggingLevel: LogLevel.none); + app?.enableCors(Cors()); + await app?.serve(); + }); + tearDownAll(() async { + await app?.close(); + }); + test( + '''when a request is made to a route with a middleware in the module, then the middleware should be executed''', + () async { + final response = await http.get( + Uri.parse('http://localhost:3003/middleware'), + ); + expect(response.statusCode, 200); + expect(response.headers.containsKey('x-middleware'), true); + }); + }); +} diff --git a/packages/serinus/test/core/module.dart b/packages/serinus/test/core/module.dart deleted file mode 100644 index 2d5cd059..00000000 --- a/packages/serinus/test/core/module.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/errors/initialization_error.dart'; -import 'package:serinus/src/core/containers/module_container.dart'; -import 'package:test/test.dart'; - -class TestProvider extends Provider { - TestProvider(); -} - -class TestModule extends Module { - TestModule({super.imports, super.providers = const [], super.exports}); -} - -class TestSubModule extends Module { - TestSubModule({super.providers = const [], super.exports}); -} - -class TestProviderExported extends Provider { - TestProviderExported(); -} - -final config = ApplicationConfig( - host: 'localhost', - port: 3000, - poweredByHeader: 'Powered by Serinus', - securityContext: null, - serverAdapter: SerinusHttpServer()); - -class ModuleTestSuite { - static void runTests() { - group('$Module', () { - test('''when a $Module is registered in the application, - then all the submodules should be registered as well - ''', () async { - final container = ModulesContainer(config); - - await container.registerModules( - TestModule(imports: [TestSubModule()]), Type); - - await container.finalize(); - - expect(container.modules.length, 2); - }); - - test('''when a $Module is registered in the application, - and is the entrypoint, - and has exports, - then it should throw a $InitializationError - ''', () async { - final container = ModulesContainer(config); - - container - .registerModules( - TestModule( - imports: [TestSubModule()], - providers: [TestProviderExported()], - exports: [TestProviderExported]), - TestModule) - .catchError( - (value) => expect(value.runtimeType, InitializationError)); - }); - - test('''when a $Module is registered in the application, - and it imports itself, - then it should throw a $InitializationError - ''', () async { - final container = ModulesContainer(config); - - container - .registerModules( - TestModule( - imports: [TestModule()], - ), - TestModule) - .catchError( - (value) => expect(value.runtimeType, InitializationError)); - }); - - test('''when a $Module is registered in the application, - and it exports a provider that is not registered in the module, - then it should throw a $InitializationError - ''', () async { - final container = ModulesContainer(config); - - await container.registerModules( - TestModule( - imports: [ - TestSubModule(exports: [TestProviderExported]) - ], - ), - TestModule); - - container.finalize().catchError( - (value) => expect(value.runtimeType, InitializationError)); - }); - - test( - '''when the function 'getModuleByToken' is called with a token that does not exist, - then it should throw an $ArgumentError - ''', () async { - final container = ModulesContainer(config); - - expect(() => container.getModuleByToken('test'), - throwsA(isA())); - }); - - test( - '''when the function 'getParents' is called with a module that has no parents, - then it should return an empty list - ''', () async { - final container = ModulesContainer(config); - - final module = TestModule(); - final parents = container.getParents(module); - - expect(parents, []); - }); - - test( - '''when the function 'getParents' is called with a module that has parents, - then it should return a list with the parents - ''', () async { - final container = ModulesContainer(config); - final subModule = TestSubModule(); - final module = TestModule(imports: [subModule]); - - await container.registerModule(module, TestModule); - - await container.finalize(); - - final parents = container.getParents(subModule); - - expect(parents, [module]); - }); - - test( - '''when a $DeferredModule is registered in the application through a $Module, - then it should be initialized after the 'eager' modules - ''', () async { - final container = ModulesContainer(config); - final subModule = TestSubModule(); - final module = TestModule(imports: [ - DeferredModule((context) async => subModule, inject: []) - ]); - - await container.registerModules(module, TestModule); - - await container.finalize(); - - final parents = container.getParents(subModule); - - expect(parents, [module]); - }); - }); - } -} diff --git a/packages/serinus/test/core/module_test.dart b/packages/serinus/test/core/module_test.dart new file mode 100644 index 00000000..cdc2a7ae --- /dev/null +++ b/packages/serinus/test/core/module_test.dart @@ -0,0 +1,148 @@ +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestProvider extends Provider { + TestProvider(); +} + +class TestModule extends Module { + TestModule({super.imports, super.providers = const [], super.exports}); +} + +class TestSubModule extends Module { + TestSubModule({super.providers = const [], super.exports}); +} + +class TestProviderExported extends Provider { + TestProviderExported(); +} + +final config = ApplicationConfig( + host: 'localhost', + port: 3000, + poweredByHeader: 'Powered by Serinus', + securityContext: null, + serverAdapter: SerinusHttpServer()); + +void main() async { + group('$Module', () { + test('''when a $Module is registered in the application, + then all the submodules should be registered as well + ''', () async { + final container = ModulesContainer(config); + final module = TestModule(imports: [TestSubModule()]); + await container.registerModules(module, Type); + + await container.finalize(module); + + expect(container.modules.length, 2); + }); + + test('''when a $Module is registered in the application, + and is the entrypoint, + and has exports, + then it should throw a $InitializationError + ''', () async { + final container = ModulesContainer(config); + + container + .registerModules( + TestModule( + imports: [TestSubModule()], + providers: [TestProviderExported()], + exports: [TestProviderExported]), + TestModule) + .catchError( + (value) => expect(value.runtimeType, InitializationError)); + }); + + test('''when a $Module is registered in the application, + and it imports itself, + then it should throw a $InitializationError + ''', () async { + final container = ModulesContainer(config); + + container + .registerModules( + TestModule( + imports: [TestModule()], + ), + TestModule) + .catchError( + (value) => expect(value.runtimeType, InitializationError)); + }); + + test('''when a $Module is registered in the application, + and it exports a provider that is not registered in the module, + then it should throw a $InitializationError + ''', () async { + final container = ModulesContainer(config); + final entrypoint = TestModule( + imports: [ + TestSubModule(exports: [TestProviderExported]) + ], + ); + await container.registerModules(entrypoint, TestModule); + + container.finalize(entrypoint).catchError( + (value) => expect(value.runtimeType, InitializationError)); + }); + + test( + '''when the function 'getModuleByToken' is called with a token that does not exist, + then it should throw an $ArgumentError + ''', () async { + final container = ModulesContainer(config); + + expect(() => container.getModuleByToken('test'), + throwsA(isA())); + }); + + test( + '''when the function 'getParents' is called with a module that has no parents, + then it should return an empty list + ''', () async { + final container = ModulesContainer(config); + + final module = TestModule(); + final parents = container.getParents(module); + + expect(parents, []); + }); + + test( + '''when the function 'getParents' is called with a module that has parents, + then it should return a list with the parents + ''', () async { + final container = ModulesContainer(config); + final subModule = TestSubModule(); + final module = TestModule(imports: [subModule]); + + await container.registerModules(module, TestModule); + + await container.finalize(module); + + final parents = container.getParents(subModule); + + expect(parents, [module]); + }); + + test( + '''when a $DeferredModule is registered in the application through a $Module, + then it should be initialized after the 'eager' modules + ''', () async { + final container = ModulesContainer(config); + final subModule = TestSubModule(); + final module = TestModule( + imports: [DeferredModule((context) async => subModule, inject: [])]); + + await container.registerModules(module, TestModule); + + await container.finalize(module); + + final parents = container.getParents(subModule); + + expect(parents, [module]); + }); + }); +} diff --git a/packages/serinus/test/core/provider.dart b/packages/serinus/test/core/provider.dart deleted file mode 100644 index 46a305dc..00000000 --- a/packages/serinus/test/core/provider.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:serinus/serinus.dart'; -import 'package:serinus/src/commons/errors/initialization_error.dart'; -import 'package:serinus/src/core/containers/module_container.dart'; -import 'package:test/test.dart'; - -class TestProvider extends Provider { - TestProvider(); -} - -class TestModule extends Module { - TestModule({super.providers = const []}); -} - -class TestProviderDependent extends Provider { - TestProviderDependent(TestProvider provider); -} - -class TestProviderOnInit extends Provider with OnApplicationInit { - bool isInitialized = false; - - @override - Future onApplicationInit() async { - isInitialized = true; - } - - TestProviderOnInit(); -} - -final config = ApplicationConfig( - host: 'localhost', - port: 3000, - poweredByHeader: 'Powered by Serinus', - securityContext: null, - serverAdapter: SerinusHttpServer()); - -class ProviderTestSuite { - static void runTests() { - group('$Provider', () { - test( - '''when a $Provider is registered in the application through a $Module, - then it should be gettable from the container - ''', () async { - final provider = TestProvider(); - final container = ModulesContainer(config); - - await container.registerModule(TestModule(providers: [provider]), Type); - expect(container.get(), provider); - }); - - test('''when a $Provider is registered in the application two times, - then it should throw a $InitializationError - ''', () async { - final container = ModulesContainer(config); - - container - .registerModule( - TestModule(providers: [TestProvider(), TestProvider()]), Type) - .catchError((e) => expect(e.runtimeType, InitializationError)); - }); - - test( - '''when a $DeferredProvider is registered in the application through a $Module, - and the 'finalize' method has been called, - then the initialized $Provider should be gettable''', () async { - final provider = TestProvider(); - final container = ModulesContainer(config); - - await container.registerModule( - TestModule(providers: [ - DeferredProvider((context) async => provider, inject: []) - ]), - Type); - - await container.finalize(); - expect(container.get(), provider); - }); - - test( - '''when a $DeferredProvider is registered in the application through a $Module, - and the 'finalize' method has not been called, - then the initialized $Provider should not be gettable''', () async { - final provider = TestProvider(); - final container = ModulesContainer(config); - - await container.registerModule( - TestModule(providers: [ - DeferredProvider((context) async => provider, inject: []) - ]), - Type); - - expect(container.get(), isNull); - }); - - test( - '''when a $DeferredProvider with dependencies is registered in the application through a $Module, - and the dipendency is in the scoped context, - then the initialized $Provider should be gettable''', () async { - final container = ModulesContainer(config); - - await container.registerModule( - TestModule(providers: [ - TestProvider(), - DeferredProvider((context) async { - final dep = context.use(); - return TestProviderDependent(dep); - }, inject: [TestProvider]) - ]), - Type); - - await container.finalize(); - - expect(container.get(), isNotNull); - }); - - test( - '''when a $DeferredProvider with dependencies is registered in the application through a $Module, - and the dipendency is not in the scoped context, - then the initialized $Provider should not be gettable''', () async { - final container = ModulesContainer(config); - - await container.registerModule( - TestModule(providers: [ - DeferredProvider((context) async { - final dep = context.use(); - return TestProviderDependent(dep); - }, inject: [TestProvider]) - ]), - Type); - - container - .finalize() - .catchError((value) => expect(value.runtimeType, StateError)); - }); - - test('''when a $Provider has $OnApplicationInit mixin, - then the onApplicationInit method should be called''', () async { - final provider = TestProviderOnInit(); - final container = ModulesContainer(config); - - await container.registerModule(TestModule(providers: [provider]), Type); - - await container.finalize(); - expect(provider.isInitialized, true); - }); - }); - } -} diff --git a/packages/serinus/test/core/provider_test.dart b/packages/serinus/test/core/provider_test.dart new file mode 100644 index 00000000..7baf7794 --- /dev/null +++ b/packages/serinus/test/core/provider_test.dart @@ -0,0 +1,136 @@ +import 'package:serinus/serinus.dart'; +import 'package:test/test.dart'; + +class TestProvider extends Provider { + TestProvider(); +} + +class TestModule extends Module { + TestModule({super.providers = const []}); +} + +class TestProviderDependent extends Provider { + TestProviderDependent(TestProvider provider); +} + +class TestProviderOnInit extends Provider with OnApplicationInit { + bool isInitialized = false; + + @override + Future onApplicationInit() async { + isInitialized = true; + } + + TestProviderOnInit(); +} + +final config = ApplicationConfig( + host: 'localhost', + port: 3000, + poweredByHeader: 'Powered by Serinus', + securityContext: null, + serverAdapter: SerinusHttpServer()); + +void main() async { + group('$Provider', () { + test( + '''when a $Provider is registered in the application through a $Module, + then it should be gettable from the container + ''', () async { + final provider = TestProvider(); + final container = ModulesContainer(config); + + await container.registerModules(TestModule(providers: [provider]), Type); + expect(container.get(), provider); + }); + + test('''when a $Provider is registered in the application two times, + then it should throw a $InitializationError + ''', () async { + final container = ModulesContainer(config); + + container + .registerModule( + TestModule(providers: [TestProvider(), TestProvider()]), Type) + .catchError((e) => expect(e.runtimeType, InitializationError)); + }); + + test( + '''when a $DeferredProvider is registered in the application through a $Module, + and the 'finalize' method has been called, + then the initialized $Provider should be gettable''', () async { + final provider = TestProvider(); + final container = ModulesContainer(config); + final module = TestModule(providers: [ + DeferredProvider((context) async => provider, inject: []) + ]); + await container.registerModules(module, Type); + + await container.finalize(module); + expect(container.get(), provider); + }); + + test( + '''when a $DeferredProvider is registered in the application through a $Module, + and the 'finalize' method has not been called, + then the initialized $Provider should not be gettable''', () async { + final provider = TestProvider(); + final container = ModulesContainer(config); + + await container.registerModules( + TestModule(providers: [ + DeferredProvider((context) async => provider, inject: []) + ]), + Type); + + expect(container.get(), isNull); + }); + + test( + '''when a $DeferredProvider with dependencies is registered in the application through a $Module, + and the dipendency is in the scoped context, + then the initialized $Provider should be gettable''', () async { + final container = ModulesContainer(config); + final module = TestModule(providers: [ + TestProvider(), + DeferredProvider((context) async { + final dep = context.use(); + return TestProviderDependent(dep); + }, inject: [TestProvider]) + ]); + await container.registerModules(module, Type); + + await container.finalize(module); + + expect(container.get(), isNotNull); + }); + + test( + '''when a $DeferredProvider with dependencies is registered in the application through a $Module, + and the dipendency is not in the scoped context, + then the initialized $Provider should not be gettable''', () async { + final container = ModulesContainer(config); + final module = TestModule(providers: [ + DeferredProvider((context) async { + final dep = context.use(); + return TestProviderDependent(dep); + }, inject: [TestProvider]) + ]); + await container.registerModules(module, Type); + + container + .finalize(module) + .catchError((value) => expect(value.runtimeType, StateError)); + }); + + test('''when a $Provider has $OnApplicationInit mixin, + then the onApplicationInit method should be called''', () async { + final provider = TestProviderOnInit(); + final container = ModulesContainer(config); + final module = TestModule(providers: [provider]); + await container.registerModules(module, Type); + await container.finalize(module); + expect(provider.isInitialized, true); + }); + }); +} diff --git a/packages/serinus/test/errors_test.dart b/packages/serinus/test/errors_test.dart new file mode 100644 index 00000000..5e7e729a --- /dev/null +++ b/packages/serinus/test/errors_test.dart @@ -0,0 +1,13 @@ +import 'package:serinus/src/errors/initialization_error.dart'; +import 'package:test/test.dart'; + +void main() { + group('$InitializationError', () { + test( + 'when a message is provided to $InitializationError, then it should print "Initialization failed: [message]"', + () { + final error = InitializationError('test'); + expect(error.toString(), 'Initialization failed: test'); + }); + }); +} diff --git a/packages/serinus/test/exceptions_test.dart b/packages/serinus/test/exceptions_test.dart index d84d1b74..8d0cbc53 100644 --- a/packages/serinus/test/exceptions_test.dart +++ b/packages/serinus/test/exceptions_test.dart @@ -1,160 +1,149 @@ import 'package:serinus/serinus.dart'; import 'package:test/test.dart'; -class ExceptionsTestSuite { - static void runTests() { - test("should instantiate a BadRequestException with custom message", () { - BadRequestException exception = - BadRequestException(message: "Custom message!"); - expect(exception.statusCode, 400); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a ConflictException with custom message", () { - ConflictException exception = - ConflictException(message: "Custom message!"); - expect(exception.statusCode, 409); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a ForbiddenException with custom message", () { - ForbiddenException exception = - ForbiddenException(message: "Custom message!"); - expect(exception.statusCode, 403); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a GoneException with custom message", () { - GoneException exception = GoneException(message: "Custom message!"); - expect(exception.statusCode, 410); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a InternalServerError with custom message", () { - final exception = - InternalServerErrorException(message: "Custom message!"); - expect(exception.statusCode, 500); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a MethodNotAllowedException with custom message", - () { - MethodNotAllowedException exception = - MethodNotAllowedException(message: "Custom message!"); - expect(exception.statusCode, 405); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a NotAcceptableException with custom message", () { - NotAcceptableException exception = - NotAcceptableException(message: "Custom message!"); - expect(exception.statusCode, 406); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a NotFoundException with custom message", () { - NotFoundException exception = - NotFoundException(message: "Custom message!"); - expect(exception.statusCode, 404); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a RequestTimeoutException with custom message", - () { - RequestTimeoutException exception = - RequestTimeoutException(message: "Custom message!"); - expect(exception.statusCode, 408); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a UnauthorizedException with custom message", () { - UnauthorizedException exception = - UnauthorizedException(message: "Custom message!"); - expect(exception.statusCode, 401); - expect(exception.message, "Custom message!"); - }); - - test( - "should instantiate a UnprocessableEntityException with custom message", - () { - UnprocessableEntityException exception = - UnprocessableEntityException(message: "Custom message!"); - expect(exception.statusCode, 422); - expect(exception.message, "Custom message!"); - }); - - test( - "should instantiate a UnsupportedMediaTypeException with custom message", - () { - UnsupportedMediaTypeException exception = - UnsupportedMediaTypeException(message: "Custom message!"); - expect(exception.statusCode, 415); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a SerinusException with custom message", () { - SerinusException exception = - SerinusException(message: "Custom message!", statusCode: 500); - expect(exception.statusCode, 500); - expect(exception.message, "Custom message!"); - expect(exception.toString(), - '{"message":"Custom message!","statusCode":500,"uri":"No Uri"}'); - }); - - test("should instantiate a BadGatewayException with custom message", () { - BadGatewayException exception = - BadGatewayException(message: "Custom message!"); - expect(exception.statusCode, 502); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a GatewayTimeoutException with custom message", - () { - GatewayTimeoutException exception = - GatewayTimeoutException(message: "Custom message!"); - expect(exception.statusCode, 504); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a ServiceUnavailableException with custom message", - () { - ServiceUnavailableException exception = - ServiceUnavailableException(message: "Custom message!"); - expect(exception.statusCode, 503); - expect(exception.message, "Custom message!"); - }); - - test( - "should instantiate a HttpVersionNotSupportedException with custom message", - () { - HttpVersionNotSupportedException exception = - HttpVersionNotSupportedException(message: "Custom message!"); - expect(exception.statusCode, 505); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a NotImplementedException with custom message", - () { - NotImplementedException exception = - NotImplementedException(message: "Custom message!"); - expect(exception.statusCode, 501); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a PayloadTooLargeException with custom message", - () { - PayloadTooLargeException exception = - PayloadTooLargeException(message: "Custom message!"); - expect(exception.statusCode, 413); - expect(exception.message, "Custom message!"); - }); - - test("should instantiate a PreconditionFailedException with custom message", - () { - PreconditionFailedException exception = - PreconditionFailedException(message: "Custom message!"); - expect(exception.statusCode, 412); - expect(exception.message, "Custom message!"); - }); - } +void main() { + test('should instantiate a BadRequestException with custom message', () { + BadRequestException exception = + BadRequestException(message: 'Custom message!'); + expect(exception.statusCode, 400); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a ConflictException with custom message', () { + ConflictException exception = ConflictException(message: 'Custom message!'); + expect(exception.statusCode, 409); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a ForbiddenException with custom message', () { + ForbiddenException exception = + ForbiddenException(message: 'Custom message!'); + expect(exception.statusCode, 403); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a GoneException with custom message', () { + GoneException exception = GoneException(message: 'Custom message!'); + expect(exception.statusCode, 410); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a InternalServerError with custom message', () { + final exception = InternalServerErrorException(message: 'Custom message!'); + expect(exception.statusCode, 500); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a MethodNotAllowedException with custom message', + () { + MethodNotAllowedException exception = + MethodNotAllowedException(message: 'Custom message!'); + expect(exception.statusCode, 405); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a NotAcceptableException with custom message', () { + NotAcceptableException exception = + NotAcceptableException(message: 'Custom message!'); + expect(exception.statusCode, 406); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a NotFoundException with custom message', () { + NotFoundException exception = NotFoundException(message: 'Custom message!'); + expect(exception.statusCode, 404); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a RequestTimeoutException with custom message', () { + RequestTimeoutException exception = + RequestTimeoutException(message: 'Custom message!'); + expect(exception.statusCode, 408); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a UnauthorizedException with custom message', () { + UnauthorizedException exception = + UnauthorizedException(message: 'Custom message!'); + expect(exception.statusCode, 401); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a UnprocessableEntityException with custom message', + () { + UnprocessableEntityException exception = + UnprocessableEntityException(message: 'Custom message!'); + expect(exception.statusCode, 422); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a UnsupportedMediaTypeException with custom message', + () { + UnsupportedMediaTypeException exception = + UnsupportedMediaTypeException(message: 'Custom message!'); + expect(exception.statusCode, 415); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a SerinusException with custom message', () { + SerinusException exception = + SerinusException(message: 'Custom message!', statusCode: 500); + expect(exception.statusCode, 500); + expect(exception.message, 'Custom message!'); + expect(exception.toString(), + '{"message":"Custom message!","statusCode":500,"uri":"No Uri"}'); + }); + + test('should instantiate a BadGatewayException with custom message', () { + BadGatewayException exception = + BadGatewayException(message: 'Custom message!'); + expect(exception.statusCode, 502); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a GatewayTimeoutException with custom message', () { + GatewayTimeoutException exception = + GatewayTimeoutException(message: 'Custom message!'); + expect(exception.statusCode, 504); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a ServiceUnavailableException with custom message', + () { + ServiceUnavailableException exception = + ServiceUnavailableException(message: 'Custom message!'); + expect(exception.statusCode, 503); + expect(exception.message, 'Custom message!'); + }); + + test( + 'should instantiate a HttpVersionNotSupportedException with custom message', + () { + HttpVersionNotSupportedException exception = + HttpVersionNotSupportedException(message: 'Custom message!'); + expect(exception.statusCode, 505); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a NotImplementedException with custom message', () { + NotImplementedException exception = + NotImplementedException(message: 'Custom message!'); + expect(exception.statusCode, 501); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a PayloadTooLargeException with custom message', () { + PayloadTooLargeException exception = + PayloadTooLargeException(message: 'Custom message!'); + expect(exception.statusCode, 413); + expect(exception.message, 'Custom message!'); + }); + + test('should instantiate a PreconditionFailedException with custom message', + () { + PreconditionFailedException exception = + PreconditionFailedException(message: 'Custom message!'); + expect(exception.statusCode, 412); + expect(exception.message, 'Custom message!'); + }); } diff --git a/packages/serinus/test/http/http.dart b/packages/serinus/test/http/http.dart deleted file mode 100644 index 719ef7f8..00000000 --- a/packages/serinus/test/http/http.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'responses_test.dart'; -import 'session_test.dart'; - -class HttpTestSuite { - static void runTests() { - ResponsesTestSuite.runTests(); - SessionsTestSuite.runTests(); - } -} diff --git a/packages/serinus/test/http/responses_test.dart b/packages/serinus/test/http/responses_test.dart index 79a13f71..4ac0ad9f 100644 --- a/packages/serinus/test/http/responses_test.dart +++ b/packages/serinus/test/http/responses_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:serinus/serinus.dart'; +import 'package:serinus/src/containers/router.dart'; import 'package:test/test.dart'; class TestRoute extends Route { @@ -47,106 +48,128 @@ class TestModule extends Module { {super.controllers, super.imports, super.providers, super.exports}); } -class ResponsesTestSuite { - static void runTests() { - group('$Response', () { - SerinusApplication? app; - setUpAll(() async { - app = await serinus.createApplication( - entrypoint: TestModule(controllers: [TestController()]), - loggingLevel: LogLevel.none); - await app?.serve(); - }); - tearDownAll(() async { - await app?.close(); - }); - test( - '''when a 'Response.text' is called, then it should return a text/plain response''', - () async { - final request = - await HttpClient().getUrl(Uri.parse('http://localhost:3000/text')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); +void main() async { + group('$Response', () { + SerinusApplication? app; + final controller = TestController(); + setUpAll(() async { + app = await serinus.createApplication( + entrypoint: TestModule(controllers: [controller]), + loggingLevel: LogLevel.none); + app?.enableCors(Cors()); + await app?.serve(); + }); + tearDownAll(() async { + await app?.close(); + }); + test( + '''when a 'Response.text' is called, then it should return a text/plain response''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/text')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect(response.headers.contentType?.mimeType, 'text/plain'); - expect(body, 'ok!'); - }); - test( - '''when a 'Response.json' is called, then it should return a application/json response''', - () async { - final request = - await HttpClient().getUrl(Uri.parse('http://localhost:3000/json')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + expect(response.headers.contentType?.mimeType, 'text/plain'); + expect(body, 'ok!'); + }); + test( + '''when a 'Response.json' is called, then it should return a application/json response''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/json')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect(response.headers.contentType?.mimeType, 'application/json'); - expect(jsonDecode(body), {'message': 'ok!'}); - }); - test( - '''when a 'Response.html' is called, then it should return a text/html response''', - () async { - final request = - await HttpClient().getUrl(Uri.parse('http://localhost:3000/html')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(jsonDecode(body), {'message': 'ok!'}); + }); + test( + '''when a 'Response.html' is called, then it should return a text/html response''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/html')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect(response.headers.contentType?.mimeType, 'text/html'); - expect(body, '

ok!

'); - }); - test( - '''when a 'Response.bytes' is called, then it should return a application/octet-stream response''', - () async { - final request = - await HttpClient().getUrl(Uri.parse('http://localhost:3000/bytes')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + expect(response.headers.contentType?.mimeType, 'text/html'); + expect(body, '

ok!

'); + }); + test( + '''when a 'Response.bytes' is called, then it should return a application/octet-stream response''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/bytes')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect( - response.headers.contentType?.mimeType, 'application/octet-stream'); - expect(body, '[111, 107, 33]'); - }); - test( - '''when a 'Response.file' is called, then it should return a application/octet-stream response''', - () async { - final request = - await HttpClient().getUrl(Uri.parse('http://localhost:3000/file')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + expect( + response.headers.contentType?.mimeType, 'application/octet-stream'); + expect(body, '[111, 107, 33]'); + }); + test( + '''when a 'Response.file' is called, then it should return a application/octet-stream response''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/file')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect( - response.headers.contentType?.mimeType, 'application/octet-stream'); - expect(body, '[111, 107, 33]'); - }); - test( - '''when a 'Response.redirect' is called, then the request should be redirected to the corresponding route''', - () async { - final request = await HttpClient() - .getUrl(Uri.parse('http://localhost:3000/redirect')); - final response = await request.close(); - expect(response.statusCode, 200); - final body = await response.transform(Utf8Decoder()).join(); - expect(body, 'ok!'); - }); - test( - '''when a 'Response.json' is called, and a JsonObject is provided, then the request should return a json object''', - () async { - final request = await HttpClient() - .getUrl(Uri.parse('http://localhost:3000/json-obj')); - final response = await request.close(); - expect(response.statusCode, 200); - final body = await response.transform(Utf8Decoder()).join(); - expect(body, '{"id":"json-obj"}'); - }); - test( - '''when a 'Response.json' is called, and a JsonObject is provided, then the request should return a json object''', - () async { - final request = await HttpClient() - .getUrl(Uri.parse('http://localhost:3000/status')); - final response = await request.close(); - expect(response.statusCode, 201); - final body = await response.transform(Utf8Decoder()).join(); - expect(body, 'test'); - }); + expect( + response.headers.contentType?.mimeType, 'application/octet-stream'); + expect(body, '[111, 107, 33]'); }); - } + test( + '''when a 'Response.redirect' is called, then the request should be redirected to the corresponding route''', + () async { + final request = await HttpClient() + .getUrl(Uri.parse('http://localhost:3000/redirect')); + final response = await request.close(); + expect(response.statusCode, 200); + final body = await response.transform(Utf8Decoder()).join(); + expect(body, 'ok!'); + }); + test( + '''when a 'Response.json' is called, and a JsonObject is provided, then the request should return a json object''', + () async { + final request = await HttpClient() + .getUrl(Uri.parse('http://localhost:3000/json-obj')); + final response = await request.close(); + expect(response.statusCode, 200); + final body = await response.transform(Utf8Decoder()).join(); + expect(body, '{"id":"json-obj"}'); + }); + test( + '''when a 'Response.json' is called, and a JsonObject is provided, then the request should return a json object''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3000/status')); + final response = await request.close(); + expect(response.statusCode, 201); + final body = await response.transform(Utf8Decoder()).join(); + expect(body, 'test'); + }); + test( + '''when a non-existent route is called, then it should return a 404 status code''', + () async { + final request = await HttpClient() + .getUrl(Uri.parse('http://localhost:3000/status-error')); + final response = await request.close(); + expect(response.statusCode, 404); + }); + test( + '''when a non-existent route is called, then it should return a 404 status code''', + () async { + app?.router.registerRoute(RouteData( + path: 'path-error', + method: HttpMethod.get, + controller: controller, + routeCls: TestRoute, + moduleToken: 'TestModule')); + final request = await HttpClient() + .getUrl(Uri.parse('http://localhost:3000/path-error')); + final response = await request.close(); + expect(response.statusCode, 500); + }); + }); } diff --git a/packages/serinus/test/http/session_test.dart b/packages/serinus/test/http/session_test.dart index c0c99f13..c0249aa8 100644 --- a/packages/serinus/test/http/session_test.dart +++ b/packages/serinus/test/http/session_test.dart @@ -39,48 +39,46 @@ class TestModule extends Module { {super.controllers, super.imports, super.providers, super.exports}); } -class SessionsTestSuite { - static void runTests() { - group('$Session', () { - SerinusApplication? app; - setUpAll(() async { - app = await serinus.createApplication( +Future main() async { + group('$Session', () { + SerinusApplication? app; + setUpAll(() async { + app = await serinus.createApplication( entrypoint: TestModule(controllers: [TestController()]), loggingLevel: LogLevel.none, - ); - await app?.serve(); - }); - tearDownAll(() async { - await app?.close(); - }); - test( - '''when the first request of session is handled, then the session should be new''', - () async { - final request = await HttpClient() - .getUrl(Uri.parse('http://localhost:3000/session')); - final response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + port: 3001); + await app?.serve(); + }); + tearDownAll(() async { + await app?.close(); + }); + test( + '''when the first request of session is handled, then the session should be new''', + () async { + final request = + await HttpClient().getUrl(Uri.parse('http://localhost:3001/session')); + final response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect(response.headers.contentType?.mimeType, 'application/json'); - expect(jsonDecode(body), {'isSessionNew': true}); - }); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(jsonDecode(body), {'isSessionNew': true}); + }); - test( - '''when the second request of session is handled, then the session should not be new''', - () async { - var request = await HttpClient() - .getUrl(Uri.parse('http://localhost:3000/session')); - var response = await request.close(); - request = await HttpClient().getUrl( - Uri.parse('http://localhost:3000/session'), - ); - request.headers.add('Cookie', response.headers['set-cookie']!); - response = await request.close(); - final body = await response.transform(Utf8Decoder()).join(); + test( + '''when the second request of session is handled, then the session should not be new''', + () async { + var request = + await HttpClient().getUrl(Uri.parse('http://localhost:3001/session')); + var response = await request.close(); + request = await HttpClient().getUrl( + Uri.parse('http://localhost:3001/session'), + ); + request.headers.add('Cookie', response.headers['set-cookie']!); + response = await request.close(); + final body = await response.transform(Utf8Decoder()).join(); - expect(response.headers.contentType?.mimeType, 'application/json'); - expect(jsonDecode(body), {'isSessionNew': false}); - }); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(jsonDecode(body), {'isSessionNew': false}); }); - } + }); } diff --git a/packages/serinus/test/injector/explorer_test.dart b/packages/serinus/test/injector/explorer_test.dart new file mode 100644 index 00000000..daabb547 --- /dev/null +++ b/packages/serinus/test/injector/explorer_test.dart @@ -0,0 +1,117 @@ +import 'package:serinus/serinus.dart'; +import 'package:serinus/src/containers/router.dart'; +import 'package:serinus/src/injector/explorer.dart'; +import 'package:test/test.dart'; + +import '../mocks/controller_mock.dart'; +import '../mocks/module_mock.dart'; + +final config = ApplicationConfig( + host: 'localhost', + port: 3000, + poweredByHeader: 'Powered by Serinus', + securityContext: null, + serverAdapter: SerinusHttpServer()); + +void main() { + group('$Explorer', () { + test( + 'when the application startup, then the controller can be walked through to register all the routes', + () async { + final router = Router(); + final modulesContainer = ModulesContainer(config); + await modulesContainer.registerModule( + SimpleMockModule(controllers: [MockController()]), SimpleMockModule); + final explorer = Explorer(modulesContainer, router, config); + explorer.resolveRoutes(); + expect(router.routes.length, 1); + }); + + test( + 'when the application startup, and a controller has not a static path, then the explorer will throw an error', + () async { + final router = Router(); + final modulesContainer = ModulesContainer(config); + await modulesContainer.registerModule( + SimpleMockModule(controllers: [MockControllerWithWrongPath()]), + SimpleMockModule); + final explorer = Explorer(modulesContainer, router, config); + expect(() => explorer.resolveRoutes(), throwsException); + }); + + test( + 'when a path without leading slash is passed, then the path will be normalized', + () { + final explorer = Explorer(ModulesContainer(config), Router(), config); + final path = 'test'; + final normalizedPath = explorer.normalizePath(path); + expect(normalizedPath, '/test'); + }); + + test( + 'when a path with multiple slashes is passed, then the path will be normalized', + () { + final explorer = Explorer(ModulesContainer(config), Router(), config); + final path = '/test//test'; + final normalizedPath = explorer.normalizePath(path); + expect(normalizedPath, '/test/test'); + }); + + test( + 'when the $VersioningOptions is set to uri, then the route path will be prefixed with the version', + () async { + config.versioningOptions = + VersioningOptions(type: VersioningType.uri, version: 1); + final router = Router(); + final modulesContainer = ModulesContainer(config); + await modulesContainer.registerModule( + SimpleMockModule(controllers: [MockController()]), SimpleMockModule); + final explorer = Explorer(modulesContainer, router, config); + explorer.resolveRoutes(); + final result = router.getRouteByPathAndMethod('/v1', HttpMethod.get); + expect(result.route?.path, '/v1/'); + }); + + test( + 'when the $GlobalPrefix is set, then the route path will be prefixed with the global prefix', + () async { + final config = ApplicationConfig( + host: 'localhost', + port: 3000, + poweredByHeader: 'Powered by Serinus', + securityContext: null, + serverAdapter: SerinusHttpServer()); + config.globalPrefix = GlobalPrefix(prefix: 'api'); + final router = Router(); + final modulesContainer = ModulesContainer(config); + await modulesContainer.registerModule( + SimpleMockModule(controllers: [MockController()]), SimpleMockModule); + final explorer = Explorer(modulesContainer, router, config); + explorer.resolveRoutes(); + final result = router.getRouteByPathAndMethod('/api', HttpMethod.get); + expect(result.route?.path, '/api/'); + }); + + test( + 'when the $GlobalPrefix and $VersioningOptions are set, then the route path will be prefixed with the global prefix and the version', + () async { + final config = ApplicationConfig( + host: 'localhost', + port: 3000, + poweredByHeader: 'Powered by Serinus', + securityContext: null, + serverAdapter: SerinusHttpServer()); + config.globalPrefix = GlobalPrefix(prefix: 'api'); + config.versioningOptions = + VersioningOptions(type: VersioningType.uri, version: 1); + final router = Router(); + final modulesContainer = ModulesContainer(config); + await modulesContainer.registerModule( + SimpleMockModule(controllers: [MockController()]), SimpleMockModule); + final explorer = Explorer(modulesContainer, router, config); + explorer.resolveRoutes(); + final result = router.getRouteByPathAndMethod('/api/v1', HttpMethod.get); + expect(result.route?.path, '/api/v1/'); + }); + }); +} diff --git a/packages/serinus/test/serinus.dart b/packages/serinus/test/serinus.dart deleted file mode 100644 index 2c815a6d..00000000 --- a/packages/serinus/test/serinus.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'commons/form_data_test.dart'; -import 'commons/versioning_test.dart'; -import 'core/containers/router.dart'; -import 'core/contexts.dart'; -import 'core/controller.dart'; -import 'core/injector/explorer_test.dart'; -import 'core/module.dart'; -import 'core/provider.dart'; -import 'exceptions_test.dart'; -import 'http/http.dart'; - -void main() { - ControllerTestSuite.runTests(); - ExplorerTestsSuite.runTests(); - ExceptionsTestSuite.runTests(); - ProviderTestSuite.runTests(); - ModuleTestSuite.runTests(); - RouterTestSuite.runTests(); - ContextsTestSuite.runTests(); - HttpTestSuite.runTests(); - FormDataTestSuites.runTests(); - VersioningTestsSuite.runTests(); -} diff --git a/packages/serinus_cli/CHANGELOG.md b/packages/serinus_cli/CHANGELOG.md index f584a8a7..fd6a39b5 100644 --- a/packages/serinus_cli/CHANGELOG.md +++ b/packages/serinus_cli/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.2.3 + +- Re-add the dynamic fetch of the serinus version in the create command. + +## 0.2.2 + +- Fix the create command. + ## 0.2.1 - Fix the create command to use the pre and post gen hooks. diff --git a/packages/serinus_cli/example/example.md b/packages/serinus_cli/example/example.md new file mode 100644 index 00000000..d40fb46d --- /dev/null +++ b/packages/serinus_cli/example/example.md @@ -0,0 +1,10 @@ +# Example + +The following example shows how to create a simple Serinus application using the CLI. + +```bash +dart pub global activate serinus_cli +serinus create my_app +cd my_app +dart run +``` diff --git a/packages/serinus_cli/lib/src/commands/create/create_command.dart b/packages/serinus_cli/lib/src/commands/create/create_command.dart index 2974ba03..f315b70b 100644 --- a/packages/serinus_cli/lib/src/commands/create/create_command.dart +++ b/packages/serinus_cli/lib/src/commands/create/create_command.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; @@ -58,9 +59,9 @@ class CreateCommand extends Command { ); final generator = await MasonGenerator.fromBrick(brick); final progress = _logger?.progress( - 'Generation a new Serinus Application [$projectName]', + 'Generating a new Serinus Application [$projectName]', ); - var vars = { + final vars = { 'name': projectName, 'output': outputDirectory.absolute.path, 'description': 'A simple Serinus application', @@ -68,24 +69,36 @@ class CreateCommand extends Command { if(!outputDirectory.existsSync()){ outputDirectory.createSync(recursive: true); } - await generator.hooks.preGen( - workingDirectory: outputDirectory.absolute.path, - vars: vars, - onVarsChanged: (newVars) { - vars = { - ...newVars, - }; - } - ); + _logger?.success('Directory created at ${outputDirectory.absolute.path}'); + progress?.update('Fetching latest version of serinus package...'); + try{ + vars['serinus_version'] = await getSerinusVersion(); + }catch(e){ + _logger?.err('''Failed to fetch latest version of serinus package, you will need to update it manually'''); + rethrow; + } + _logger?.success('Pre-gen hooks executed successfully'); + progress?.update('Generating files...'); await generator.generate( DirectoryGeneratorTarget(outputDirectory), vars: vars, ); - await generator.hooks.postGen( - workingDirectory: outputDirectory.absolute.path, - vars: vars, - ); + _logger?.success('Files generated successfully'); + // progress?.update('Executing post-gen hooks...'); + // await generator.hooks.postGen( + // workingDirectory: outputDirectory.absolute.path, + // vars: vars, + // logger: _logger + // ); + // _logger?.success('Post-gen hooks executed successfully'); progress?.complete(); + + _logger?.info( + 'Run the following commands to get started:\n\n' + 'cd ${outputDirectory.absolute.path}\n' + 'dart pub get\n' + 'serinus run\n', + ); return ExitCode.success.code; } @@ -133,4 +146,17 @@ class CreateCommand extends Command { final match = _identifierRegExp.matchAsPrefix(name); return match != null && match.end == name.length; } + + Future getSerinusVersion() async { + final client = HttpClient(); + final req = await client.getUrl(Uri.parse('https://pub.dev/api/packages/serinus')); + final res = await req.close(); + if(res.statusCode != 200){ + throw Exception('Failed to fetch serinus package'); + } + final body = await res.transform(utf8.decoder).join(); + final package = jsonDecode(body); + final version = package['latest']['version'] as String; + return version; + } } diff --git a/packages/serinus_cli/lib/src/version.dart b/packages/serinus_cli/lib/src/version.dart index 0a9ef8e5..0399482f 100644 --- a/packages/serinus_cli/lib/src/version.dart +++ b/packages/serinus_cli/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '0.2.0'; +const packageVersion = '0.2.3'; diff --git a/packages/serinus_cli/pubspec.yaml b/packages/serinus_cli/pubspec.yaml index d6e77578..d35ff229 100644 --- a/packages/serinus_cli/pubspec.yaml +++ b/packages/serinus_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: serinus_cli description: The official CLI to manage and create Serinus projects. -version: 0.2.1 +version: 0.2.3 repository: 'https://github.com/francescovallone/serinus' homepage: https://docs.serinus.app