├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── benchmark.yaml │ ├── dart-tests.yaml │ ├── publish.yaml │ └── title-validation.yaml ├── .gitignore ├── .pubignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PUBLISH.md ├── README.md ├── analysis_options.yaml ├── benchmark └── benchmark.dart ├── example ├── example.dart └── multi_isolate.dart ├── lib ├── io_adapter.dart ├── relic.dart └── src │ ├── adapter │ ├── adapter.dart │ ├── context.dart │ ├── duplex_stream_channel.dart │ └── io │ │ ├── bind_http_server.dart │ │ ├── http_response_extension.dart │ │ ├── io_adapter.dart │ │ ├── io_serve.dart │ │ ├── request.dart │ │ └── response.dart │ ├── body │ ├── body.dart │ └── types │ │ ├── body_type.dart │ │ └── mime_type.dart │ ├── handler │ ├── cascade.dart │ ├── handler.dart │ └── pipeline.dart │ ├── headers │ ├── codecs │ │ └── common_types_codecs.dart │ ├── exception │ │ └── header_exception.dart │ ├── extension │ │ └── string_list_extensions.dart │ ├── header_accessor.dart │ ├── headers.dart │ ├── mutable_headers.dart │ ├── standard_headers_extensions.dart │ └── typed │ │ ├── headers │ │ ├── accept_encoding_header.dart │ │ ├── accept_header.dart │ │ ├── accept_language_header.dart │ │ ├── accept_ranges_header.dart │ │ ├── access_control_allow_headers_header.dart │ │ ├── access_control_allow_methods_header.dart │ │ ├── access_control_allow_origin_header.dart │ │ ├── access_control_expose_headers_header.dart │ │ ├── authentication_header.dart │ │ ├── authorization_header.dart │ │ ├── cache_control_header.dart │ │ ├── clear_site_data_header.dart │ │ ├── connection_header.dart │ │ ├── content_disposition_header.dart │ │ ├── content_encoding_header.dart │ │ ├── content_language_header.dart │ │ ├── content_range_header.dart │ │ ├── content_security_policy_header.dart │ │ ├── cookie_header.dart │ │ ├── cross_origin_embedder_policy_header.dart │ │ ├── cross_origin_opener_policy_header.dart │ │ ├── cross_origin_resource_policy_header.dart │ │ ├── etag_condition_header.dart │ │ ├── etag_header.dart │ │ ├── expect_header.dart │ │ ├── from_header.dart │ │ ├── if_range_header.dart │ │ ├── permission_policy_header.dart │ │ ├── range_header.dart │ │ ├── referrer_policy_header.dart │ │ ├── retry_after_header.dart │ │ ├── sec_fetch_dest_header.dart │ │ ├── sec_fetch_mode_header.dart │ │ ├── sec_fetch_site_header.dart │ │ ├── set_cookie_header.dart │ │ ├── strict_transport_security_header.dart │ │ ├── te_header.dart │ │ ├── transfer_encoding_header.dart │ │ ├── upgrade_header.dart │ │ ├── util │ │ │ └── cookie_util.dart │ │ └── vary_header.dart │ │ └── typed_headers.dart │ ├── io │ └── static │ │ ├── directory_listing.dart │ │ ├── extension │ │ └── datetime_extension.dart │ │ └── static_handler.dart │ ├── logger │ └── logger.dart │ ├── message │ ├── message.dart │ ├── request.dart │ └── response.dart │ ├── method │ └── request_method.dart │ ├── middleware │ ├── middleware.dart │ ├── middleware_extensions.dart │ ├── middleware_logger.dart │ └── routing_middleware.dart │ ├── relic_server.dart │ ├── router │ ├── lookup_result.dart │ ├── lru_cache.dart │ ├── normalized_path.dart │ ├── path_trie.dart │ └── router.dart │ └── util │ └── util.dart ├── misc └── images │ └── github-banner.jpg ├── pubspec.yaml └── test ├── exception └── relic_exceptions_test.dart ├── handler ├── cascade_test.dart └── pipeline_test.dart ├── headers ├── basic │ ├── access_control_allow_credentials_header_test.dart │ ├── access_control_max_age_header_test.dart │ ├── access_control_request_headers_heder_test.dart │ ├── age_header_test.dart │ ├── allow_header_test.dart │ ├── content_location_header_test.dart │ ├── date_header_test.dart │ ├── expires_header_test.dart │ ├── host_header_test.dart │ ├── if_modified_since_header_test.dart │ ├── if_unmodified_since_header_test.dart │ ├── last_modified_header.dart │ ├── location_header_test.dart │ ├── max_forwards_header_test.dart │ ├── origin_header_test.dart │ ├── referer_header_test.dart │ ├── server_header_test.dart │ ├── trailer_header_test.dart │ ├── user_agent_header_test.dart │ ├── via_header_test.dart │ └── x_powered_by_header_test.dart ├── docs │ └── strict_validation_docs.dart ├── header_test.dart ├── headers_accessor_test.dart ├── headers_test_utils.dart ├── mutable_headers_test.dart └── typed_headers │ ├── accept_encoding_header_test.dart │ ├── accept_header_test.dart │ ├── accept_language_test.dart │ ├── accept_ranges_header_test.dart │ ├── access_control_allow_headers_header_test.dart │ ├── access_control_allow_methods_header_test.dart │ ├── access_control_allow_origin_header_test.dart │ ├── access_control_expose_headers_header_test.dart │ ├── access_control_request_method_header_test.dart │ ├── authorization_header_test.dart │ ├── cache_control_header_test.dart │ ├── clear_site_data_header_test.dart │ ├── connection_header_test.dart │ ├── content_disposition_header_test.dart │ ├── content_encoding_header_test.dart │ ├── content_language_header_test.dart │ ├── content_range_header_test.dart │ ├── content_security_policy_header_test.dart │ ├── cookie_header_test.dart │ ├── cross_origin_embedder_policy_header_test.dart │ ├── cross_origin_opener_policy_header_test.dart │ ├── cross_origin_resource_policy_header_test.dart │ ├── etag_header_test.dart │ ├── expect_header_test.dart │ ├── from_header_test.dart │ ├── if_match_header_test.dart │ ├── if_none_match_header_test.dart │ ├── if_range_header_test.dart │ ├── permissions_policy_header_test.dart │ ├── proxy_authenticate_header_test.dart │ ├── proxy_authorization_header_test.dart │ ├── range_header_test.dart │ ├── referrer_policy_header_test.dart │ ├── retry_after_header_test.dart │ ├── sec_fetch_dest_header_test.dart │ ├── sec_fetch_mode_header_test.dart │ ├── sec_fetch_site_header_test.dart │ ├── set_cookie_header_test.dart │ ├── strict_transport_security_header_test.dart │ ├── te_header_test.dart │ ├── transfer_encoding_header_test.dart │ ├── upgrade_header_test.dart │ ├── vary_header_test.dart │ └── www_authenticate_header_test.dart ├── hijack └── relic_hijack_test.dart ├── message ├── apply_headers_test.dart ├── message_test.dart ├── request_test.dart └── response_test.dart ├── middleware ├── create_middleware_test.dart ├── log_middleware_test.dart └── routing_middleware_test.dart ├── relic_server_serve_test.dart ├── relic_server_test.dart ├── router ├── lru_cache_test.dart ├── normalized_path_test.dart ├── path_trie_crud_test.dart ├── path_trie_tail_test.dart ├── path_trie_test.dart ├── path_trie_wildcard_test.dart ├── router_methods_test.dart └── router_test.dart ├── ssl └── ssl_certs.dart ├── static ├── alternative_root_test.dart ├── basic_file_test.dart ├── create_file_handler_test.dart ├── default_document_test.dart ├── directory_listing_test.dart ├── get_handler_test.dart ├── symbolic_link_test.dart └── test_util.dart ├── util └── test_util.dart └── web_socket └── web_socket_test.dart /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or unexpected behavior you encountered. 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | When reporting a bug, please complete this template thoroughly to help us address the issue effectively! 9 | 10 | - type: textarea 11 | id: description-of-bug 12 | attributes: 13 | label: Describe the bug 14 | description: A clear and concise description of what the bug is. 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To reproduce 22 | description: Steps to reproduce the behavior. 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: expectation 28 | attributes: 29 | label: Expected behavior 30 | description: A clear and concise description of what you expected to happen. 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: library-version 36 | attributes: 37 | label: Library version 38 | description: Library and dart version where the bug was found. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: platforms 44 | attributes: 45 | label: Platform information 46 | description: Information about the platform where the bug was found. 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: additional-context 52 | attributes: 53 | label: Additional context 54 | description: Share any additional context or information about the bug. 55 | 56 | - type: dropdown 57 | id: experience 58 | attributes: 59 | label: How experienced are you with this library? 60 | multiple: false 61 | description: This helps us understand where in the user journey this issue might arise. 62 | options: 63 | - Beginner - Just getting started with this library 64 | - Intermediate - Familiar with the basics or have used it in a few projects 65 | - Expert - Experienced and comfortable with using this library in complex projects 66 | 67 | - type: checkboxes 68 | id: terms 69 | attributes: 70 | label: Are you interested in working on a PR for this? 71 | options: 72 | - label: I want to work on this 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for Relic. 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | When suggesting a feature, please read this complete form and fill in all the fields to ensure we understand your idea thoroughly! 9 | 10 | - type: textarea 11 | id: problem-to-solve 12 | attributes: 13 | label: Problem to Solve 14 | description: What problem are you trying to solve with this feature? 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: proposal 20 | attributes: 21 | label: Proposal 22 | description: What is your proposed solution? Add as much detail as possible, including code examples or references. 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: use-case 28 | attributes: 29 | label: Use Case 30 | description: How would this feature be used in a real-world scenario? Provide an example if possible. 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: alternatives 36 | attributes: 37 | label: Alternatives 38 | description: Are there any alternative solutions or features you've considered? If so, what are they? 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: additional-context 44 | attributes: 45 | label: Additional context 46 | description: Share any additional context or information about the feature. 47 | 48 | - type: dropdown 49 | id: experience 50 | attributes: 51 | label: How experienced are you with this library? 52 | multiple: false 53 | description: This helps us understand where in the user journey this issue might arise. 54 | options: 55 | - Beginner - Just getting started with this library 56 | - Intermediate - Familiar with the basics or have used it in a few projects 57 | - Expert - Experienced and comfortable with using this library in complex projects 58 | 59 | - type: checkboxes 60 | id: terms 61 | attributes: 62 | label: Are you interested in working on a PR for this? 63 | options: 64 | - label: I want to work on this 65 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pub" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | versioning-strategy: "widen" 8 | # Limit open pull requests to 5 9 | open-pull-requests-limit: 5 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | _Provide a detailed description of the changes in this PR, including what it changes or adds, and why it is necessary._ 3 | 4 | ## Related Issues 5 | _Link any related issues here. If this PR fixes an issue, use the following syntax to close it automatically:_ 6 | - (Closes/Fixes): # 7 | 8 | ## Pre-Launch Checklist 9 | Please ensure that your PR meets the following requirements before submitting: 10 | 11 | - [ ] This update focuses on a single feature or bug fix. (For multiple fixes, please submit separate PRs.) 12 | - [ ] I have read and followed the [Dart Style Guide](https://dart.dev/guides/language/effective-dart/style) and formatted the code using [dart format](https://dart.dev/tools/dart-format). 13 | - [ ] I have referenced at least one issue this PR fixes or is related to. 14 | - [ ] I have updated/added relevant documentation (doc comments with `///`), ensuring consistency with existing project documentation. 15 | - [ ] I have added new tests to verify the changes. 16 | - [ ] All existing and new tests pass successfully. 17 | - [ ] I have documented any breaking changes below. 18 | 19 | ## Breaking Changes 20 | - [ ] Includes breaking changes. 21 | - [ ] No breaking changes. 22 | 23 | _If there are breaking changes, clearly outline them here to ensure they are included in the release notes:_ 24 | 25 | ## Additional Notes 26 | _Include any additional information or context about the PR here, if needed._ 27 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yaml: -------------------------------------------------------------------------------- 1 | name: Benchmark and Store as Git Notes 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "0 0 * * 0" # every Sunday at 00:00 UTC 10 | workflow_dispatch: # allow manual triggering 11 | 12 | env: 13 | PUB_CACHE_PATH: ~/.pub-cache 14 | 15 | jobs: 16 | benchmark: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Dart 23 | uses: dart-lang/setup-dart@v1.3 24 | with: 25 | sdk: stable 26 | 27 | - name: Cache Dart Dependencies 28 | uses: actions/cache@v4 29 | with: 30 | path: ${{ env.PUB_CACHE_PATH }} 31 | key: ${{ runner.os }}-pub-cache-stable 32 | restore-keys: ${{ runner.os }}-pub-cache- 33 | 34 | - name: Configure Git 35 | run: | 36 | git config --global user.name "GitHub Actions" 37 | git config --global user.email "actions@github.com" 38 | 39 | - name: Fetch Existing Benchmark Data 40 | run: git fetch origin refs/notes/benchmarks:refs/notes/benchmarks 41 | 42 | - name: Run Benchmark 43 | id: run-benchmark 44 | run: | 45 | dart compile exe benchmark/benchmark.dart 46 | ./benchmark/benchmark.exe run --store-in-git-notes --verbose 47 | 48 | - name: Push Benchmark Data 49 | run: git push origin refs/notes/benchmarks # This 50 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Relic 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+*' # Matches tags like v1.2.3 and v1.2.3-pre.1 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | id-token: write 12 | uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 13 | -------------------------------------------------------------------------------- /.github/workflows/title-validation.yaml: -------------------------------------------------------------------------------- 1 | # See https://github.com/amannn/action-semantic-pull-request 2 | name: 'PR Title is Conventional' 3 | 4 | on: 5 | pull_request_target: 6 | types: 7 | - opened 8 | - edited 9 | - synchronize 10 | 11 | jobs: 12 | main: 13 | name: Validate PR title 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: amannn/action-semantic-pull-request@v5 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | with: 20 | types: | 21 | build 22 | chore 23 | ci 24 | docs 25 | feat 26 | fix 27 | perf 28 | refactor 29 | revert 30 | style 31 | test 32 | subjectPattern: ^[A-Z].+$ 33 | subjectPatternError: | 34 | The subject of the PR must begin with an uppercase letter. 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dart tool and build artifacts 2 | .dart_tool/ 3 | .pub/ 4 | build/ 5 | coverage/ 6 | pubspec.lock 7 | 8 | # macOS system files 9 | .DS_Store 10 | 11 | # IDE and editor configurations 12 | .atom/ 13 | .idea/ 14 | .vscode/ 15 | .vscode-test/ 16 | 17 | *.exe 18 | benchmark_results.csv 19 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | test/ssl_certs.dart -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 2 | 3 | - feat: Implements lazy loading when parsing headers to avoid unnecessary validation. 4 | - feat: Makes address strongly typed and adds `RelicAddress` type. 5 | - fix: Resolves issue with `Content-Length` header conflicting with `Transfer-Encoding: chunked`. 6 | 7 | ## 0.2.0 8 | 9 | - First tech preview. 10 | 11 | ## 0.1.0 12 | 13 | - Initial version. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014, the Dart project authors. 2 | Copyright 2015, the Dart project authors. 3 | Copyright 2024, the Serverpod project authors. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | * Neither the name of Google LLC nor the names of its 16 | contributors may be used to endorse or promote products derived 17 | from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /PUBLISH.md: -------------------------------------------------------------------------------- 1 | # How to publish Relic 2 | 3 | To publish this package, simply create a new tag and push it to the repository. The GitHub action will automatically build and publish the package to pub.dev. 4 | 5 | The tag needs to be in the format `vX.Y.Z`, where `X`, `Y`, and `Z` are integers. The version number should be incremented according to the [Semantic Versioning](https://semver.org/) rules. 6 | 7 | It is also possible to publish a pre-release version by adding a suffix to the version number. For example, `v1.0.0-dev.1` is a pre-release version of `v1.0.0`. 8 | 9 | ## Prepare the release 10 | 11 | Before publishing a new version, follow these steps to prepare the release: 12 | 1. Make sure to update the version number in the `pubspec.yaml` file. The version number should be incremented according to the [Semantic Versioning](https://semver.org/) rules. 13 | 2. Update the `CHANGELOG.md` file with the changes made in the new version. This is the changelog that will be displayed on pub.dev. 14 | 3. Merge the changes to the `main` branch and then create a tag based on that commit. 15 | 16 | ## Create a new tag 17 | 18 | The preferred way to create a new tag is to use GitHub's interface to create a new release. 19 | 20 | GitHub will suggest the latest commit on the `main` branch as the target for the tag. Make sure to select the correct commit. 21 | 22 | An automatically generated changelog will be created for the release. This changelog is not used by pub.dev. Instead, the `CHANGELOG.md` file is our source of truth. Therefore, the changelog generated by GitHub can be left as is. 23 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:serverpod_lints/public.yaml 2 | 3 | linter: 4 | rules: 5 | avoid_relative_lib_imports: true 6 | prefer_relative_imports: true 7 | public_member_api_docs: false # TODO: This is temporary until we get closer to release 8 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:relic/io_adapter.dart'; 4 | import 'package:relic/relic.dart'; 5 | 6 | /// A simple 'Hello World' server 7 | Future main() async { 8 | // Setup router 9 | final router = Router()..get('/user/:name/age/:age', hello); 10 | 11 | // Setup a handler. 12 | // 13 | // A [Handler] is function consuming [NewContext]s and producing [HandledContext]s, 14 | // but if you are mostly concerned with converting [Request]s to [Response]s 15 | // (known as a [Responder] in relic parlor) you can use [respondWith] to 16 | // wrap a [Responder] into a [Handler] 17 | final handler = const Pipeline() 18 | .addMiddleware(logRequests()) 19 | .addMiddleware(routeWith(router)) 20 | .addHandler(respondWith((final _) => Response.notFound( 21 | body: Body.fromString("Sorry, that doesn't compute")))); 22 | 23 | // Start the server with the handler 24 | await serve(handler, InternetAddress.anyIPv4, 8080); 25 | 26 | // Check the _example_ directory for other examples. 27 | } 28 | 29 | ResponseContext hello(final NewContext ctx) { 30 | final name = ctx.pathParameters[#name]; 31 | final age = int.parse(ctx.pathParameters[#age]!); 32 | 33 | return ctx.withResponse(Response.ok( 34 | body: Body.fromString('Hello $name! To think you are $age years old.'))); 35 | } 36 | -------------------------------------------------------------------------------- /example/multi_isolate.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | import 'dart:io'; 3 | import 'dart:isolate'; 4 | 5 | import 'package:relic/io_adapter.dart'; 6 | import 'package:relic/relic.dart'; 7 | 8 | void main() async { 9 | // The number of isolates to use. Making it proportional to number of 10 | // processors ensure we have enough isolates to utilize the hardware. 11 | final isolateCount = Platform.numberOfProcessors * 2; 12 | 13 | // Wait for all the isolates to spawn 14 | print('Starting $isolateCount isolates'); 15 | final isolates = await Future.wait(List.generate( 16 | isolateCount, 17 | (final index) => 18 | Isolate.spawn((final _) => _serve(), null, debugName: '$index'))); 19 | 20 | // Wait for Ctrl-C before proceeding 21 | await ProcessSignal.sigint.watch().first; 22 | 23 | // Shutdown again. 24 | for (final i in isolates) { 25 | i.kill(priority: Isolate.immediate); 26 | } 27 | } 28 | 29 | /// [_serve] is called in each spawned isolate. 30 | Future _serve() async { 31 | /// 32 | final handler = const Pipeline() 33 | .addMiddleware(logRequests()) // setup logging 34 | .addHandler(respondWith(_echoRequest)); // add our handler 35 | 36 | // start the server 37 | await serve(handler, InternetAddress.anyIPv4, 8080, shared: true); 38 | print('serving on ${Isolate.current.debugName}'); 39 | } 40 | 41 | /// [_echoRequest] just echoes the path of the request 42 | Response _echoRequest(final Request request) { 43 | sleep(const Duration(seconds: 1)); // pretend to be really slow 44 | return Response.ok( 45 | body: Body.fromString( 46 | 'Request for "${request.url}" handled by isolate ${Isolate.current.debugName}', 47 | ), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/io_adapter.dart: -------------------------------------------------------------------------------- 1 | export 'src/adapter/io/io_adapter.dart'; 2 | export 'src/adapter/io/io_serve.dart'; 3 | -------------------------------------------------------------------------------- /lib/relic.dart: -------------------------------------------------------------------------------- 1 | library; 2 | 3 | export 'src/adapter/adapter.dart'; 4 | export 'src/adapter/context.dart' hide RequestInternal; 5 | export 'src/body/body.dart' show Body; 6 | export 'src/body/types/body_type.dart' show BodyType; 7 | export 'src/body/types/mime_type.dart' show MimeType; 8 | export 'src/handler/cascade.dart' show Cascade; 9 | export 'src/handler/handler.dart'; 10 | export 'src/handler/pipeline.dart' show Pipeline; 11 | export 'src/headers/exception/header_exception.dart' 12 | show HeaderException, InvalidHeaderException, MissingHeaderException; 13 | export 'src/headers/header_accessor.dart'; 14 | export 'src/headers/headers.dart'; 15 | export 'src/headers/standard_headers_extensions.dart'; 16 | export 'src/headers/typed/typed_headers.dart'; 17 | export 'src/io/static/static_handler.dart'; 18 | export 'src/message/request.dart' show Request; 19 | export 'src/message/response.dart' show Response; 20 | export 'src/method/request_method.dart' show RequestMethod; 21 | export 'src/middleware/middleware.dart' show Middleware, createMiddleware; 22 | export 'src/middleware/middleware_extensions.dart' show MiddlewareExtensions; 23 | export 'src/middleware/middleware_logger.dart' show logRequests; 24 | export 'src/middleware/routing_middleware.dart'; 25 | export 'src/relic_server.dart'; 26 | export 'src/router/lookup_result.dart'; 27 | export 'src/router/router.dart'; 28 | -------------------------------------------------------------------------------- /lib/src/adapter/duplex_stream_channel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | import 'package:stream_channel/stream_channel.dart'; 4 | 5 | /// An abstract class representing a bi-directional communication channel. 6 | /// 7 | /// This class mixes in [StreamChannelMixin] and implements [StreamChannel] 8 | /// for [Payload] types. It defines a common interface for duplex streams, 9 | /// such as WebSockets, allowing for sending and receiving [Payload] messages. 10 | abstract class DuplexStreamChannel 11 | with StreamChannelMixin 12 | implements StreamChannel { 13 | /// The interval at which ping messages are sent to keep the connection alive. 14 | /// 15 | /// If null, no ping messages are sent. 16 | Duration? pingInterval; 17 | 18 | /// Closes the duplex stream channel. 19 | /// 20 | /// Implementations should gracefully terminate the connection. 21 | /// Optionally, a [closeCode] and [closeReason] can be provided, 22 | /// which may be used by the underlying protocol (e.g., WebSocket close frames). 23 | Future close([final int? closeCode, final String? closeReason]); 24 | } 25 | 26 | /// A sealed class representing the types of messages that can be sent or received 27 | /// over a [DuplexStreamChannel]. 28 | /// 29 | /// This allows for type-safe handling of different message formats, 30 | /// like binary or text. 31 | sealed class Payload { 32 | const Payload(); 33 | } 34 | 35 | /// A [Payload] representing a binary message. 36 | /// 37 | /// Contains raw byte data. 38 | final class BinaryPayload extends Payload { 39 | /// The binary data of this payload. 40 | final Uint8List data; 41 | 42 | /// Creates a new [BinaryPayload] with the given [data]. 43 | const BinaryPayload(this.data); 44 | } 45 | 46 | /// A [Payload] representing a text message. 47 | /// 48 | /// Contains string data. 49 | final class TextPayload extends Payload { 50 | /// The text data of this payload. 51 | final String data; 52 | 53 | /// Creates a new [TextPayload] with the given [data]. 54 | const TextPayload(this.data); 55 | } 56 | 57 | /// A callback function that handles a [DuplexStreamChannel]. 58 | /// 59 | /// This is typically used when a connection is established (e.g., a WebSocket 60 | /// connection), providing the handler with the channel to manage communication. 61 | typedef DuplexStreamCallback = void Function(DuplexStreamChannel channel); 62 | -------------------------------------------------------------------------------- /lib/src/adapter/io/bind_http_server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io' as io; 3 | 4 | /// Binds an HTTP server to the given [address] and [port]. 5 | /// 6 | /// If [context] is provided, a secure HTTPS server will be started using 7 | /// [io.HttpServer.bindSecure]. Otherwise, an HTTP server will be started 8 | /// using [io.HttpServer.bind]. 9 | /// 10 | /// - [address]: The [io.InternetAddress] to bind the server to. 11 | /// - [port]: The port number to listen on. Defaults to 0, which means 12 | /// the operating system will assign an available port. 13 | /// - [context]: An optional [io.SecurityContext] for HTTPS. If null, HTTP is used. 14 | /// - [backlog]: The maximum length of the queue for incoming connections. 15 | /// Defaults to 0 (system-dependent). 16 | /// - [v6Only]: Whether to only accept IPv6 connections. This is only 17 | /// meaningful for IPv6 addresses. Defaults to false. 18 | /// - [shared]: Whether to allow multiple `HttpServer` objects to bind to the 19 | /// same combination of [address], [port] and [v6Only]. Defaults to false. 20 | /// 21 | /// Returns a [Future] that completes with the bound [io.HttpServer]. 22 | Future bindHttpServer( 23 | final io.InternetAddress address, { 24 | final int port = 0, 25 | final io.SecurityContext? context, 26 | final int backlog = 0, 27 | final bool v6Only = false, 28 | final bool shared = false, 29 | }) async { 30 | if (context == null) { 31 | return await io.HttpServer.bind( 32 | address, 33 | port, 34 | backlog: backlog, 35 | v6Only: v6Only, 36 | shared: shared, 37 | ); 38 | } 39 | return await io.HttpServer.bindSecure( 40 | address, 41 | port, 42 | context, 43 | backlog: backlog, 44 | v6Only: v6Only, 45 | shared: shared, 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/adapter/io/io_serve.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import '../../handler/handler.dart'; 5 | import '../../relic_server.dart'; 6 | import 'bind_http_server.dart'; 7 | import 'io_adapter.dart'; 8 | 9 | /// Starts a server that listens on the specified [address] and 10 | /// [port] and sends requests to [handler]. 11 | /// 12 | /// If [security] is provided, a secure server will be started. 13 | /// 14 | /// {@template relic_server_header_defaults} 15 | /// Every response will get a "date" header and an "X-Powered-By" header. 16 | /// If either header is present in the `Response`, it will not be 17 | /// overwritten. 18 | /// Pass [poweredByHeader] to set the default content for "X-Powered-By", 19 | /// pass `null` to omit this header. 20 | /// {@endtemplate} 21 | Future serve( 22 | final Handler handler, 23 | final InternetAddress address, 24 | final int port, { 25 | final SecurityContext? context, 26 | final int? backlog, 27 | final bool shared = false, 28 | final bool strictHeaders = false, 29 | final String? poweredByHeader, 30 | }) async { 31 | final adapter = IOAdapter(await bindHttpServer( 32 | address, 33 | port: port, 34 | context: context, 35 | backlog: backlog ?? 0, 36 | shared: shared, 37 | )); 38 | final server = RelicServer( 39 | adapter, 40 | strictHeaders: strictHeaders, 41 | poweredByHeader: poweredByHeader, 42 | ); 43 | await server.mountAndStart(handler); 44 | return server; 45 | } 46 | -------------------------------------------------------------------------------- /lib/src/adapter/io/request.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io' as io; 3 | 4 | import '../../../relic.dart'; 5 | 6 | /// Creates a new [Request] from an [io.HttpRequest]. 7 | /// 8 | /// [strictHeaders] determines whether to strictly enforce header parsing 9 | /// rules. [poweredByHeader] sets the value of the `X-Powered-By` header. 10 | Request fromHttpRequest( 11 | final io.HttpRequest request, { 12 | final bool strictHeaders = false, 13 | final String? poweredByHeader, 14 | }) { 15 | return Request( 16 | RequestMethod.parse(request.method), 17 | request.requestedUri, 18 | protocolVersion: request.protocolVersion, 19 | headers: headersFromHttpRequest(request), 20 | body: bodyFromHttpRequest(request), 21 | context: {}, 22 | ); 23 | } 24 | 25 | Headers headersFromHttpRequest(final io.HttpRequest request) { 26 | return Headers.build((final mh) { 27 | request.headers.forEach((final k, final v) => mh[k] = v); 28 | }); 29 | } 30 | 31 | /// Creates a body from a [HttpRequest]. 32 | Body bodyFromHttpRequest(final io.HttpRequest request) { 33 | final contentType = request.headers.contentType; 34 | return Body.fromDataStream( 35 | request, 36 | contentLength: request.contentLength <= 0 ? null : request.contentLength, 37 | encoding: Encoding.getByName(contentType?.charset), 38 | mimeType: contentType?.toMimeType, 39 | ); 40 | } 41 | 42 | /// Extension to convert a [ContentType] to a [MimeType]. 43 | extension ContentTypeExtension on io.ContentType { 44 | /// Converts a [ContentType] to a [MimeType]. 45 | /// We are calling this method 'toMimeType' to avoid conflict with the 'mimeType' property. 46 | MimeType get toMimeType => MimeType(primaryType, subType); 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/adapter/io/response.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../../message/response.dart'; 4 | 5 | import 'http_response_extension.dart'; 6 | 7 | extension ResponseExIo on Response { 8 | /// Writes the response to an [HttpResponse]. 9 | /// 10 | /// This method sets the status code, headers, and body on the [httpResponse] 11 | /// and returns a [Future] that completes when the body has been written. 12 | Future writeHttpResponse( 13 | final HttpResponse httpResponse, 14 | ) async { 15 | if (context.containsKey('relic_server.buffer_output')) { 16 | httpResponse.bufferOutput = context['relic_server.buffer_output'] as bool; 17 | } 18 | 19 | // Set the status code. 20 | httpResponse.statusCode = statusCode; 21 | 22 | // Apply all headers to the response. 23 | httpResponse.applyHeaders(headers, body); 24 | 25 | return httpResponse 26 | .addStream(body.read()) 27 | .then((final _) => httpResponse.close()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/body/body.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:typed_data'; 4 | 5 | import 'types/body_type.dart'; 6 | import 'types/mime_type.dart'; 7 | 8 | /// The body of a request or response. 9 | /// 10 | /// This tracks whether the body has been read. It's separate from [Message] 11 | /// because the message may be changed with [Message.copyWith], but each instance 12 | /// should share a notion of whether the body was read. 13 | class Body { 14 | /// The contents of the message body. 15 | /// 16 | /// This will be `null` after [read] is called. 17 | Stream? _stream; 18 | 19 | /// The length of the stream returned by [read], or `null` if that can't be 20 | /// determined efficiently. 21 | final int? contentLength; 22 | 23 | /// Body type is a combination of [mimeType] and [encoding]. 24 | /// 25 | /// For incoming requests, this is populated from the request content type 26 | /// header. 27 | /// 28 | /// For outgoing responses, this field is used to create the content type 29 | /// header. 30 | /// 31 | /// This will be `null` if the body is empty. 32 | /// 33 | /// This is a convenience property that combines [mimeType] and [encoding]. 34 | /// Example: 35 | /// ```dart 36 | /// var body = Body.fromString('hello', mimeType: MimeType.plainText); 37 | /// print(body.contentType); // ContentType(text/plain; charset=utf-8) 38 | /// ``` 39 | final BodyType? bodyType; 40 | 41 | Body._( 42 | this._stream, 43 | this.contentLength, { 44 | final Encoding? encoding, 45 | final MimeType? mimeType, 46 | }) : bodyType = mimeType == null 47 | ? null 48 | : BodyType(mimeType: mimeType, encoding: encoding); 49 | 50 | /// Creates an empty body. 51 | factory Body.empty() => Body._(const Stream.empty(), 0); 52 | 53 | /// Creates a body from a string. 54 | factory Body.fromString( 55 | final String body, { 56 | final Encoding encoding = utf8, 57 | final MimeType mimeType = MimeType.plainText, 58 | }) { 59 | final Uint8List encoded = Uint8List.fromList(encoding.encode(body)); 60 | return Body._( 61 | Stream.value(encoded), 62 | encoded.length, 63 | encoding: encoding, 64 | mimeType: mimeType, 65 | ); 66 | } 67 | 68 | /// Creates a body from a [Stream] of [Uint8List]. 69 | factory Body.fromDataStream( 70 | final Stream body, { 71 | final Encoding? encoding = utf8, 72 | final MimeType? mimeType = MimeType.plainText, 73 | final int? contentLength, 74 | }) { 75 | return Body._( 76 | body, 77 | contentLength, 78 | encoding: encoding, 79 | mimeType: mimeType, 80 | ); 81 | } 82 | 83 | /// Creates a body from a [Uint8List]. 84 | factory Body.fromData( 85 | final Uint8List body, { 86 | final Encoding? encoding, 87 | final MimeType mimeType = MimeType.octetStream, 88 | }) { 89 | return Body._( 90 | Stream.value(body), 91 | body.length, 92 | encoding: encoding, 93 | mimeType: mimeType, 94 | ); 95 | } 96 | 97 | /// Returns a [Stream] representing the body. 98 | /// 99 | /// Can only be called once. 100 | Stream read() { 101 | final stream = _stream; 102 | if (stream == null) { 103 | throw StateError( 104 | "The 'read' method can only be called once on a " 105 | 'Request/Response object.', 106 | ); 107 | } 108 | _stream = null; 109 | return stream; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/src/body/types/body_type.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'mime_type.dart'; 4 | 5 | /// A body type. 6 | class BodyType { 7 | /// A body type for plain text. 8 | static const plainText = BodyType( 9 | mimeType: MimeType.plainText, 10 | encoding: utf8, 11 | ); 12 | 13 | /// A body type for HTML. 14 | static const html = BodyType( 15 | mimeType: MimeType.html, 16 | encoding: utf8, 17 | ); 18 | 19 | /// A body type for CSS. 20 | static const css = BodyType( 21 | mimeType: MimeType.css, 22 | encoding: utf8, 23 | ); 24 | 25 | /// A body type for CSV. 26 | static const csv = BodyType( 27 | mimeType: MimeType.csv, 28 | encoding: utf8, 29 | ); 30 | 31 | /// A body type for JavaScript. 32 | static const javascript = BodyType( 33 | mimeType: MimeType.javascript, 34 | encoding: utf8, 35 | ); 36 | 37 | /// A body type for JSON. 38 | static const json = BodyType( 39 | mimeType: MimeType.json, 40 | encoding: utf8, 41 | ); 42 | 43 | /// A body type for XML. 44 | static const xml = BodyType( 45 | mimeType: MimeType.xml, 46 | encoding: utf8, 47 | ); 48 | 49 | /// A body type for octet stream data. 50 | static const octetStream = BodyType( 51 | mimeType: MimeType.octetStream, 52 | ); 53 | 54 | /// A body type for PDF. 55 | static const pdf = BodyType( 56 | mimeType: MimeType.pdf, 57 | ); 58 | 59 | /// A body type for RTF. 60 | static const rtf = BodyType( 61 | mimeType: MimeType.rtf, 62 | ); 63 | 64 | /// A body type for multipart form data. 65 | static const multipartFormData = BodyType( 66 | mimeType: MimeType.multipartFormData, 67 | ); 68 | 69 | /// A body type for multipart byteranges. 70 | static const multipartByteranges = BodyType( 71 | mimeType: MimeType.multipartByteranges, 72 | ); 73 | 74 | /// A body type for URL-encoded form data. 75 | static const urlEncoded = BodyType( 76 | mimeType: MimeType.urlEncoded, 77 | ); 78 | 79 | /// The mime type of the body. 80 | final MimeType mimeType; 81 | 82 | /// The encoding of the body. 83 | final Encoding? encoding; 84 | 85 | const BodyType({ 86 | required this.mimeType, 87 | this.encoding, 88 | }); 89 | 90 | /// Returns the value to use for the Content-Type header. 91 | String toHeaderValue() { 92 | if (encoding != null) { 93 | return '${mimeType.toHeaderValue()}; charset=${encoding!.name}'; 94 | } else { 95 | return mimeType.toHeaderValue(); 96 | } 97 | } 98 | 99 | @override 100 | String toString() => 'BodyType(mimeType: $mimeType, encoding: $encoding)'; 101 | } 102 | -------------------------------------------------------------------------------- /lib/src/body/types/mime_type.dart: -------------------------------------------------------------------------------- 1 | /// A mime type. 2 | class MimeType { 3 | /// Text mime types. 4 | static const plainText = MimeType('text', 'plain'); 5 | 6 | /// HTML mime type. 7 | static const html = MimeType('text', 'html'); 8 | 9 | /// CSS mime type. 10 | static const css = MimeType('text', 'css'); 11 | 12 | /// CSV mime type. 13 | static const csv = MimeType('text', 'csv'); 14 | 15 | /// JavaScript mime type. 16 | static const javascript = MimeType('text', 'javascript'); 17 | 18 | /// JSON mime type. 19 | static const json = MimeType('application', 'json'); 20 | 21 | /// XML mime type. 22 | static const xml = MimeType('application', 'xml'); 23 | 24 | /// Binary mime type. 25 | 26 | static const octetStream = MimeType('application', 'octet-stream'); 27 | 28 | /// PDF mime type. 29 | static const pdf = MimeType('application', 'pdf'); 30 | 31 | /// RTF mime type. 32 | static const rtf = MimeType('application', 'rtf'); 33 | 34 | /// Multipart form data mime type. 35 | static const multipartFormData = MimeType('multipart', 'form-data'); 36 | 37 | /// Multipart byteranges mime type. 38 | static const multipartByteranges = MimeType('multipart', 'byteranges'); 39 | 40 | /// URL-encoded form MIME type. 41 | static const urlEncoded = MimeType('application', 'x-www-form-urlencoded'); 42 | 43 | /// The primary type of the mime type. 44 | final String primaryType; 45 | 46 | /// The sub type of the mime type. 47 | final String subType; 48 | 49 | const MimeType(this.primaryType, this.subType); 50 | 51 | /// Parses a mime type from a string. 52 | /// It splits the string on the '/' character and expects exactly two parts. 53 | /// First part is the primary type, second is the sub type. 54 | /// If the string is not a valid mime type then a [FormatException] is thrown. 55 | factory MimeType.parse(final String type) { 56 | final parts = type.split('/'); 57 | if (parts.length != 2) { 58 | throw FormatException('Invalid mime type $type'); 59 | } 60 | 61 | final primaryType = parts[0]; 62 | final subType = parts[1]; 63 | 64 | if (primaryType.isEmpty || subType.isEmpty) { 65 | throw FormatException('Invalid mime type $type'); 66 | } 67 | 68 | return MimeType(primaryType, subType); 69 | } 70 | 71 | /// Returns the value to use for the Content-Type header. 72 | String toHeaderValue() => '$primaryType/$subType'; 73 | 74 | @override 75 | String toString() => 'MimeType(primaryType: $primaryType, subType: $subType)'; 76 | } 77 | -------------------------------------------------------------------------------- /lib/src/handler/cascade.dart: -------------------------------------------------------------------------------- 1 | import '../adapter/context.dart'; 2 | import '../message/response.dart'; 3 | import 'handler.dart'; 4 | 5 | /// A typedef for [Cascade._shouldCascade]. 6 | /// The signature for the function used by [Cascade] to determine if it 7 | /// should try the next handler based on the current [response]. 8 | /// Returns `true` if the next handler should be tried, `false` otherwise. 9 | typedef _ShouldCascade = bool Function(Response response); 10 | 11 | /// A helper that calls several handlers in sequence and returns the first 12 | /// acceptable response. 13 | /// 14 | /// By default, a response is considered acceptable if it has a status other 15 | /// than 404 or 405; other statuses indicate that the handler understood the 16 | /// request. 17 | /// 18 | /// If all handlers return unacceptable responses, the final response will be 19 | /// returned. 20 | /// 21 | /// ```dart 22 | /// var handler = new Cascade() 23 | /// .add(webSocketHandler) 24 | /// .add(staticFileHandler) 25 | /// .add(application) 26 | /// .handler; 27 | /// ``` 28 | class Cascade { 29 | /// The function used to determine whether the cascade should continue on to 30 | /// the next handler. 31 | final _ShouldCascade _shouldCascade; 32 | 33 | final Cascade? _parent; 34 | final Handler? _handler; 35 | 36 | /// Creates a new, empty cascade. 37 | /// 38 | /// If [statusCodes] is passed, responses with those status codes are 39 | /// considered unacceptable. If [shouldCascade] is passed, responses for which 40 | /// it returns `true` are considered unacceptable. [statusCodes] and 41 | /// [shouldCascade] may not both be passed. 42 | Cascade( 43 | {final Iterable? statusCodes, 44 | final bool Function(Response)? shouldCascade}) 45 | : _shouldCascade = _computeShouldCascade(statusCodes, shouldCascade), 46 | _parent = null, 47 | _handler = null { 48 | if (statusCodes != null && shouldCascade != null) { 49 | throw ArgumentError('statusCodes and shouldCascade may not both be ' 50 | 'passed.'); 51 | } 52 | } 53 | 54 | Cascade._(this._parent, this._handler, this._shouldCascade); 55 | 56 | /// Returns a new cascade with [handler] added to the end. 57 | /// 58 | /// [handler] will only be called if all previous handlers in the cascade 59 | /// return unacceptable responses. 60 | Cascade add(final Handler handler) => 61 | Cascade._(this, handler, _shouldCascade); 62 | 63 | /// Exposes this cascade as a single handler. 64 | /// 65 | /// This handler will call each inner handler in the cascade until one returns 66 | /// an acceptable response, and return that. If no inner handlers return an 67 | /// acceptable response, this will return the final response. 68 | Handler get handler { 69 | final handler = _handler; 70 | if (handler == null) { 71 | throw StateError("Can't get a handler for a cascade with no inner " 72 | 'handlers.'); 73 | } 74 | 75 | return (final ctx) async { 76 | if (_parent!._handler == null) return handler(ctx); 77 | final newCtx = await _parent.handler(ctx); 78 | if (newCtx is ResponseContext && _shouldCascade(newCtx.response)) { 79 | return handler(ctx); 80 | } 81 | return newCtx; 82 | }; 83 | } 84 | } 85 | 86 | /// Computes the [Cascade._shouldCascade] function based on the user's 87 | /// parameters. 88 | _ShouldCascade _computeShouldCascade( 89 | Iterable? statusCodes, final bool Function(Response)? shouldCascade) { 90 | if (shouldCascade != null) return shouldCascade; 91 | statusCodes ??= [404, 405]; 92 | final statusCodeSet = statusCodes.toSet(); 93 | return (final response) => statusCodeSet.contains(response.statusCode); 94 | } 95 | -------------------------------------------------------------------------------- /lib/src/handler/handler.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import '../adapter/adapter.dart'; 4 | import '../adapter/context.dart'; 5 | import '../message/request.dart'; 6 | import '../message/response.dart'; 7 | 8 | /// A function that processes a [NewContext] to produce a [HandledContext]. 9 | /// 10 | /// For example, a static file handler may access the [Request] via the [NewContext], 11 | /// read the requested URI from the filesystem, and return a [ResponseContext] 12 | /// (a type of [HandledContext]) containing the file data as its body. 13 | /// 14 | /// A function which produces a [Handler], either by wrapping one or more other handlers, 15 | // or using function composition is known as a "middleware". 16 | /// 17 | /// A [Handler] may receive a [NewContext] directly from an HTTP server adapter or it 18 | /// may have been processed by other middleware. Similarly, the resulting [HandledContext] 19 | /// may be directly returned to an HTTP server adapter or have further processing 20 | /// done by other middleware. 21 | typedef Handler = FutureOr Function(NewContext ctx); 22 | 23 | /// A handler specifically designed to produce a [ResponseContext]. 24 | /// 25 | /// It takes a [RespondableContext] and must return a [FutureOr]. 26 | /// This is useful for handlers that are guaranteed to generate a response. 27 | typedef ResponseHandler = FutureOr Function( 28 | RespondableContext ctx); 29 | 30 | /// A handler specifically designed to produce a [HijackContext]. 31 | /// 32 | /// It takes a [HijackableContext] and must return a [FutureOr]. 33 | /// This is useful for handlers that are guaranteed to hijack the connection 34 | /// (e.g., for WebSocket upgrades). 35 | typedef HijackHandler = FutureOr Function(HijackableContext ctx); 36 | 37 | /// A function which handles exceptions. 38 | /// 39 | /// This typedef is used to define how exceptions should be handled in the 40 | /// context of processing requests. It takes in the [error] and [stackTrace] 41 | /// and returns a [Response] after processing the exception. 42 | typedef ExceptionHandler = FutureOr Function( 43 | Object error, 44 | StackTrace stackTrace, 45 | ); 46 | 47 | /// A simplified handler function that takes a [Request] and returns a [Response]. 48 | /// 49 | /// This is often used with helper functions like [respondWith] to create 50 | /// standard [Handler] instances more easily. 51 | typedef Responder = FutureOr Function(Request); 52 | 53 | /// Creates a [Handler] that uses the given [Responder] function to generate 54 | /// a response. 55 | /// 56 | /// This adapts a simpler `Request -> Response` function ([Responder]) into 57 | /// the standard [Handler] format. The returned [Handler] takes a [NewContext], 58 | /// retrieves its [Request] (which is passed to the [responder]), and then uses 59 | /// the [Response] from the [responder] to create a [ResponseContext]. 60 | /// 61 | /// The input [NewContext] to the generated [Handler] must be a 62 | /// [RespondableContext] (i.e., capable of producing a response) for the 63 | /// `withResponse` call to succeed. The handler ensures the resulting context is 64 | /// a [ResponseContext]. 65 | Handler respondWith(final Responder responder) { 66 | return (final ctx) async { 67 | return ctx.withResponse(await responder(ctx.request)); 68 | }; 69 | } 70 | 71 | /// Creates a [HijackHandler] that uses the given [HijackCallback] to 72 | /// take control of the connection. 73 | /// 74 | /// This adapts a [HijackCallback] into the [HijackHandler] format. 75 | /// The returned handler takes a [HijackableContext], invokes the [callback] 76 | /// to take control of the connection, and produces a [HijackContext]. 77 | HijackHandler hijack(final HijackCallback callback) { 78 | return (final ctx) { 79 | return ctx.hijack(callback); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/handler/pipeline.dart: -------------------------------------------------------------------------------- 1 | import '../middleware/middleware.dart'; 2 | import 'handler.dart'; 3 | 4 | /// A helper that makes it easy to compose a set of [Middleware] and a 5 | /// [Handler]. 6 | /// 7 | /// ```dart 8 | /// var handler = const Pipeline() 9 | /// .addMiddleware(loggingMiddleware) 10 | /// .addMiddleware(cachingMiddleware) 11 | /// .addHandler(application); 12 | /// ``` 13 | /// 14 | /// Note: this package also provides `addMiddleware` and `addHandler` extensions 15 | /// members on [Middleware], which may be easier to use. 16 | class Pipeline { 17 | /// Creates a new, empty pipeline. 18 | const Pipeline(); 19 | 20 | /// Returns a new [Pipeline] with [middleware] added to the existing set of 21 | /// [Middleware]. 22 | /// 23 | /// [middleware] will be the last [Middleware] to process a request and 24 | /// the first to process a response. 25 | Pipeline addMiddleware(final Middleware middleware) => 26 | _Pipeline(middleware, addHandler); 27 | 28 | /// Returns a new [Handler] with [handler] as the final processor of a 29 | /// [Request] if all of the middleware in the pipeline have passed the request 30 | /// through. 31 | Handler addHandler(final Handler handler) => handler; 32 | 33 | /// Exposes this pipeline of [Middleware] as a single middleware instance. 34 | Middleware get middleware => addHandler; 35 | } 36 | 37 | class _Pipeline extends Pipeline { 38 | final Middleware _middleware; 39 | final Middleware _parent; 40 | 41 | const _Pipeline(this._middleware, this._parent); 42 | 43 | @override 44 | Handler addHandler(final Handler handler) => _parent(_middleware(handler)); 45 | } 46 | -------------------------------------------------------------------------------- /lib/src/headers/exception/header_exception.dart: -------------------------------------------------------------------------------- 1 | /// [HeaderException] serves as the common supertype for specific exceptions that 2 | /// can occur when processing HTTP headers. 3 | /// 4 | /// The error details, including the header type and description, are also 5 | /// available in a formatted string for use in HTTP responses via [httpResponseBody]. 6 | /// 7 | /// Specific subtypes include: 8 | /// - [InvalidHeaderException]: For malformed or invalid header values 9 | /// - [MissingHeaderException]: For required headers that are absent 10 | sealed class HeaderException implements Exception { 11 | /// Detailed description of the error. 12 | /// 13 | /// This describes what is wrong with the header value and is included 14 | /// in the HTTP response body as part of [httpResponseBody]. 15 | final String description; 16 | 17 | /// The type of header that caused the error. 18 | /// 19 | /// This indicates which specific header caused the issue (e.g., 'Content-Type') 20 | /// and should be included in the HTTP response body as part of [httpResponseBody]. 21 | final String headerType; 22 | 23 | const HeaderException(this.description, {required this.headerType}); 24 | 25 | /// A formatted description of the error for inclusion in an HTTP response body. 26 | String get httpResponseBody; 27 | } 28 | 29 | /// Exception thrown for invalid HTTP header values. 30 | /// 31 | /// This exception is used to indicate that a specific HTTP header contains 32 | /// invalid or malformed data. It provides details about the type of header 33 | /// that caused the error and a description of the issue. 34 | /// 35 | /// The error details, including the header type and description, are also 36 | /// available in a formatted string for use in HTTP responses via [httpResponseBody]. 37 | class InvalidHeaderException extends HeaderException { 38 | /// Creates an [InvalidHeaderException] with a [description] describing the error 39 | /// and the [headerType] indicating the problematic header. 40 | const InvalidHeaderException(super.description, {required super.headerType}); 41 | 42 | @override 43 | String get httpResponseBody => "Invalid '$headerType' header: $description"; 44 | 45 | @override 46 | String toString() => 47 | 'InvalidHeaderException(description: $description, headerType: $headerType)'; 48 | } 49 | 50 | /// Exception thrown when a required HTTP header is missing. 51 | /// 52 | /// This exception indicates that a header that was expected or required 53 | /// for processing a request was not provided. It provides information about 54 | /// which header was missing through the [headerType] property. 55 | /// 56 | /// The error details can be formatted for use in HTTP responses via 57 | /// the [httpResponseBody] property. 58 | class MissingHeaderException extends HeaderException { 59 | /// Creates a [MissingHeaderException] with a [description] of why the header 60 | /// is required and the [headerType] indicating which header was missing. 61 | /// 62 | /// Use this exception when a required HTTP header is not present in a request 63 | /// where it is expected or mandatory for proper processing. 64 | const MissingHeaderException(super.description, {required super.headerType}); 65 | 66 | @override 67 | String get httpResponseBody => "Missing '$headerType' header"; 68 | 69 | @override 70 | String toString() => 71 | 'MissingHeaderException(description: $description, headerType: $headerType)'; 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/headers/extension/string_list_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | extension StringListExtensions on Iterable { 4 | /// Processes a list of strings by: 5 | /// 1. Splitting each element by the given [separator] 6 | /// 2. Trimming whitespace from each resulting part 7 | /// 3. Removing empty strings 8 | /// 4. Removing duplicates while preserving order 9 | /// 10 | /// Example: 11 | /// ```dart 12 | /// ['apple, banana', 'banana, orange'].splitTrimAndFilterUnique() 13 | /// // Returns: ['apple', 'banana', 'orange'] 14 | /// ``` 15 | /// 16 | /// The default separator is comma (","). 17 | Iterable splitTrimAndFilterUnique({ 18 | final String separator = ',', 19 | final bool emptyCheck = true, 20 | }) { 21 | final filtered = expand((final element) => element.split(separator)) 22 | .map((final el) => el.trim()) 23 | .where((final e) => !emptyCheck || e.isNotEmpty); 24 | return LinkedHashSet.from(filtered); 25 | } 26 | } 27 | 28 | extension StringExtensions on String { 29 | /// Processes a string by: 30 | /// 1. Splitting it by the given [separator] 31 | /// 2. Trimming whitespace from each resulting part 32 | /// 3. Removing empty strings 33 | /// 4. Removing duplicates while preserving order 34 | /// 35 | /// Example: 36 | /// ```dart 37 | /// 'apple, banana, banana, orange'.splitTrimAndFilterUnique() 38 | /// // Returns: ['apple', 'banana', 'orange'] 39 | /// ``` 40 | /// 41 | /// The default separator is comma (","). 42 | Iterable splitTrimAndFilterUnique({ 43 | final String separator = ',', 44 | final bool emptyCheck = true, 45 | final bool noTrim = false, 46 | }) { 47 | final filtered = split(separator) 48 | .map((final el) => noTrim ? el : el.trim()) 49 | .where((final e) => !emptyCheck || e.isNotEmpty); 50 | return LinkedHashSet.from(filtered); 51 | } 52 | 53 | /// Checks if the string is a valid email address. 54 | 55 | bool isValidEmail() { 56 | return RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$') 57 | .hasMatch(this); 58 | } 59 | 60 | bool isValidLanguageCode() { 61 | return RegExp(r'^[a-zA-Z]{2,8}(-[a-zA-Z]{2,8})?$').hasMatch(this); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/headers/mutable_headers.dart: -------------------------------------------------------------------------------- 1 | part of 'headers.dart'; 2 | 3 | class MutableHeaders extends HeadersBase 4 | with MapMixin> { 5 | MutableHeaders._(super.backing) : super._(); 6 | 7 | MutableHeaders() : this._(_BackingStore()); 8 | 9 | MutableHeaders._from(final Headers headers) 10 | : this._(_BackingStore.from(headers._backing)); 11 | 12 | Headers _freeze() { 13 | // TODO: 14 | // Would be nice if we could decouple _backing from this MutableHeaders object 15 | // at this point to prevent caller to hold on to the mutable headers after freezing 16 | // 17 | // Will require a change to MapView or 18 | return Headers._(_backing); 19 | } 20 | 21 | @override 22 | Iterable? operator [](final Object? key) => _backing[key]; 23 | 24 | @override 25 | void operator []=(final String key, final Iterable? value) { 26 | if (value == null) { 27 | _backing.remove(key); 28 | } else { 29 | _backing[key] = value; 30 | } 31 | } 32 | 33 | @override 34 | void clear() => _backing.clear(); 35 | 36 | @override 37 | Iterable get keys => _backing.keys; 38 | 39 | @override 40 | Iterable? remove(final Object? key) => _backing.remove(key); 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/accept_encoding_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Accept-Encoding header. 5 | /// 6 | /// This header specifies the content encoding that the client can understand. 7 | final class AcceptEncodingHeader { 8 | static const codec = HeaderCodec(AcceptEncodingHeader.parse, __encode); 9 | static List __encode(final AcceptEncodingHeader value) => 10 | [value._encode()]; 11 | 12 | /// The list of encodings that are accepted. 13 | final List? encodings; 14 | 15 | /// A boolean value indicating whether the Accept-Encoding header is a wildcard. 16 | final bool isWildcard; 17 | 18 | /// Constructs an instance of [AcceptEncodingHeader] with the given encodings. 19 | AcceptEncodingHeader.encodings({required this.encodings}) 20 | : isWildcard = false; 21 | 22 | /// Constructs an instance of [AcceptEncodingHeader] with a wildcard encoding. 23 | AcceptEncodingHeader.wildcard() 24 | : encodings = null, 25 | isWildcard = true; 26 | 27 | /// Parses the Accept-Encoding header value and returns an [AcceptEncodingHeader] instance. 28 | factory AcceptEncodingHeader.parse(final Iterable values) { 29 | final splitValues = values.splitTrimAndFilterUnique(); 30 | 31 | if (splitValues.isEmpty) { 32 | throw const FormatException('Value cannot be empty'); 33 | } 34 | 35 | if (splitValues.length == 1 && splitValues.first == '*') { 36 | return AcceptEncodingHeader.wildcard(); 37 | } 38 | 39 | if (splitValues.length > 1 && splitValues.contains('*')) { 40 | throw const FormatException( 41 | 'Wildcard (*) cannot be used with other values'); 42 | } 43 | 44 | final encodings = splitValues.map((final value) { 45 | final encodingParts = value.split(';q='); 46 | final encoding = encodingParts[0].trim().toLowerCase(); 47 | if (encoding.isEmpty) { 48 | throw const FormatException('Invalid encoding'); 49 | } 50 | double? quality; 51 | if (encodingParts.length > 1) { 52 | final value = double.tryParse(encodingParts[1].trim()); 53 | if (value == null || value < 0 || value > 1) { 54 | throw const FormatException('Invalid quality value'); 55 | } 56 | quality = value; 57 | } 58 | return EncodingQuality(encoding, quality); 59 | }).toList(); 60 | 61 | return AcceptEncodingHeader.encodings(encodings: encodings); 62 | } 63 | 64 | /// Converts the [AcceptEncodingHeader] instance into a string representation suitable for HTTP headers. 65 | 66 | String _encode() => isWildcard 67 | ? '*' 68 | : encodings?.map((final e) => e._encode()).join(', ') ?? ''; 69 | 70 | @override 71 | String toString() => 'AcceptEncodingHeader(encodings: $encodings)'; 72 | } 73 | 74 | /// A class representing an encoding with an optional quality value. 75 | class EncodingQuality { 76 | /// The encoding value. 77 | final String encoding; 78 | 79 | /// The quality value (default is 1.0). 80 | final double? quality; 81 | 82 | /// Constructs an instance of [EncodingQuality]. 83 | EncodingQuality(this.encoding, [final double? quality]) 84 | : quality = quality ?? 1.0; 85 | 86 | /// Converts the [EncodingQuality] instance into a string representation suitable for HTTP headers. 87 | String _encode() => quality == 1.0 ? encoding : '$encoding;q=$quality'; 88 | 89 | @override 90 | String toString() => 91 | 'EncodingQuality(encoding: $encoding, quality: $quality)'; 92 | } 93 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/accept_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Accept header. 5 | /// 6 | /// This class manages media ranges and their associated quality values. 7 | final class AcceptHeader { 8 | static const codec = HeaderCodec(AcceptHeader.parse, __encode); 9 | static List __encode(final AcceptHeader value) => [value._encode()]; 10 | 11 | /// The list of media ranges accepted by the client. 12 | final List mediaRanges; 13 | 14 | /// Constructs an [AcceptHeader] instance with the specified media ranges. 15 | const AcceptHeader({required this.mediaRanges}); 16 | 17 | /// Parses the Accept header value and returns an [AcceptHeader] instance. 18 | /// 19 | /// This method processes the header value, extracting media types and 20 | /// their quality values. 21 | factory AcceptHeader.parse(final Iterable values) { 22 | final splitValues = values.splitTrimAndFilterUnique(); 23 | if (splitValues.isEmpty) { 24 | throw const FormatException('Value cannot be empty'); 25 | } 26 | 27 | final mediaRanges = splitValues.map(MediaRange.parse).toList(); 28 | 29 | return AcceptHeader(mediaRanges: mediaRanges); 30 | } 31 | 32 | /// Converts the [AcceptHeader] instance into a string representation suitable for HTTP headers. 33 | String _encode() => mediaRanges.map((final mr) => mr._encode()).join(', '); 34 | 35 | @override 36 | String toString() => 'AcceptHeader(mediaRanges: $mediaRanges)'; 37 | } 38 | 39 | /// A class representing a media range with an optional quality value. 40 | class MediaRange { 41 | /// The type of the media (e.g., "text"). 42 | final String type; 43 | 44 | /// The subtype of the media (e.g., "html"). 45 | final String subtype; 46 | 47 | /// The quality value (default is 1.0). 48 | final double quality; 49 | 50 | /// Constructs a [MediaRange] instance with the specified type, subtype, 51 | /// quality, and parameters. 52 | MediaRange(this.type, this.subtype, {final double? quality}) 53 | : quality = quality ?? 1.0; 54 | 55 | /// Parses a media range string and returns a [MediaRange] instance. 56 | /// 57 | /// This method processes the media range string, extracting the type, 58 | /// subtype, quality, and parameters. 59 | factory MediaRange.parse(final String value) { 60 | final parts = value.splitTrimAndFilterUnique(separator: ';').toList(); 61 | final typeSubtype = parts.first.split('/'); 62 | if (typeSubtype.length != 2) { 63 | throw const FormatException('Invalid media range'); 64 | } 65 | 66 | final type = typeSubtype[0].trim(); 67 | final subtype = typeSubtype[1].trim(); 68 | 69 | double? quality; 70 | if (parts.length > 1) { 71 | final qualityParts = 72 | parts[1].splitTrimAndFilterUnique(separator: 'q=').firstOrNull; 73 | if (qualityParts != null) { 74 | final value = double.tryParse(qualityParts); 75 | if (value == null || value < 0 || value > 1) { 76 | throw const FormatException('Invalid quality value'); 77 | } 78 | quality = value; 79 | } 80 | } 81 | 82 | return MediaRange( 83 | type, 84 | subtype, 85 | quality: quality, 86 | ); 87 | } 88 | 89 | /// Converts the [MediaRange] instance into a string representation suitable for HTTP headers. 90 | String _encode() { 91 | final qualityStr = quality == 1.0 ? '' : ';q=$quality'; 92 | return '$type/$subtype$qualityStr'; 93 | } 94 | 95 | @override 96 | String toString() => 97 | 'MediaRange(type: $type, subtype: $subtype, quality: $quality)'; 98 | } 99 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/accept_language_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Accept-Language header. 5 | /// 6 | /// This header specifies the natural languages that are preferred in the response. 7 | final class AcceptLanguageHeader { 8 | static const codec = HeaderCodec(AcceptLanguageHeader.parse, __encode); 9 | static List __encode(final AcceptLanguageHeader value) => 10 | [value._encode()]; 11 | 12 | /// The list of languages that are accepted. 13 | final List? languages; 14 | 15 | /// A boolean value indicating whether the Accept-Language header is a wildcard. 16 | final bool isWildcard; 17 | 18 | /// Constructs an instance of [AcceptLanguageHeader] with the given languages. 19 | const AcceptLanguageHeader.languages({required this.languages}) 20 | : isWildcard = false; 21 | 22 | /// Constructs an instance of [AcceptLanguageHeader] with a wildcard language. 23 | const AcceptLanguageHeader.wildcard() 24 | : languages = null, 25 | isWildcard = true; 26 | 27 | /// Parses the Accept-Language header value and returns an [AcceptLanguageHeader] instance. 28 | factory AcceptLanguageHeader.parse(final Iterable values) { 29 | final splitValues = values.splitTrimAndFilterUnique(); 30 | 31 | if (splitValues.isEmpty) { 32 | throw const FormatException('Value cannot be empty'); 33 | } 34 | 35 | if (splitValues.length == 1 && splitValues.first == '*') { 36 | return const AcceptLanguageHeader.wildcard(); 37 | } 38 | 39 | if (splitValues.length > 1 && splitValues.contains('*')) { 40 | throw const FormatException( 41 | 'Wildcard (*) cannot be used with other values'); 42 | } 43 | 44 | final languages = splitValues.map((final value) { 45 | final languageParts = value.split(';q='); 46 | final language = languageParts[0].trim().toLowerCase(); 47 | if (language.isEmpty) { 48 | throw const FormatException('Invalid language'); 49 | } 50 | double? quality; 51 | if (languageParts.length > 1) { 52 | final value = double.tryParse(languageParts[1].trim()); 53 | if (value == null || value < 0 || value > 1) { 54 | throw const FormatException('Invalid quality value'); 55 | } 56 | quality = value; 57 | } 58 | return LanguageQuality(language, quality); 59 | }).toList(); 60 | 61 | return AcceptLanguageHeader.languages(languages: languages); 62 | } 63 | 64 | /// Converts the [AcceptLanguageHeader] instance into a string representation suitable for HTTP headers. 65 | String _encode() => isWildcard 66 | ? '*' 67 | : languages?.map((final e) => e._encode()).join(', ') ?? ''; 68 | 69 | @override 70 | String toString() => 'AcceptLanguageHeader(languages: $languages)'; 71 | } 72 | 73 | /// A class representing a language with an optional quality value. 74 | class LanguageQuality { 75 | /// The language value. 76 | final String language; 77 | 78 | /// The quality value (default is 1.0). 79 | final double? quality; 80 | 81 | /// Constructs an instance of [LanguageQuality]. 82 | LanguageQuality(this.language, [final double? quality]) 83 | : quality = quality ?? 1.0; 84 | 85 | /// Converts the [LanguageQuality] instance into a string representation suitable for HTTP headers. 86 | String _encode() => quality == 1.0 ? language : '$language;q=$quality'; 87 | 88 | @override 89 | String toString() => 90 | 'LanguageQuality(language: $language, quality: $quality)'; 91 | } 92 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/accept_ranges_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Accept-Ranges header. 4 | /// 5 | /// This class manages the range units that the server supports. 6 | final class AcceptRangesHeader { 7 | static const codec = HeaderCodec.single(AcceptRangesHeader.parse, __encode); 8 | static List __encode(final AcceptRangesHeader value) => 9 | [value._encode()]; 10 | 11 | /// The range unit supported by the server, or `null` if no specific unit is supported. 12 | final String? rangeUnit; 13 | 14 | /// Constructs an [AcceptRangesHeader] instance with the specified range unit. 15 | const AcceptRangesHeader({this.rangeUnit}); 16 | 17 | /// Constructs an [AcceptRangesHeader] instance with the range unit set to 'none'. 18 | factory AcceptRangesHeader.none() => 19 | const AcceptRangesHeader(rangeUnit: 'none'); 20 | 21 | /// Constructs an [AcceptRangesHeader] instance with the range unit set to 'bytes'. 22 | factory AcceptRangesHeader.bytes() => 23 | const AcceptRangesHeader(rangeUnit: 'bytes'); 24 | 25 | /// Parses the Accept-Ranges header value and returns an [AcceptRangesHeader] instance. 26 | /// 27 | /// This method processes the header value, extracting the range unit. 28 | factory AcceptRangesHeader.parse(final String value) { 29 | final trimmed = value.trim(); 30 | if (trimmed.isEmpty) { 31 | throw const FormatException('Value cannot be empty'); 32 | } 33 | 34 | return AcceptRangesHeader(rangeUnit: trimmed); 35 | } 36 | 37 | /// Returns `true` if the range unit is 'bytes', otherwise `false`. 38 | bool get isBytes => rangeUnit == 'bytes'; 39 | 40 | /// Returns `true` if the range unit is 'none' or `null`, otherwise `false`. 41 | bool get isNone => rangeUnit == 'none' || rangeUnit == null; 42 | 43 | /// Converts the [AcceptRangesHeader] instance into a string representation suitable for HTTP headers. 44 | 45 | String _encode() => rangeUnit ?? 'none'; 46 | @override 47 | String toString() { 48 | return 'AcceptRangesHeader(rangeUnit: $rangeUnit)'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/access_control_allow_headers_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Access-Control-Allow-Headers header. 5 | /// 6 | /// This header specifies which HTTP headers can be used during the actual request 7 | /// by listing them explicitly or using a wildcard (`*`) to allow all headers. 8 | final class AccessControlAllowHeadersHeader { 9 | static const codec = 10 | HeaderCodec(AccessControlAllowHeadersHeader.parse, __encode); 11 | static List __encode(final AccessControlAllowHeadersHeader value) => 12 | [value._encode()]; 13 | 14 | /// The list of headers that are allowed. 15 | final Iterable? headers; 16 | 17 | /// Whether all headers are allowed (`*`). 18 | final bool isWildcard; 19 | 20 | /// Constructs an instance allowing specific headers to be allowed. 21 | const AccessControlAllowHeadersHeader.headers({required this.headers}) 22 | : isWildcard = false; 23 | 24 | /// Constructs an instance allowing all headers to be allowed (`*`). 25 | const AccessControlAllowHeadersHeader.wildcard() 26 | : headers = null, 27 | isWildcard = true; 28 | 29 | /// Parses the Access-Control-Allow-Headers header value and returns an 30 | /// [AccessControlAllowHeadersHeader] instance. 31 | factory AccessControlAllowHeadersHeader.parse(final Iterable values) { 32 | final splitValues = values.splitTrimAndFilterUnique(); 33 | if (splitValues.isEmpty) { 34 | throw const FormatException('Value cannot be empty'); 35 | } 36 | 37 | if (splitValues.length == 1 && splitValues.first == '*') { 38 | return const AccessControlAllowHeadersHeader.wildcard(); 39 | } 40 | 41 | if (splitValues.length > 1 && splitValues.contains('*')) { 42 | throw const FormatException( 43 | 'Wildcard (*) cannot be used with other headers'); 44 | } 45 | 46 | return AccessControlAllowHeadersHeader.headers( 47 | headers: splitValues, 48 | ); 49 | } 50 | 51 | /// Converts the [AccessControlAllowHeadersHeader] instance into a string 52 | /// representation suitable for HTTP headers. 53 | 54 | String _encode() => isWildcard ? '*' : headers!.join(', '); 55 | 56 | @override 57 | String toString() => 58 | 'AccessControlAllowHeadersHeader(headers: $headers, isWildcard: $isWildcard)'; 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/access_control_allow_methods_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Access-Control-Allow-Methods header. 5 | /// 6 | /// This header specifies which methods are allowed when accessing the resource 7 | /// in response to a preflight request. 8 | final class AccessControlAllowMethodsHeader { 9 | static const codec = 10 | HeaderCodec(AccessControlAllowMethodsHeader.parse, __encode); 11 | static List __encode(final AccessControlAllowMethodsHeader value) => 12 | [value._encode()]; 13 | 14 | /// The list of methods that are allowed. 15 | final List? methods; 16 | 17 | /// Whether all methods are allowed (`*`). 18 | final bool isWildcard; 19 | 20 | /// Constructs an instance allowing specific methods to be allowed. 21 | const AccessControlAllowMethodsHeader.methods({required this.methods}) 22 | : isWildcard = false; 23 | 24 | /// Constructs an instance allowing all methods to be allowed (`*`). 25 | const AccessControlAllowMethodsHeader.wildcard() 26 | : methods = null, 27 | isWildcard = true; 28 | 29 | /// Parses the Access-Control-Allow-Methods header value and returns an 30 | /// [AccessControlAllowMethodsHeader] instance. 31 | factory AccessControlAllowMethodsHeader.parse(final Iterable values) { 32 | final splitValues = values.splitTrimAndFilterUnique(); 33 | if (splitValues.isEmpty) { 34 | throw const FormatException( 35 | 'Value cannot be empty', 36 | ); 37 | } 38 | 39 | if (splitValues.length == 1 && splitValues.first == '*') { 40 | return const AccessControlAllowMethodsHeader.wildcard(); 41 | } 42 | 43 | if (splitValues.length > 1 && splitValues.contains('*')) { 44 | throw const FormatException( 45 | 'Wildcard (*) cannot be used with other values', 46 | ); 47 | } 48 | 49 | return AccessControlAllowMethodsHeader.methods( 50 | methods: splitValues.map(RequestMethod.parse).toList(), 51 | ); 52 | } 53 | 54 | /// Converts the [AccessControlAllowMethodsHeader] instance into a string 55 | /// representation suitable for HTTP headers. 56 | 57 | String _encode() => isWildcard ? '*' : methods!.join(', '); 58 | 59 | @override 60 | String toString() => 61 | 'AccessControlAllowMethodsHeader(methods: $methods, isWildcard: $isWildcard)'; 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/access_control_allow_origin_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Access-Control-Allow-Origin header. 4 | /// 5 | /// This header specifies which origins are allowed to access the resource. 6 | /// It can be a specific origin or a wildcard (`*`) to allow any origin. 7 | final class AccessControlAllowOriginHeader { 8 | static const codec = 9 | HeaderCodec.single(AccessControlAllowOriginHeader.parse, __encode); 10 | static List __encode(final AccessControlAllowOriginHeader value) => 11 | [value._encode()]; 12 | 13 | /// The allowed origin URI, if specified. 14 | final Uri? origin; 15 | 16 | /// Whether any origin is allowed (`*`). 17 | final bool isWildcard; 18 | 19 | /// Constructs an instance allowing a specific origin. 20 | const AccessControlAllowOriginHeader.origin({required this.origin}) 21 | : isWildcard = false; 22 | 23 | /// Constructs an instance allowing any origin (`*`). 24 | const AccessControlAllowOriginHeader.wildcard() 25 | : origin = null, 26 | isWildcard = true; 27 | 28 | /// Parses the Access-Control-Allow-Origin header value and 29 | /// returns an [AccessControlAllowOriginHeader] instance. 30 | /// 31 | /// This method checks if the value is a wildcard or a specific origin. 32 | factory AccessControlAllowOriginHeader.parse(final String value) { 33 | final trimmed = value.trim(); 34 | if (trimmed.isEmpty) { 35 | throw const FormatException('Value cannot be empty'); 36 | } 37 | 38 | if (trimmed == '*') { 39 | return const AccessControlAllowOriginHeader.wildcard(); 40 | } 41 | 42 | try { 43 | return AccessControlAllowOriginHeader.origin( 44 | origin: Uri.parse(trimmed), 45 | ); 46 | } catch (_) { 47 | throw const FormatException('Invalid URI format'); 48 | } 49 | } 50 | 51 | /// Converts the [AccessControlAllowOriginHeader] instance into a string 52 | /// representation suitable for HTTP headers. 53 | 54 | String _encode() => isWildcard ? '*' : origin.toString(); 55 | 56 | @override 57 | String toString() => 58 | 'AccessControlAllowOriginHeader(origin: $origin, isWildcard: $isWildcard)'; 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/access_control_expose_headers_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Access-Control-Expose-Headers header. 5 | /// 6 | /// This header specifies which headers can be exposed as part of the response 7 | /// by listing them explicitly or using a wildcard (`*`) to expose all headers. 8 | final class AccessControlExposeHeadersHeader { 9 | static const codec = 10 | HeaderCodec(AccessControlExposeHeadersHeader.parse, __encode); 11 | static List __encode(final AccessControlExposeHeadersHeader value) => 12 | [value._encode()]; 13 | 14 | /// The list of headers that can be exposed. 15 | final Iterable? headers; 16 | 17 | /// Whether all headers are allowed to be exposed (`*`). 18 | final bool isWildcard; 19 | 20 | /// Constructs an instance allowing specific headers to be exposed. 21 | const AccessControlExposeHeadersHeader.headers({required this.headers}) 22 | : isWildcard = false; 23 | 24 | /// Constructs an instance allowing all headers to be exposed (`*`). 25 | const AccessControlExposeHeadersHeader.wildcard() 26 | : headers = null, 27 | isWildcard = true; 28 | 29 | /// Parses the Access-Control-Expose-Headers header value and returns an 30 | /// [AccessControlExposeHeadersHeader] instance. 31 | factory AccessControlExposeHeadersHeader.parse( 32 | final Iterable values) { 33 | final splitValues = values.splitTrimAndFilterUnique(); 34 | if (splitValues.isEmpty) { 35 | throw const FormatException('Value cannot be empty'); 36 | } 37 | 38 | if (splitValues.length == 1 && splitValues.first == '*') { 39 | return const AccessControlExposeHeadersHeader.wildcard(); 40 | } 41 | 42 | if (splitValues.length > 1 && splitValues.contains('*')) { 43 | throw const FormatException( 44 | 'Wildcard (*) cannot be used with other values'); 45 | } 46 | 47 | return AccessControlExposeHeadersHeader.headers( 48 | headers: splitValues, 49 | ); 50 | } 51 | 52 | /// Converts the [AccessControlExposeHeadersHeader] instance into a string 53 | /// representation suitable for HTTP headers. 54 | 55 | String _encode() => isWildcard ? '*' : headers?.join(', ') ?? ''; 56 | 57 | @override 58 | String toString() => 59 | 'AccessControlExposeHeadersHeader(headers: $headers, isWildcard: $isWildcard)'; 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/authentication_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Authentication header. 4 | final class AuthenticationHeader { 5 | static const codec = HeaderCodec.single(AuthenticationHeader.parse, __encode); 6 | static List __encode(final AuthenticationHeader value) => 7 | [value._encode()]; 8 | 9 | /// The authentication scheme (e.g., "Basic", "Bearer", "Digest"). 10 | final String scheme; 11 | 12 | /// The parameters associated with the authentication scheme. 13 | final List parameters; 14 | 15 | /// Constructs an [AuthenticationHeader] instance with the specified scheme and parameters. 16 | const AuthenticationHeader({ 17 | required this.scheme, 18 | required this.parameters, 19 | }); 20 | 21 | /// Parses the Authentication header value and returns an [AuthenticationHeader] instance. 22 | factory AuthenticationHeader.parse(final String value) { 23 | final trimmed = value.trim(); 24 | if (trimmed.isEmpty) { 25 | throw const FormatException('Value cannot be empty'); 26 | } 27 | 28 | // Split into scheme and parameters part 29 | final firstSpace = trimmed.indexOf(' '); 30 | if (firstSpace == -1) { 31 | throw const FormatException('Missing scheme or parameters'); 32 | } 33 | 34 | final scheme = trimmed.substring(0, firstSpace).trim(); 35 | final paramsString = trimmed.substring(firstSpace + 1).trim(); 36 | final parameters = []; 37 | 38 | if (paramsString.isNotEmpty) { 39 | // Split parameters by comma, but not within quotes 40 | final paramRegex = RegExp(r'(\w+)="([^"]*)"'); 41 | final matches = paramRegex.allMatches(paramsString); 42 | 43 | if (matches.isEmpty && paramsString.isNotEmpty) { 44 | // If no key="value" pairs found but string is not empty, 45 | // treat the entire remaining string as a single parameter value 46 | parameters.add(AuthenticationParameter('', paramsString)); 47 | } else { 48 | for (final match in matches) { 49 | final key = match.group(1)!; 50 | final value = match.group(2)!; 51 | parameters.add(AuthenticationParameter(key, value)); 52 | } 53 | } 54 | } 55 | 56 | return AuthenticationHeader(scheme: scheme, parameters: parameters); 57 | } 58 | 59 | /// Converts the [AuthenticationHeader] instance into a string representation 60 | /// suitable for HTTP headers. 61 | String _encode() { 62 | final paramsString = parameters.map((final param) { 63 | if (param.key.isEmpty) return param.value; 64 | return '${param.key}="${param.value}"'; 65 | }).join(', '); 66 | return '$scheme $paramsString'; 67 | } 68 | 69 | @override 70 | String toString() { 71 | return 'AuthenticationHeader(scheme: $scheme, parameters: $parameters)'; 72 | } 73 | } 74 | 75 | /// A class representing a key-value pair parameter for authentication. 76 | class AuthenticationParameter { 77 | final String key; 78 | final String value; 79 | 80 | const AuthenticationParameter(this.key, this.value); 81 | 82 | @override 83 | String toString() => 'AuthenticationParameter(key: $key, value: $value)'; 84 | } 85 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/content_encoding_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Content-Encoding header. 5 | /// 6 | /// This class manages content encodings such as `gzip`, `compress`, `deflate`, 7 | /// `br`, and `identity`. It provides functionality to parse and generate 8 | /// content encoding header values. 9 | final class ContentEncodingHeader { 10 | static const codec = HeaderCodec(ContentEncodingHeader.parse, __encode); 11 | static List __encode(final ContentEncodingHeader value) => 12 | [value._encode()]; 13 | 14 | /// A list of content encodings. 15 | final List encodings; 16 | 17 | /// Constructs a [ContentEncodingHeader] instance with the specified content 18 | /// encodings. 19 | const ContentEncodingHeader({ 20 | required this.encodings, 21 | }); 22 | 23 | /// Parses the Content-Encoding header value and returns a 24 | /// [ContentEncodingHeader] instance. 25 | /// 26 | /// This method splits the value by commas and trims each encoding. 27 | factory ContentEncodingHeader.parse(final Iterable values) { 28 | final splitValues = values.splitTrimAndFilterUnique(); 29 | if (splitValues.isEmpty) { 30 | throw const FormatException('Value cannot be empty'); 31 | } 32 | 33 | final parsedEncodings = 34 | splitValues.map((final e) => ContentEncoding.parse(e)).toList(); 35 | 36 | return ContentEncodingHeader(encodings: parsedEncodings); 37 | } 38 | 39 | /// Checks if the Content-Encoding contains a specific encoding. 40 | bool containsEncoding(final ContentEncoding encoding) { 41 | return encodings.contains(encoding); 42 | } 43 | 44 | /// Converts the [ContentEncodingHeader] instance into a string representation 45 | /// suitable for HTTP headers. 46 | String _encode() => encodings.map((final e) => e.name).join(', '); 47 | 48 | @override 49 | String toString() { 50 | return 'ContentEncodingHeader(encodings: $encodings)'; 51 | } 52 | } 53 | 54 | /// A class representing valid content encodings. 55 | class ContentEncoding { 56 | /// The string representation of the content encoding. 57 | final String name; 58 | 59 | /// Constructs a [ContentEncoding] instance with the specified name. 60 | const ContentEncoding._(this.name); 61 | 62 | /// Predefined content encodings. 63 | static const _gzip = 'gzip'; 64 | static const _compress = 'compress'; 65 | static const _deflate = 'deflate'; 66 | static const _br = 'br'; 67 | static const _identity = 'identity'; 68 | static const _zstd = 'zstd'; 69 | 70 | static const gzip = ContentEncoding._(_gzip); 71 | static const compress = ContentEncoding._(_compress); 72 | static const deflate = ContentEncoding._(_deflate); 73 | static const br = ContentEncoding._(_br); 74 | static const identity = ContentEncoding._(_identity); 75 | static const zstd = ContentEncoding._(_zstd); 76 | 77 | /// Parses a [name] and returns the corresponding [ContentEncoding] instance. 78 | /// If the name does not match any predefined encodings, it returns a custom 79 | /// instance. 80 | factory ContentEncoding.parse(final String name) { 81 | final trimmed = name.trim(); 82 | if (trimmed.isEmpty) { 83 | throw const FormatException('Name cannot be empty'); 84 | } 85 | switch (trimmed) { 86 | case _gzip: 87 | return gzip; 88 | case _compress: 89 | return compress; 90 | case _deflate: 91 | return deflate; 92 | case _br: 93 | return br; 94 | case _identity: 95 | return identity; 96 | case _zstd: 97 | return zstd; 98 | default: 99 | throw const FormatException('Invalid value'); 100 | } 101 | } 102 | 103 | @override 104 | String toString() => 'ContentEncoding(name: $name)'; 105 | } 106 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/content_language_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Content-Language header. 5 | /// 6 | /// This class manages the language codes specified in the Content-Language header. 7 | final class ContentLanguageHeader { 8 | static const codec = HeaderCodec(ContentLanguageHeader.parse, __encode); 9 | static List __encode(final ContentLanguageHeader value) => 10 | [value._encode()]; 11 | 12 | /// The list of language codes specified in the header. 13 | final Iterable languages; 14 | 15 | /// Constructs a [ContentLanguageHeader] instance with the specified language codes. 16 | const ContentLanguageHeader({required this.languages}); 17 | 18 | /// Parses the Content-Language header value and returns a [ContentLanguageHeader] instance. 19 | /// 20 | /// This method splits the header value by commas and trims each language code. 21 | factory ContentLanguageHeader.parse(final Iterable values) { 22 | final splitValues = values.splitTrimAndFilterUnique(); 23 | if (splitValues.isEmpty) { 24 | throw const FormatException('Value cannot be empty'); 25 | } 26 | 27 | final languages = splitValues.map((final language) { 28 | if (!language.isValidLanguageCode()) { 29 | throw const FormatException('Invalid language code'); 30 | } 31 | return language; 32 | }).toList(); 33 | 34 | return ContentLanguageHeader(languages: languages); 35 | } 36 | 37 | /// Converts the [ContentLanguageHeader] instance into a string representation 38 | /// suitable for HTTP headers. 39 | 40 | String _encode() => languages.join(', '); 41 | 42 | @override 43 | String toString() { 44 | return 'ContentLanguageHeader(languages: $languages)'; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/content_range_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing an HTTP Content-Range header for byte ranges. 4 | /// 5 | /// This class is used to manage byte ranges in HTTP requests or responses, 6 | /// including cases for unsatisfiable range requests. 7 | final class ContentRangeHeader { 8 | static const codec = HeaderCodec.single(ContentRangeHeader.parse, __encode); 9 | static List __encode(final ContentRangeHeader value) => 10 | [value._encode()]; 11 | 12 | /// The unit of the range, e.g. "bytes". 13 | final String unit; 14 | 15 | /// The start of the byte range, or `null` if this is an unsatisfiable range. 16 | final int? start; 17 | 18 | /// The end of the byte range, or `null` if this is an unsatisfiable range. 19 | final int? end; 20 | 21 | /// The total size of the resource being ranged, or `null` if unknown. 22 | final int? size; 23 | 24 | /// Constructs a [ContentRangeHeader] with the specified range and optional total size. 25 | ContentRangeHeader({ 26 | this.unit = 'bytes', 27 | this.start, 28 | this.end, 29 | this.size, 30 | }) { 31 | if (start != null && end != null && start! > end!) { 32 | throw const FormatException('Invalid range'); 33 | } 34 | } 35 | 36 | /// Factory constructor to create a [ContentRangeHeader] from the header string. 37 | factory ContentRangeHeader.parse(final String value) { 38 | final trimmed = value.trim(); 39 | if (trimmed.isEmpty) { 40 | throw const FormatException('Value cannot be empty'); 41 | } 42 | 43 | final regex = RegExp(r'(\w+) (?:(\d+)-(\d+)|\*)/(\*|\d+)'); 44 | final match = regex.firstMatch(trimmed); 45 | 46 | if (match == null) { 47 | throw const FormatException('Invalid format'); 48 | } 49 | 50 | final unit = match.group(1)!; 51 | final start = match.group(2) != null ? int.tryParse(match.group(2)!) : null; 52 | final end = match.group(3) != null ? int.tryParse(match.group(3)!) : null; 53 | if (start != null && end != null && start > end) { 54 | throw const FormatException('Invalid range'); 55 | } 56 | final sizeGroup = match.group(4)!; 57 | 58 | // If totalSize is '*', it means the total size is unknown 59 | final size = sizeGroup == '*' ? null : int.parse(sizeGroup); 60 | 61 | return ContentRangeHeader( 62 | unit: unit, 63 | start: start, 64 | end: end, 65 | size: size, 66 | ); 67 | } 68 | 69 | /// Returns the full content range string in the format "bytes start-end/totalSize". 70 | /// 71 | /// If the total size is unknown, it uses "*" in place of the total size. 72 | 73 | String _encode() { 74 | final sizeStr = size?.toString() ?? '*'; 75 | if (start == null && end == null) { 76 | return '$unit */$sizeStr'; 77 | } 78 | return '$unit $start-$end/$sizeStr'; 79 | } 80 | 81 | @override 82 | String toString() { 83 | return 'ContentRangeHeader(unit: $unit, start: $start, end: $end, size: $size)'; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/content_security_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Content-Security-Policy (CSP) header. 5 | /// 6 | /// This class manages CSP directives, providing functionality to parse, add, 7 | /// remove, and generate CSP header values. 8 | final class ContentSecurityPolicyHeader { 9 | static const codec = 10 | HeaderCodec.single(ContentSecurityPolicyHeader.parse, __encode); 11 | static List __encode(final ContentSecurityPolicyHeader value) => 12 | [value._encode()]; 13 | 14 | /// A list of CSP directives. 15 | final List directives; 16 | 17 | /// Constructs a [ContentSecurityPolicyHeader] instance with the specified 18 | /// directives. 19 | const ContentSecurityPolicyHeader({required this.directives}); 20 | 21 | /// Parses a CSP header value and returns a [ContentSecurityPolicyHeader] 22 | /// instance. 23 | /// 24 | /// This method splits the header value by semicolons, trims each directive, 25 | /// and processes the directive and its values. 26 | factory ContentSecurityPolicyHeader.parse(final String value) { 27 | final splitValues = value.splitTrimAndFilterUnique(separator: ';'); 28 | if (splitValues.isEmpty) { 29 | throw const FormatException('Value cannot be empty'); 30 | } 31 | 32 | final directiveSeparator = RegExp(r'\s+'); 33 | final directives = 34 | splitValues.map((final part) { 35 | final directiveParts = part.split(directiveSeparator); 36 | final name = directiveParts.first; 37 | final values = directiveParts.skip(1).toList(); 38 | return ContentSecurityPolicyDirective( 39 | name: name, 40 | values: values, 41 | ); 42 | }).toList(); 43 | 44 | return ContentSecurityPolicyHeader(directives: directives); 45 | } 46 | 47 | /// Converts the [ContentSecurityPolicyHeader] instance into a string 48 | /// representation suitable for HTTP headers. 49 | 50 | String _encode() { 51 | return directives.map((final directive) => directive._encode()).join('; '); 52 | } 53 | 54 | @override 55 | String toString() { 56 | return 'ContentSecurityPolicyHeader(directives: $directives)'; 57 | } 58 | } 59 | 60 | /// A class representing a single CSP directive. 61 | class ContentSecurityPolicyDirective { 62 | /// The name of the directive (e.g., `default-src`, `script-src`). 63 | final String name; 64 | 65 | /// The values associated with the directive (e.g., `'self'`, 66 | /// `https://example.com`). 67 | final Iterable values; 68 | 69 | /// Constructs a [ContentSecurityPolicyDirective] instance with the specified 70 | /// name and values. 71 | ContentSecurityPolicyDirective({ 72 | required this.name, 73 | required this.values, 74 | }); 75 | 76 | /// Converts the [ContentSecurityPolicyDirective] instance into a string 77 | /// representation. 78 | String _encode() => '$name ${values.join(' ')}'; 79 | 80 | @override 81 | String toString() { 82 | return 'ContentSecurityPolicyDirective(name: $name, values: $values)'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/cookie_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import '../../../../relic.dart'; 3 | import '../../extension/string_list_extensions.dart'; 4 | import 'util/cookie_util.dart'; 5 | 6 | /// A class representing the HTTP Cookie header. 7 | /// 8 | /// This class manages the parsing and representation of cookies. 9 | final class CookieHeader { 10 | static const codec = HeaderCodec.single(CookieHeader.parse, __encode); 11 | static List __encode(final CookieHeader value) => [value._encode()]; 12 | 13 | /// The list of cookies. 14 | final List cookies; 15 | 16 | /// Constructs a [CookieHeader] instance with the specified cookies. 17 | const CookieHeader({required this.cookies}); 18 | 19 | /// Parses the Cookie header value and returns a [CookieHeader] instance. 20 | /// 21 | /// This method processes the header value, extracting the cookies into a list. 22 | factory CookieHeader.parse(final String value) { 23 | if (value.isEmpty) { 24 | throw const FormatException('Value cannot be empty'); 25 | } 26 | 27 | final splitValues = value.splitTrimAndFilterUnique(separator: ';'); 28 | 29 | final cookies = splitValues.map(Cookie.parse).toList(); 30 | final names = 31 | cookies.map((final cookie) => cookie.name.toLowerCase()).toList(); 32 | final uniqueNames = names.toSet(); 33 | 34 | if (names.length != uniqueNames.length) { 35 | throw const FormatException( 36 | 'Supplied multiple Name and Value attributes'); 37 | } 38 | 39 | return CookieHeader(cookies: cookies); 40 | } 41 | 42 | Cookie? getCookie(final String name) { 43 | return cookies.firstWhereOrNull((final cookie) => cookie.name == name); 44 | } 45 | 46 | /// Converts the [CookieHeader] instance into a string representation 47 | /// suitable for HTTP headers. 48 | 49 | String _encode() { 50 | return cookies.map((final cookie) => cookie._encode()).join('; '); 51 | } 52 | 53 | @override 54 | String toString() { 55 | return 'CookieHeader(cookies: $cookies)'; 56 | } 57 | } 58 | 59 | /// A class representing a single cookie. 60 | class Cookie { 61 | /// The name of the cookie. 62 | final String name; 63 | 64 | /// The value of the cookie. 65 | final String value; 66 | 67 | Cookie({ 68 | required final String name, 69 | required final String value, 70 | }) : name = validateCookieName(name), 71 | value = validateCookieValue(value); 72 | 73 | factory Cookie.parse(final String value) { 74 | final splitValue = value.split('='); 75 | if (splitValue.length != 2) { 76 | throw const FormatException('Invalid cookie format'); 77 | } 78 | 79 | return Cookie( 80 | name: splitValue.first.trim(), 81 | value: splitValue.last.trim(), 82 | ); 83 | } 84 | 85 | /// Converts the [Cookie] instance into a string representation suitable for HTTP headers. 86 | String _encode() => '$name=$value'; 87 | 88 | @override 89 | String toString() { 90 | return 'Cookie(name: $name, value: $value)'; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/cross_origin_embedder_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Cross-Origin-Embedder-Policy header. 4 | /// 5 | /// This header specifies the policy for embedding cross-origin resources. 6 | final class CrossOriginEmbedderPolicyHeader { 7 | static const codec = 8 | HeaderCodec.single(CrossOriginEmbedderPolicyHeader.parse, __encode); 9 | static List __encode(final CrossOriginEmbedderPolicyHeader value) => 10 | [value._encode()]; 11 | 12 | /// The policy value of the header. 13 | final String policy; 14 | 15 | /// Constructs a [CrossOriginEmbedderPolicyHeader] instance with the specified value. 16 | const CrossOriginEmbedderPolicyHeader._(this.policy); 17 | 18 | /// Predefined policy values. 19 | static const _unsafeNone = 'unsafe-none'; 20 | static const _requireCorp = 'require-corp'; 21 | static const _credentialless = 'credentialless'; 22 | 23 | static const unsafeNone = CrossOriginEmbedderPolicyHeader._(_unsafeNone); 24 | static const requireCorp = CrossOriginEmbedderPolicyHeader._(_requireCorp); 25 | static const credentialless = 26 | CrossOriginEmbedderPolicyHeader._(_credentialless); 27 | 28 | /// Parses a [value] and returns the corresponding [CrossOriginEmbedderPolicyHeader] instance. 29 | /// If the value does not match any predefined types, it returns a custom instance. 30 | factory CrossOriginEmbedderPolicyHeader.parse(final String value) { 31 | final trimmed = value.trim(); 32 | if (trimmed.isEmpty) { 33 | throw const FormatException('Value cannot be empty'); 34 | } 35 | 36 | switch (trimmed) { 37 | case _unsafeNone: 38 | return unsafeNone; 39 | case _requireCorp: 40 | return requireCorp; 41 | case _credentialless: 42 | return credentialless; 43 | default: 44 | throw const FormatException('Invalid value'); 45 | } 46 | } 47 | 48 | /// Converts the [CrossOriginEmbedderPolicyHeader] instance into a string 49 | /// representation suitable for HTTP headers. 50 | 51 | String _encode() => policy; 52 | 53 | @override 54 | String toString() { 55 | return 'CrossOriginEmbedderPolicyHeader(value: $policy)'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/cross_origin_opener_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Cross-Origin-Opener-Policy header. 4 | /// 5 | /// This header specifies the policy for opening cross-origin resources. 6 | final class CrossOriginOpenerPolicyHeader { 7 | static const codec = 8 | HeaderCodec.single(CrossOriginOpenerPolicyHeader.parse, __encode); 9 | static List __encode(final CrossOriginOpenerPolicyHeader value) => 10 | [value._encode()]; 11 | 12 | /// The policy value of the header. 13 | final String policy; 14 | 15 | /// Constructs a [CrossOriginOpenerPolicyHeader] instance with the specified value. 16 | const CrossOriginOpenerPolicyHeader._(this.policy); 17 | 18 | /// Predefined policy values. 19 | static const _sameOrigin = 'same-origin'; 20 | static const _sameOriginAllowPopups = 'same-origin-allow-popups'; 21 | static const _unsafeNone = 'unsafe-none'; 22 | 23 | static const sameOrigin = CrossOriginOpenerPolicyHeader._(_sameOrigin); 24 | static const sameOriginAllowPopups = 25 | CrossOriginOpenerPolicyHeader._(_sameOriginAllowPopups); 26 | static const unsafeNone = CrossOriginOpenerPolicyHeader._(_unsafeNone); 27 | 28 | /// Parses a [value] and returns the corresponding [CrossOriginOpenerPolicyHeader] instance. 29 | /// If the value does not match any predefined types, it returns a custom instance. 30 | factory CrossOriginOpenerPolicyHeader.parse(final String value) { 31 | final trimmed = value.trim(); 32 | if (trimmed.isEmpty) { 33 | throw const FormatException('Value cannot be empty'); 34 | } 35 | 36 | switch (trimmed) { 37 | case _sameOrigin: 38 | return sameOrigin; 39 | case _sameOriginAllowPopups: 40 | return sameOriginAllowPopups; 41 | case _unsafeNone: 42 | return unsafeNone; 43 | default: 44 | throw const FormatException('Invalid value'); 45 | } 46 | } 47 | 48 | /// Converts the [CrossOriginOpenerPolicyHeader] instance into a string 49 | /// representation suitable for HTTP headers. 50 | 51 | String _encode() => policy; 52 | 53 | @override 54 | String toString() { 55 | return 'CrossOriginOpenerPolicyHeader(value: $policy)'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/cross_origin_resource_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Cross-Origin-Resource-Policy header. 4 | /// 5 | /// This header specifies the policy for sharing resources across origins. 6 | final class CrossOriginResourcePolicyHeader { 7 | static const codec = 8 | HeaderCodec.single(CrossOriginResourcePolicyHeader.parse, __encode); 9 | static List __encode(final CrossOriginResourcePolicyHeader value) => 10 | [value._encode()]; 11 | 12 | /// The policy value of the header. 13 | final String policy; 14 | 15 | /// Constructs a [CrossOriginResourcePolicyHeader] instance with the specified value. 16 | const CrossOriginResourcePolicyHeader._(this.policy); 17 | 18 | /// Predefined policy values. 19 | static const _sameOrigin = 'same-origin'; 20 | static const _sameSite = 'same-site'; 21 | static const _crossOrigin = 'cross-origin'; 22 | 23 | static const sameOrigin = CrossOriginResourcePolicyHeader._(_sameOrigin); 24 | static const sameSite = CrossOriginResourcePolicyHeader._(_sameSite); 25 | static const crossOrigin = CrossOriginResourcePolicyHeader._(_crossOrigin); 26 | 27 | /// Parses a [value] and returns the corresponding [CrossOriginResourcePolicyHeader] instance. 28 | /// If the value does not match any predefined types, it returns a custom instance. 29 | factory CrossOriginResourcePolicyHeader.parse(final String value) { 30 | final trimmed = value.trim(); 31 | if (trimmed.isEmpty) { 32 | throw const FormatException('Value cannot be empty'); 33 | } 34 | switch (trimmed) { 35 | case _sameOrigin: 36 | return sameOrigin; 37 | case _sameSite: 38 | return sameSite; 39 | case _crossOrigin: 40 | return crossOrigin; 41 | default: 42 | throw const FormatException('Invalid value'); 43 | } 44 | } 45 | 46 | /// Converts the [CrossOriginResourcePolicyHeader] instance into a string 47 | /// representation suitable for HTTP headers. 48 | 49 | String _encode() => policy; 50 | 51 | @override 52 | String toString() { 53 | return 'CrossOriginResourcePolicyHeader(value: $policy)'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/etag_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP ETag header. 4 | /// 5 | /// This class manages the ETag value, which can be either strong or weak. 6 | /// It provides functionality to parse the header value and construct the 7 | /// appropriate header string. 8 | final class ETagHeader { 9 | static const codec = HeaderCodec.single(ETagHeader.parse, __encode); 10 | static List __encode(final ETagHeader value) => [value._encode()]; 11 | 12 | /// The ETag value without quotes. 13 | final String value; 14 | 15 | /// Indicates whether the ETag is weak. 16 | final bool isWeak; 17 | 18 | /// Constructs an [ETagHeader] instance with the specified value and whether it is weak. 19 | const ETagHeader({ 20 | required this.value, 21 | this.isWeak = false, 22 | }); 23 | 24 | /// Predefined ETag prefixes. 25 | static const _weakPrefix = 'W/'; 26 | static const _quote = '"'; 27 | 28 | /// Checks if a string is a valid ETag format (either strong or weak). 29 | /// 30 | /// Returns true if the string is either: 31 | /// - A strong ETag: quoted string (e.g., "123456") 32 | /// - A weak ETag: W/ followed by a quoted string (e.g., W/"123456") 33 | static bool isValidETag(final String value) { 34 | final trimmed = value.trim(); 35 | if (trimmed.isEmpty) { 36 | throw const FormatException('Value cannot be empty'); 37 | } 38 | 39 | // Check for weak ETag format 40 | if (trimmed.startsWith(_weakPrefix)) { 41 | final tag = trimmed.substring(2).trim(); 42 | return tag.startsWith(_quote) && tag.endsWith(_quote); 43 | } 44 | 45 | // Check for strong ETag format 46 | return trimmed.startsWith(_quote) && trimmed.endsWith(_quote); 47 | } 48 | 49 | /// Parses the ETag header value and returns an [ETagHeader] instance. 50 | /// 51 | /// This method validates the format of the ETag string and parses 52 | /// the ETag value and whether it is weak. 53 | factory ETagHeader.parse(final String value) { 54 | if (!isValidETag(value)) { 55 | throw const FormatException('Invalid format'); 56 | } 57 | 58 | final isWeak = value.startsWith(_weakPrefix); 59 | final tagValue = isWeak ? value.substring(2).trim() : value.trim(); 60 | return ETagHeader(value: tagValue.replaceAll(_quote, ''), isWeak: isWeak); 61 | } 62 | 63 | /// Converts the [ETagHeader] instance into a string representation suitable 64 | /// for HTTP headers. 65 | String _encode() { 66 | final prefix = isWeak ? _weakPrefix : ''; 67 | return '$prefix$_quote$value$_quote'; 68 | } 69 | 70 | @override 71 | String toString() { 72 | return 'ETagHeader(value: $value, isWeak: $isWeak)'; 73 | } 74 | } 75 | 76 | // This class should be hidden on public export 77 | extension InternalEx on ETagHeader { 78 | String encode() => _encode(); 79 | } 80 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/expect_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Expect header. 4 | /// 5 | /// This class manages the directive for the Expect header, such as `100-continue`. 6 | /// It provides functionality to parse and generate Expect header values. 7 | final class ExpectHeader { 8 | static const codec = HeaderCodec.single(ExpectHeader.parse, __encode); 9 | static List __encode(final ExpectHeader value) => [value._encode()]; 10 | 11 | /// The string representation of the expectation directive. 12 | final String value; 13 | 14 | /// Constructs an [ExpectHeader] instance with the specified value. 15 | const ExpectHeader._(this.value); 16 | 17 | /// Predefined expectation directives. 18 | static const _continue100 = '100-continue'; 19 | 20 | static const continue100 = ExpectHeader._(_continue100); 21 | 22 | /// Parses a [value] and returns the corresponding [ExpectHeader] instance. 23 | /// If the value does not match any predefined types, it returns a custom instance. 24 | factory ExpectHeader.parse(final String value) { 25 | final trimmed = value.trim(); 26 | if (trimmed.isEmpty) { 27 | throw const FormatException('Value cannot be empty'); 28 | } 29 | switch (trimmed) { 30 | case _continue100: 31 | return continue100; 32 | default: 33 | throw const FormatException('Invalid value'); 34 | } 35 | } 36 | 37 | /// Converts the [ExpectHeader] instance into a string representation 38 | /// suitable for HTTP headers. 39 | 40 | String _encode() => value; 41 | 42 | @override 43 | String toString() { 44 | return 'ExpectHeader(value: $value)'; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/from_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP `From` header. 5 | /// 6 | /// The `From` header is used to indicate the email address of the user making the request. 7 | /// It usually contains a single email address, but in edge cases, it could contain multiple 8 | /// email addresses separated by commas. 9 | final class FromHeader { 10 | static const codec = HeaderCodec(FromHeader.parse, __encode); 11 | static List __encode(final FromHeader value) => [value._encode()]; 12 | 13 | /// A list of email addresses provided in the `From` header. 14 | final Iterable emails; 15 | 16 | /// Private constructor for initializing the [emails] list. 17 | FromHeader({required this.emails}); 18 | 19 | /// Parses a `From` header value and returns a [FromHeader] instance. 20 | factory FromHeader.parse(final Iterable values) { 21 | final emails = values.splitTrimAndFilterUnique(); 22 | if (emails.isEmpty) { 23 | throw const FormatException('Value cannot be empty'); 24 | } 25 | 26 | for (final email in emails) { 27 | if (!email.isValidEmail()) { 28 | throw const FormatException('Invalid email format'); 29 | } 30 | } 31 | 32 | return FromHeader(emails: emails); 33 | } 34 | 35 | /// Returns the single email address if the list only contains one email. 36 | String? get singleEmail => emails.length == 1 ? emails.first : null; 37 | 38 | /// Converts the [FromHeader] instance into a string representation 39 | /// suitable for HTTP headers. 40 | 41 | String _encode() => emails.join(', '); 42 | 43 | @override 44 | String toString() { 45 | return 'FromHeader(emails: $emails)'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/if_range_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:http_parser/http_parser.dart'; 2 | import '../../../../relic.dart'; 3 | 4 | import 'etag_header.dart'; 5 | 6 | /// A class representing the HTTP `If-Range` header. 7 | /// 8 | /// The `If-Range` header can contain either an HTTP date or an ETag. 9 | final class IfRangeHeader { 10 | static const codec = HeaderCodec.single(IfRangeHeader.parse, __encode); 11 | static List __encode(final IfRangeHeader value) => [value._encode()]; 12 | 13 | /// The HTTP date if the `If-Range` header contains a date. 14 | final DateTime? lastModified; 15 | 16 | /// The ETag if the `If-Range` header contains an ETag. 17 | final ETagHeader? etag; 18 | 19 | /// Constructs an [IfRangeHeader] instance with either a date or an ETag. 20 | /// 21 | /// Either [lastModified] or [etag] must be non-null. 22 | IfRangeHeader({ 23 | this.lastModified, 24 | this.etag, 25 | }) { 26 | if (lastModified == null && etag == null) { 27 | throw const FormatException('Either date or etag must be provided'); 28 | } 29 | } 30 | 31 | /// Parses the `If-Range` header value and returns an [IfRangeHeader] instance. 32 | /// 33 | /// Determines if the value is an ETag or a date and creates the appropriate instance. 34 | factory IfRangeHeader.parse(final String value) { 35 | final trimmed = value.trim(); 36 | if (trimmed.isEmpty) { 37 | throw const FormatException('Value cannot be empty'); 38 | } 39 | 40 | // Check if the value is a valid ETag 41 | if (ETagHeader.isValidETag(trimmed)) { 42 | return IfRangeHeader(etag: ETagHeader.parse(trimmed)); 43 | } 44 | 45 | try { 46 | final parsedDate = parseHttpDate(trimmed); 47 | return IfRangeHeader(lastModified: parsedDate); 48 | } catch (_) { 49 | throw const FormatException('Invalid format'); 50 | } 51 | } 52 | 53 | /// Converts the [IfRangeHeader] instance into a string representation 54 | /// suitable for HTTP headers. 55 | 56 | String _encode() => 57 | lastModified != null ? formatHttpDate(lastModified!) : etag!.encode(); 58 | 59 | @override 60 | String toString() { 61 | return 'IfRangeHeader(lastModified: $lastModified, etag: $etag)'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/permission_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Permissions-Policy header. 5 | /// 6 | /// This class manages Permissions-Policy directives, providing functionality to parse, 7 | /// add, remove, and generate Permissions-Policy header values. 8 | final class PermissionsPolicyHeader { 9 | static const codec = 10 | HeaderCodec.single(PermissionsPolicyHeader.parse, __encode); 11 | static List __encode(final PermissionsPolicyHeader value) => 12 | [value._encode()]; 13 | 14 | /// A list of Permissions-Policy directives. 15 | final List directives; 16 | 17 | /// Constructs a [PermissionsPolicyHeader] instance with the specified directives. 18 | const PermissionsPolicyHeader({required this.directives}); 19 | 20 | /// Parses the Permissions-Policy header value and returns a [PermissionsPolicyHeader] instance. 21 | /// 22 | /// This method splits the header value by commas, trims each directive, 23 | /// and processes the directive and its values. 24 | factory PermissionsPolicyHeader.parse(final String value) { 25 | final splitValues = value.splitTrimAndFilterUnique(separator: ','); 26 | if (splitValues.isEmpty) { 27 | throw const FormatException('Value cannot be empty'); 28 | } 29 | 30 | final directives = []; 31 | for (final part in splitValues) { 32 | final directiveParts = part.split('='); 33 | final name = directiveParts.first.trim(); 34 | final values = directiveParts.length > 1 35 | ? directiveParts[1] 36 | .replaceAll('(', '') 37 | .replaceAll(')', '') 38 | .split(' ') 39 | .map((final s) => s.trim()) 40 | .toList() 41 | : []; 42 | 43 | directives.add( 44 | PermissionsPolicyDirective( 45 | name: name, 46 | values: values, 47 | ), 48 | ); 49 | } 50 | 51 | return PermissionsPolicyHeader(directives: directives); 52 | } 53 | 54 | /// Converts the [PermissionsPolicyHeader] instance into a string 55 | /// representation suitable for HTTP headers. 56 | 57 | String _encode() { 58 | return directives.map((final directive) => directive._encode()).join(', '); 59 | } 60 | 61 | @override 62 | String toString() { 63 | return 'PermissionsPolicyHeader(directives: $directives)'; 64 | } 65 | } 66 | 67 | /// A class representing a single Permissions-Policy directive. 68 | class PermissionsPolicyDirective { 69 | /// The name of the directive (e.g., `geolocation`, `microphone`). 70 | final String name; 71 | 72 | /// The values associated with the directive (e.g., `self`, `https://example.com`). 73 | final Iterable values; 74 | 75 | /// Constructs a [PermissionsPolicyDirective] instance with the specified name and values. 76 | const PermissionsPolicyDirective({ 77 | required this.name, 78 | required this.values, 79 | }); 80 | 81 | /// Converts the [PermissionsPolicyDirective] instance into a string representation. 82 | String _encode() { 83 | final valuesStr = values.isNotEmpty ? '(${values.join(' ')})' : '()'; 84 | return '$name=$valuesStr'; 85 | } 86 | 87 | @override 88 | String toString() { 89 | return 'PermissionsPolicyDirective(name: $name, values: $values)'; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/range_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Range header. 4 | /// 5 | /// This class manages byte ranges, such as `bytes=0-499` or multiple 6 | /// ranges like `bytes=200-999, 2000-2499, 9500-`. It allows clients to 7 | /// request specific parts of a resource. It provides functionality to 8 | /// parse and generate range header values for different range units like 9 | /// bytes or custom units. 10 | final class RangeHeader { 11 | static const codec = HeaderCodec.single(RangeHeader.parse, __encode); 12 | static List __encode(final RangeHeader value) => [value._encode()]; 13 | 14 | /// The unit of the range (e.g., "bytes"). 15 | final String unit; 16 | 17 | /// The list of ranges specified in the header. 18 | final List ranges; 19 | 20 | /// Constructs a [RangeHeader] instance with the specified unit and list 21 | /// of ranges. 22 | const RangeHeader({ 23 | this.unit = 'bytes', 24 | required this.ranges, 25 | }); 26 | 27 | /// Parses the Range header value and returns a [RangeHeader] instance. 28 | /// 29 | /// This method processes the range header and extracts the unit and 30 | /// multiple range values. 31 | factory RangeHeader.parse(final String value) { 32 | final trimmed = value.trim(); 33 | if (trimmed.isEmpty) { 34 | throw const FormatException('Value cannot be empty'); 35 | } 36 | 37 | final regex = RegExp(r'^\s*(\w+)\s*=\s*(.*)$'); 38 | final match = regex.firstMatch(trimmed); 39 | 40 | if (match == null) { 41 | throw const FormatException('Invalid Range header: invalid format'); 42 | } 43 | 44 | final unit = match.group(1) ?? 'bytes'; 45 | final rangesPart = match.group(2); 46 | 47 | if (rangesPart == null) { 48 | throw const FormatException('Invalid Range header: missing ranges'); 49 | } 50 | 51 | final rangeStrings = rangesPart.split(','); 52 | final ranges = rangeStrings.map((final rangeStr) { 53 | final trimmedRange = rangeStr.trim(); 54 | final rangeMatch = RegExp(r'^(\d*)-(\d*)$').firstMatch(trimmedRange); 55 | if (rangeMatch == null) { 56 | throw const FormatException('Invalid range'); 57 | } 58 | 59 | final start = int.tryParse(rangeMatch.group(1) ?? ''); 60 | final end = int.tryParse(rangeMatch.group(2) ?? ''); 61 | 62 | if (start == null && end == null) { 63 | throw const FormatException('Both start and end cannot be empty'); 64 | } 65 | 66 | return Range(start: start, end: end); 67 | }).toList(); 68 | 69 | return RangeHeader(unit: unit, ranges: ranges); 70 | } 71 | 72 | /// Converts the [RangeHeader] instance into a string representation 73 | /// suitable for HTTP headers. 74 | String _encode() { 75 | final rangesStr = ranges.map((final range) => range._encode()).join(', '); 76 | return '$unit=$rangesStr'; 77 | } 78 | 79 | @override 80 | String toString() { 81 | return 'RangeHeader(unit: $unit, ranges: $ranges)'; 82 | } 83 | } 84 | 85 | /// A class representing a single range within a Range header. 86 | class Range { 87 | /// The start of the range. 88 | final int? start; 89 | 90 | /// The end of the range. 91 | final int? end; 92 | 93 | /// Constructs a [Range] instance with the specified start and end of 94 | /// the range. 95 | Range({ 96 | this.start, 97 | this.end, 98 | }) { 99 | if (start == null && end == null) { 100 | throw const FormatException( 101 | 'At least one of start or end must be specified'); 102 | } 103 | } 104 | 105 | /// Converts the [Range] instance into a string representation suitable 106 | /// for HTTP headers. 107 | String _encode() { 108 | final startStr = start?.toString() ?? ''; 109 | final endStr = end?.toString() ?? ''; 110 | return '$startStr-$endStr'; 111 | } 112 | 113 | @override 114 | String toString() { 115 | return 'Range(start: $start, end: $end)'; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/referrer_policy_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Referrer-Policy header. 4 | /// 5 | /// This class manages the referrer policy, providing functionality to parse 6 | /// and generate referrer policy header values. 7 | final class ReferrerPolicyHeader { 8 | static const codec = HeaderCodec.single(ReferrerPolicyHeader.parse, __encode); 9 | static List __encode(final ReferrerPolicyHeader value) => 10 | [value._encode()]; 11 | 12 | /// The string representation of the referrer policy directive. 13 | final String directive; 14 | 15 | /// Private constructor for [ReferrerPolicyHeader]. 16 | const ReferrerPolicyHeader._(this.directive); 17 | 18 | /// Predefined referrer policy directives. 19 | static const _noReferrer = 'no-referrer'; 20 | static const _noReferrerWhenDowngrade = 'no-referrer-when-downgrade'; 21 | static const _origin = 'origin'; 22 | static const _originWhenCrossOrigin = 'origin-when-cross-origin'; 23 | static const _sameOrigin = 'same-origin'; 24 | static const _strictOrigin = 'strict-origin'; 25 | static const _strictOriginWhenCrossOrigin = 'strict-origin-when-cross-origin'; 26 | static const _unsafeUrl = 'unsafe-url'; 27 | 28 | static const noReferrer = ReferrerPolicyHeader._(_noReferrer); 29 | static const noReferrerWhenDowngrade = 30 | ReferrerPolicyHeader._(_noReferrerWhenDowngrade); 31 | static const origin = ReferrerPolicyHeader._(_origin); 32 | static const originWhenCrossOrigin = 33 | ReferrerPolicyHeader._(_originWhenCrossOrigin); 34 | static const sameOrigin = ReferrerPolicyHeader._(_sameOrigin); 35 | static const strictOrigin = ReferrerPolicyHeader._(_strictOrigin); 36 | static const strictOriginWhenCrossOrigin = 37 | ReferrerPolicyHeader._(_strictOriginWhenCrossOrigin); 38 | static const unsafeUrl = ReferrerPolicyHeader._(_unsafeUrl); 39 | 40 | /// Parses a [directive] and returns the corresponding [ReferrerPolicyHeader] instance. 41 | /// If the directive does not match any predefined types, it returns a custom instance. 42 | factory ReferrerPolicyHeader.parse(final String value) { 43 | final trimmed = value.trim(); 44 | if (trimmed.isEmpty) { 45 | throw const FormatException('Value cannot be empty'); 46 | } 47 | 48 | switch (trimmed) { 49 | case _noReferrer: 50 | return noReferrer; 51 | case _noReferrerWhenDowngrade: 52 | return noReferrerWhenDowngrade; 53 | case _origin: 54 | return origin; 55 | case _originWhenCrossOrigin: 56 | return originWhenCrossOrigin; 57 | case _sameOrigin: 58 | return sameOrigin; 59 | case _strictOrigin: 60 | return strictOrigin; 61 | case _strictOriginWhenCrossOrigin: 62 | return strictOriginWhenCrossOrigin; 63 | case _unsafeUrl: 64 | return unsafeUrl; 65 | default: 66 | throw const FormatException('Invalid value'); 67 | } 68 | } 69 | 70 | /// Converts the [ReferrerPolicyHeader] instance into a string 71 | /// representation suitable for HTTP headers. 72 | 73 | String _encode() => directive; 74 | 75 | @override 76 | String toString() { 77 | return 'ReferrerPolicyHeader(directive: $directive)'; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/retry_after_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:http_parser/http_parser.dart'; 2 | import '../../../../relic.dart'; 3 | 4 | /// A class representing the HTTP Retry-After header. 5 | /// 6 | /// This class manages both date-based and delay-based retry values. 7 | /// The Retry-After header can contain either an HTTP date or a delay in seconds 8 | /// indicating when the client should retry the request. 9 | final class RetryAfterHeader { 10 | static const codec = HeaderCodec.single(RetryAfterHeader.parse, __encode); 11 | static List __encode(final RetryAfterHeader value) => 12 | [value._encode()]; 13 | 14 | /// The retry delay in seconds, if present. 15 | final int? delay; 16 | 17 | /// The retry date, if present. 18 | final DateTime? date; 19 | 20 | /// Constructs a [RetryAfterHeader] instance with either a delay in seconds or a date. 21 | RetryAfterHeader({ 22 | this.delay, 23 | this.date, 24 | }) { 25 | if (delay == null && date == null) { 26 | throw const FormatException( 27 | 'Either delay or date must be specified', 28 | ); 29 | } 30 | if (delay != null && date != null) { 31 | throw const FormatException( 32 | 'Both delay and date cannot be specified at the same time', 33 | ); 34 | } 35 | } 36 | 37 | /// Parses the Retry-After header value and returns a [RetryAfterHeader] instance. 38 | /// 39 | /// This method checks if the value is an integer (for delay) or a date string. 40 | factory RetryAfterHeader.parse(final String value) { 41 | final trimmed = value.trim(); 42 | if (trimmed.isEmpty) { 43 | throw const FormatException('Value cannot be empty'); 44 | } 45 | 46 | final delay = int.tryParse(trimmed); 47 | if (delay != null) { 48 | if (delay < 0) { 49 | throw const FormatException('Delay cannot be negative'); 50 | } 51 | return RetryAfterHeader(delay: delay); 52 | } else { 53 | try { 54 | final date = parseHttpDate(trimmed); 55 | return RetryAfterHeader(date: date); 56 | } catch (e) { 57 | throw const FormatException('Invalid date format'); 58 | } 59 | } 60 | } 61 | 62 | /// Converts the [RetryAfterHeader] instance into a string representation 63 | /// suitable for HTTP headers. 64 | 65 | String _encode() { 66 | if (delay != null) { 67 | return delay.toString(); 68 | } 69 | if (date != null) { 70 | return formatHttpDate(date!); 71 | } 72 | return ''; 73 | } 74 | 75 | @override 76 | String toString() { 77 | return 'RetryAfterHeader(delay: $delay, date: $date)'; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/sec_fetch_mode_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Sec-Fetch-Mode header. 4 | /// 5 | /// This header indicates the mode of the request. 6 | final class SecFetchModeHeader { 7 | static const codec = HeaderCodec.single(SecFetchModeHeader.parse, __encode); 8 | static List __encode(final SecFetchModeHeader value) => 9 | [value._encode()]; 10 | 11 | /// The mode value of the request. 12 | final String mode; 13 | 14 | /// Private constructor for [SecFetchModeHeader]. 15 | const SecFetchModeHeader._(this.mode); 16 | 17 | /// Predefined mode values. 18 | static const _cors = 'cors'; 19 | static const _noCors = 'no-cors'; 20 | static const _sameOrigin = 'same-origin'; 21 | static const _navigate = 'navigate'; 22 | static const _nestedNavigate = 'nested-navigate'; 23 | static const _webSocket = 'websocket'; 24 | 25 | static const cors = SecFetchModeHeader._(_cors); 26 | static const noCors = SecFetchModeHeader._(_noCors); 27 | static const sameOrigin = SecFetchModeHeader._(_sameOrigin); 28 | static const navigate = SecFetchModeHeader._(_navigate); 29 | static const nestedNavigate = SecFetchModeHeader._(_nestedNavigate); 30 | static const webSocket = SecFetchModeHeader._(_webSocket); 31 | 32 | /// Parses a [value] and returns the corresponding [SecFetchModeHeader] instance. 33 | /// If the value does not match any predefined types, it returns a custom instance. 34 | factory SecFetchModeHeader.parse(final String value) { 35 | final trimmed = value.trim(); 36 | if (trimmed.isEmpty) { 37 | throw const FormatException('Value cannot be empty'); 38 | } 39 | 40 | switch (trimmed) { 41 | case _cors: 42 | return cors; 43 | case _noCors: 44 | return noCors; 45 | case _sameOrigin: 46 | return sameOrigin; 47 | case _navigate: 48 | return navigate; 49 | case _nestedNavigate: 50 | return nestedNavigate; 51 | case _webSocket: 52 | return webSocket; 53 | default: 54 | throw const FormatException('Invalid value'); 55 | } 56 | } 57 | 58 | /// Converts the [SecFetchModeHeader] instance into a string representation 59 | /// suitable for HTTP headers. 60 | 61 | String _encode() => mode; 62 | 63 | @override 64 | String toString() { 65 | return 'SecFetchModeHeader(value: $mode)'; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/sec_fetch_site_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | 3 | /// A class representing the HTTP Sec-Fetch-Site header. 4 | /// 5 | /// This header indicates the relationship between the origin of the request 6 | /// initiator and the origin of the requested resource. 7 | final class SecFetchSiteHeader { 8 | static const codec = HeaderCodec.single(SecFetchSiteHeader.parse, __encode); 9 | static List __encode(final SecFetchSiteHeader value) => 10 | [value._encode()]; 11 | 12 | /// The site value of the request. 13 | final String site; 14 | 15 | /// Private constructor for [SecFetchSiteHeader]. 16 | const SecFetchSiteHeader._(this.site); 17 | 18 | /// Predefined site values. 19 | static const _sameOrigin = 'same-origin'; 20 | static const _sameSite = 'same-site'; 21 | static const _crossSite = 'cross-site'; 22 | static const _none = 'none'; 23 | 24 | static const sameOrigin = SecFetchSiteHeader._(_sameOrigin); 25 | static const sameSite = SecFetchSiteHeader._(_sameSite); 26 | static const crossSite = SecFetchSiteHeader._(_crossSite); 27 | static const none = SecFetchSiteHeader._(_none); 28 | 29 | /// Parses a [value] and returns the corresponding [SecFetchSiteHeader] instance. 30 | /// If the value does not match any predefined types, it returns a custom instance. 31 | factory SecFetchSiteHeader.parse(final String value) { 32 | final trimmed = value.trim(); 33 | if (trimmed.isEmpty) { 34 | throw const FormatException('Value cannot be empty'); 35 | } 36 | 37 | switch (trimmed) { 38 | case _sameOrigin: 39 | return sameOrigin; 40 | case _sameSite: 41 | return sameSite; 42 | case _crossSite: 43 | return crossSite; 44 | case _none: 45 | return none; 46 | default: 47 | throw const FormatException('Invalid value'); 48 | } 49 | } 50 | 51 | /// Converts the [SecFetchSiteHeader] instance into a string representation 52 | /// suitable for HTTP headers. 53 | 54 | String _encode() => site; 55 | 56 | @override 57 | String toString() { 58 | return 'SecFetchSiteHeader(value: $site)'; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/strict_transport_security_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// Represents the HTTP Strict-Transport-Security (HSTS) header for managing 5 | /// HSTS settings. 6 | final class StrictTransportSecurityHeader { 7 | static const codec = 8 | HeaderCodec.single(StrictTransportSecurityHeader.parse, __encode); 9 | static List __encode(final StrictTransportSecurityHeader value) => 10 | [value._encode()]; 11 | 12 | /// The max-age directive specifies the time, in seconds, that the browser 13 | /// should remember that a site is only to be accessed using HTTPS. 14 | final int maxAge; 15 | 16 | /// The includeSubDomains directive applies this rule to all of the site's subdomains as well. 17 | final bool includeSubDomains; 18 | 19 | /// The preload directive indicates that the site is requesting inclusion 20 | /// in the HSTS preload list maintained by browsers. 21 | final bool preload; 22 | 23 | /// Creates a [StrictTransportSecurityHeader] with the specified [maxAge], [includeSubDomains], and [preload]. 24 | StrictTransportSecurityHeader({ 25 | required this.maxAge, 26 | this.includeSubDomains = false, 27 | this.preload = false, 28 | }); 29 | 30 | /// Predefined directive values. 31 | static const _maxAgePrefix = 'max-age='; 32 | static const _includeSubDomains = 'includeSubDomains'; 33 | static const _preload = 'preload'; 34 | 35 | /// Parses the Strict-Transport-Security header value into a [StrictTransportSecurityHeader] instance. 36 | /// 37 | /// Throws a [FormatException] if the max-age directive is missing or invalid. 38 | factory StrictTransportSecurityHeader.parse(final String value) { 39 | final splitValues = value.splitTrimAndFilterUnique(separator: ';'); 40 | if (splitValues.isEmpty) { 41 | throw const FormatException('Value cannot be empty'); 42 | } 43 | 44 | int? maxAge; 45 | bool includeSubDomains = false; 46 | bool preload = false; 47 | 48 | for (final directive in splitValues) { 49 | if (directive.startsWith(_maxAgePrefix)) { 50 | maxAge = int.tryParse(directive.substring(_maxAgePrefix.length)); 51 | } else if (directive == _includeSubDomains) { 52 | includeSubDomains = true; 53 | } else if (directive == _preload) { 54 | preload = true; 55 | } 56 | } 57 | 58 | if (maxAge == null) { 59 | throw const FormatException('Max-age directive is missing or invalid'); 60 | } 61 | 62 | return StrictTransportSecurityHeader( 63 | maxAge: maxAge, 64 | includeSubDomains: includeSubDomains, 65 | preload: preload, 66 | ); 67 | } 68 | 69 | /// Converts the [StrictTransportSecurityHeader] into a string representation 70 | /// for HTTP headers. 71 | 72 | String _encode() { 73 | final directives = ['$_maxAgePrefix$maxAge']; 74 | if (includeSubDomains) directives.add(_includeSubDomains); 75 | if (preload) directives.add(_preload); 76 | return directives.join('; '); 77 | } 78 | 79 | @override 80 | String toString() { 81 | return 'StrictTransportSecurityHeader(maxAge: $maxAge, includeSubDomains: $includeSubDomains, preload: $preload)'; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/te_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP TE header. 5 | /// 6 | /// The TE header indicates the transfer encodings the client is willing to accept, 7 | /// optionally with quality values. 8 | final class TEHeader { 9 | static const codec = HeaderCodec(TEHeader.parse, __encode); 10 | static List __encode(final TEHeader value) => [value._encode()]; 11 | 12 | /// The list of encodings with their quality values. 13 | final List encodings; 14 | 15 | /// Constructs a [TEHeader] instance with the specified list of encodings. 16 | TEHeader({required this.encodings}); 17 | 18 | /// Parses the TE header value and returns a [TEHeader] instance. 19 | /// 20 | /// This method processes the TE header and extracts the list of encodings 21 | /// with their quality values. 22 | factory TEHeader.parse(final Iterable values) { 23 | final splitValues = values.splitTrimAndFilterUnique(); 24 | 25 | if (splitValues.isEmpty) { 26 | throw const FormatException('Value cannot be empty'); 27 | } 28 | 29 | final encodings = splitValues.map((final value) { 30 | final encodingParts = value.split(';q='); 31 | final encoding = encodingParts[0].trim().toLowerCase(); 32 | if (encoding.isEmpty) { 33 | throw const FormatException('Invalid encoding'); 34 | } 35 | double? quality; 36 | if (encodingParts.length > 1) { 37 | final value = double.tryParse(encodingParts[1].trim()); 38 | if (value == null || value < 0 || value > 1) { 39 | throw const FormatException('Invalid quality value'); 40 | } 41 | quality = value; 42 | } 43 | return TeQuality(encoding, quality); 44 | }).toList(); 45 | 46 | return TEHeader(encodings: encodings); 47 | } 48 | 49 | /// Converts the [TEHeader] instance into a string representation 50 | /// suitable for HTTP headers. 51 | 52 | String _encode() => encodings.map((final e) => e._encode()).join(', '); 53 | 54 | @override 55 | String toString() => 'TEHeader(encodings: $encodings)'; 56 | } 57 | 58 | /// A class representing a transfer encoding with an optional quality value. 59 | class TeQuality { 60 | /// The encoding value. 61 | final String encoding; 62 | 63 | /// The quality value (default is 1.0). 64 | final double? quality; 65 | 66 | /// Constructs an instance of [TeQuality]. 67 | TeQuality(this.encoding, [final double? quality]) : quality = quality ?? 1.0; 68 | 69 | /// Converts the [TeQuality] instance into a string representation suitable for HTTP headers. 70 | String _encode() => quality == 1.0 ? encoding : '$encoding;q=$quality'; 71 | 72 | @override 73 | String toString() => 'TeQuality(encoding: $encoding, quality: $quality)'; 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/upgrade_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Upgrade header. 5 | /// 6 | /// This class manages the protocols that the client supports for upgrading the 7 | /// connection. 8 | final class UpgradeHeader { 9 | static const codec = HeaderCodec(UpgradeHeader.parse, __encode); 10 | static List __encode(final UpgradeHeader value) => [value._encode()]; 11 | 12 | /// The list of protocols that the client supports. 13 | final List protocols; 14 | 15 | /// Constructs an [UpgradeHeader] instance with the specified protocols. 16 | UpgradeHeader({required this.protocols}); 17 | 18 | /// Parses the Upgrade header value and returns an [UpgradeHeader] instance. 19 | /// 20 | /// This method processes the header value, extracting the list of protocols. 21 | factory UpgradeHeader.parse(final Iterable values) { 22 | final splitValues = values.splitTrimAndFilterUnique(separator: ','); 23 | if (splitValues.isEmpty) { 24 | throw const FormatException('Value cannot be empty'); 25 | } 26 | 27 | final protocols = splitValues 28 | .map((final protocol) => UpgradeProtocol.parse(protocol)) 29 | .toList(); 30 | 31 | return UpgradeHeader(protocols: protocols); 32 | } 33 | 34 | /// Converts the [UpgradeHeader] instance into a string representation 35 | /// suitable for HTTP headers. 36 | 37 | String _encode() { 38 | return protocols.map((final protocol) => protocol._encode()).join(', '); 39 | } 40 | 41 | @override 42 | String toString() { 43 | return 'UpgradeHeader(protocols: $protocols)'; 44 | } 45 | } 46 | 47 | /// A class representing a single protocol in the Upgrade header. 48 | class UpgradeProtocol { 49 | /// The name of the protocol. 50 | final String protocol; 51 | 52 | /// The version of the protocol. 53 | final double? version; 54 | 55 | /// Constructs an [UpgradeProtocol] instance with the specified name and version. 56 | UpgradeProtocol({ 57 | required this.protocol, 58 | this.version, 59 | }); 60 | 61 | /// Parses a protocol string and returns an [UpgradeProtocol] instance. 62 | factory UpgradeProtocol.parse(final String value) { 63 | final trimmed = value.trim(); 64 | if (trimmed.isEmpty) { 65 | throw const FormatException('Protocol cannot be empty'); 66 | } 67 | 68 | final split = trimmed.split('/'); 69 | if (split.length == 1) { 70 | return UpgradeProtocol(protocol: split[0]); 71 | } 72 | 73 | final protocol = split[0]; 74 | if (protocol.isEmpty) { 75 | throw const FormatException('Protocol cannot be empty'); 76 | } 77 | 78 | final version = split[1]; 79 | if (version.isEmpty) { 80 | throw const FormatException('Version cannot be empty'); 81 | } 82 | 83 | final parsedVersion = double.tryParse(version); 84 | if (parsedVersion == null) { 85 | throw const FormatException('Invalid version'); 86 | } 87 | 88 | return UpgradeProtocol( 89 | protocol: protocol, 90 | version: parsedVersion, 91 | ); 92 | } 93 | 94 | /// Converts the [UpgradeProtocol] instance into a string representation. 95 | String _encode() => '$protocol${version != null ? '/$version' : ''}'; 96 | 97 | @override 98 | String toString() { 99 | return 'UpgradeProtocol(protocol: $protocol, version: $version)'; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/util/cookie_util.dart: -------------------------------------------------------------------------------- 1 | /// Validates the cookie name and returns a string representation of the cookie name. 2 | String validateCookieName(final String name) { 3 | // Allow empty names 4 | if (name.isEmpty) return name; 5 | 6 | // Disallowed characters (separators) 7 | const separators = { 8 | '(', 9 | ')', 10 | '<', 11 | '>', 12 | '@', 13 | ',', 14 | ';', 15 | ':', 16 | r'\', 17 | '"', 18 | '/', 19 | '[', 20 | ']', 21 | '?', 22 | '=', 23 | '{', 24 | '}' 25 | }; 26 | 27 | for (int i = 0; i < name.length; i++) { 28 | final int codeUnit = name.codeUnitAt(i); 29 | 30 | // Disallow control characters, non-ASCII characters, and reserved separators 31 | if ( 32 | // Disallows ASCII control characters (code points 0-32), including spaces, tabs, and other non-printable characters 33 | codeUnit <= 32 || 34 | // Disallows non-ASCII characters (code points 127 and above), ensuring only standard ASCII is used 35 | codeUnit >= 127 || 36 | // Disallows reserved separator characters [separators], based on RFC 6265 37 | separators.contains(name[i])) { 38 | throw const FormatException('Invalid cookie name'); 39 | } 40 | } 41 | 42 | return name; 43 | } 44 | 45 | /// Validates the cookie value and returns a string representation of the cookie value. 46 | String validateCookieValue(final String value) { 47 | // Allow empty values 48 | if (value.isEmpty) return value; 49 | 50 | // Check for quoted strings 51 | int start = 0; 52 | int end = value.length; 53 | 54 | if (value.length >= 2 && 55 | // Starting with '"' 56 | value.codeUnitAt(start) == 0x22 && 57 | // Ending with '"' 58 | value.codeUnitAt(end - 1) == 0x22) { 59 | start++; 60 | end--; 61 | } 62 | 63 | // Validate characters inside the string 64 | for (int i = start; i < end; i++) { 65 | final int codeUnit = value.codeUnitAt(i); 66 | 67 | if (!( 68 | // '!' (ASCII 33) is allowed 69 | codeUnit == 0x21 || 70 | // '#' (35) to '+' (43) are allowed, including symbols like '#', '$', '%', '&', and '+' 71 | (codeUnit >= 0x23 && codeUnit <= 0x2B) || 72 | // '-' (45) to ':' (58) are allowed, covering '-', '.', '/', '0-9', and ':' 73 | (codeUnit >= 0x2D && codeUnit <= 0x3A) || 74 | // '<' (60) to '[' (91) are allowed, covering '<', '=', '>', '?', '@', 'A-Z', and '[' 75 | (codeUnit >= 0x3C && codeUnit <= 0x5B) || 76 | // ']' (93) to '~' (126) are allowed, covering ']', '^', '_', '`', 'a-z', '{', '|', '}', and '~' 77 | (codeUnit >= 0x5D && codeUnit <= 0x7E))) { 78 | throw const FormatException('Invalid cookie value'); 79 | } 80 | } 81 | 82 | return Uri.decodeComponent(value); 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/headers/typed/headers/vary_header.dart: -------------------------------------------------------------------------------- 1 | import '../../../../relic.dart'; 2 | import '../../extension/string_list_extensions.dart'; 3 | 4 | /// A class representing the HTTP Vary header. 5 | /// 6 | /// This class manages the list of headers that the response may vary on, 7 | /// and can also handle the wildcard value "*", which indicates that the 8 | /// response varies on all request headers. 9 | final class VaryHeader { 10 | static const codec = HeaderCodec(VaryHeader.parse, ___encode); 11 | static List ___encode(final VaryHeader value) => [value._encode()]; 12 | 13 | /// A list of headers that the response varies on. 14 | /// If the list contains only "*", it means all headers are varied on. 15 | final Iterable? fields; 16 | 17 | /// Whether all headers are allowed to vary (`*`). 18 | final bool isWildcard; 19 | 20 | /// Constructs an instance allowing specific headers to vary. 21 | VaryHeader.headers({required this.fields}) : isWildcard = false; 22 | 23 | /// Constructs an instance allowing all headers to vary (`*`). 24 | VaryHeader.wildcard() 25 | : fields = null, 26 | isWildcard = true; 27 | 28 | /// Parses the Vary header value and returns a [VaryHeader] instance. 29 | /// 30 | /// This method handles the wildcard value "*" or splits the value by commas and trims each field. 31 | factory VaryHeader.parse(final Iterable values) { 32 | final splitValues = values.splitTrimAndFilterUnique(); 33 | 34 | if (splitValues.isEmpty) { 35 | throw const FormatException('Value cannot be empty'); 36 | } 37 | 38 | if (splitValues.length == 1 && splitValues.first == '*') { 39 | return VaryHeader.wildcard(); 40 | } 41 | 42 | if (splitValues.length > 1 && splitValues.contains('*')) { 43 | throw const FormatException( 44 | 'Wildcard (*) cannot be used with other values'); 45 | } 46 | 47 | return VaryHeader.headers(fields: splitValues); 48 | } 49 | 50 | /// Converts the [VaryHeader] instance into a string representation 51 | /// suitable for HTTP headers. 52 | String _encode() => isWildcard ? '*' : fields!.join(', '); 53 | 54 | @override 55 | String toString() { 56 | return 'VaryHeader(fields: $fields, isWildcard: $isWildcard)'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/headers/typed/typed_headers.dart: -------------------------------------------------------------------------------- 1 | export 'headers/accept_encoding_header.dart'; 2 | export 'headers/accept_header.dart'; 3 | export 'headers/accept_language_header.dart'; 4 | export 'headers/accept_ranges_header.dart'; 5 | export 'headers/access_control_allow_headers_header.dart'; 6 | export 'headers/access_control_allow_methods_header.dart'; 7 | export 'headers/access_control_allow_origin_header.dart'; 8 | export 'headers/access_control_expose_headers_header.dart'; 9 | export 'headers/authentication_header.dart'; 10 | export 'headers/authorization_header.dart'; 11 | export 'headers/cache_control_header.dart'; 12 | export 'headers/clear_site_data_header.dart'; 13 | export 'headers/connection_header.dart'; 14 | export 'headers/content_disposition_header.dart'; 15 | export 'headers/content_encoding_header.dart'; 16 | export 'headers/content_language_header.dart'; 17 | export 'headers/content_range_header.dart'; 18 | export 'headers/content_security_policy_header.dart'; 19 | export 'headers/cookie_header.dart'; 20 | export 'headers/cross_origin_embedder_policy_header.dart'; 21 | export 'headers/cross_origin_opener_policy_header.dart'; 22 | export 'headers/cross_origin_resource_policy_header.dart'; 23 | export 'headers/etag_condition_header.dart'; 24 | export 'headers/etag_header.dart' hide InternalEx; 25 | export 'headers/expect_header.dart'; 26 | export 'headers/from_header.dart'; 27 | export 'headers/if_range_header.dart'; 28 | export 'headers/permission_policy_header.dart'; 29 | export 'headers/range_header.dart'; 30 | export 'headers/referrer_policy_header.dart'; 31 | export 'headers/retry_after_header.dart'; 32 | export 'headers/sec_fetch_dest_header.dart'; 33 | export 'headers/sec_fetch_mode_header.dart'; 34 | export 'headers/sec_fetch_site_header.dart'; 35 | export 'headers/set_cookie_header.dart'; 36 | export 'headers/strict_transport_security_header.dart'; 37 | export 'headers/te_header.dart'; 38 | export 'headers/transfer_encoding_header.dart'; 39 | export 'headers/upgrade_header.dart'; 40 | export 'headers/vary_header.dart'; 41 | -------------------------------------------------------------------------------- /lib/src/io/static/directory_listing.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:path/path.dart' as path; 7 | 8 | import '../../body/body.dart'; 9 | import '../../message/response.dart'; 10 | 11 | String _getHeader(final String sanitizedHeading) => ''' 12 | 13 | 14 | Directory listing for $sanitizedHeading 15 | 41 | 42 | 43 |

$sanitizedHeading

44 |
    45 | '''; 46 | 47 | const String _trailer = '''
48 | 49 | 50 | '''; 51 | 52 | Response listDirectory(final String fileSystemPath, final String dirPath) { 53 | final controller = StreamController(); 54 | const encoding = Utf8Codec(); 55 | const sanitizer = HtmlEscape(); 56 | 57 | void add(final String string) { 58 | controller.add(encoding.encode(string)); 59 | } 60 | 61 | var heading = path.relative(dirPath, from: fileSystemPath); 62 | if (heading == '.') { 63 | heading = '/'; 64 | } else { 65 | heading = '/$heading/'; 66 | } 67 | 68 | add(_getHeader(sanitizer.convert(heading))); 69 | 70 | // Return a sorted listing of the directory contents asynchronously. 71 | Directory(dirPath).list().toList().then((final entities) { 72 | entities.sort((final e1, final e2) { 73 | if (e1 is Directory && e2 is! Directory) { 74 | return -1; 75 | } 76 | if (e1 is! Directory && e2 is Directory) { 77 | return 1; 78 | } 79 | return e1.path.compareTo(e2.path); 80 | }); 81 | 82 | for (final entity in entities) { 83 | var name = path.relative(entity.path, from: dirPath); 84 | if (entity is Directory) name += '/'; 85 | final sanitizedName = sanitizer.convert(name); 86 | add('
  • $sanitizedName
  • \n'); 87 | } 88 | 89 | add(_trailer); 90 | controller.close(); 91 | }); 92 | 93 | return Response.ok( 94 | body: Body.fromDataStream( 95 | controller.stream, 96 | ), 97 | encoding: encoding, 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/io/static/extension/datetime_extension.dart: -------------------------------------------------------------------------------- 1 | /// Extensions on [DateTime]. 2 | extension DatetimeExtension on DateTime { 3 | /// Returns a new [DateTime] with the milliseconds set to 0. 4 | DateTime get toSecondResolution { 5 | if (millisecond == 0) return this; 6 | return subtract(Duration(milliseconds: millisecond)); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/logger/logger.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:stack_trace/stack_trace.dart'; 3 | 4 | typedef Logger = void Function( 5 | String message, { 6 | StackTrace? stackTrace, 7 | LoggerType type, 8 | }); 9 | 10 | enum LoggerType { 11 | error, 12 | warn, 13 | info, 14 | } 15 | 16 | /// Logs a message to the standard output or error stream. 17 | /// 18 | /// If [stackTrace] is passed, it will be used to create a chain of frames 19 | /// that excludes core Dart frames and frames from the 'relic' package. 20 | /// 21 | /// If [type] is not passed, it defaults to [LoggerType.info]. 22 | void logMessage( 23 | final String message, { 24 | final StackTrace? stackTrace, 25 | final LoggerType type = LoggerType.info, 26 | }) { 27 | var chain = Chain.current(); 28 | 29 | if (stackTrace != null) { 30 | chain = Chain.forTrace(stackTrace) 31 | .foldFrames((final frame) => frame.isCore || frame.package == 'relic') 32 | .terse; 33 | } 34 | 35 | switch (type) { 36 | case LoggerType.error: 37 | stderr.writeln('ERROR - ${DateTime.now()}'); 38 | stderr.writeln(message); 39 | stderr.writeln(chain); 40 | break; 41 | case LoggerType.warn: 42 | stdout.writeln('WARN - ${DateTime.now()}'); 43 | stdout.writeln(message); 44 | stdout.writeln(chain); 45 | break; 46 | case LoggerType.info: 47 | stdout.writeln('INFO - ${DateTime.now()}'); 48 | stdout.writeln(message); 49 | break; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/message/message.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:typed_data'; 4 | 5 | import '../../relic.dart'; 6 | 7 | abstract class Message { 8 | /// The HTTP headers associated with this message. 9 | final Headers headers; 10 | 11 | /// Extra context for middleware and handlers. 12 | final Map context; 13 | 14 | /// The streaming body of the message. 15 | Body body; 16 | 17 | Message({ 18 | required this.body, 19 | required this.headers, 20 | this.context = const {}, 21 | }); 22 | 23 | /// Returns the MIME type from the Body-Type (Content-Type header), if available. 24 | MimeType? get mimeType => body.bodyType?.mimeType; 25 | 26 | /// Returns the encoding specified in the Body-Type (Content-Type header), or null if not specified. 27 | Encoding? get encoding => body.bodyType?.encoding; 28 | 29 | /// Reads the body as a stream of bytes. Can only be called once. 30 | Stream read() => body.read(); 31 | 32 | /// Reads the body as a string, decoding it using the specified or detected encoding. 33 | /// Defaults to utf8 if no encoding is provided or detected. 34 | Future readAsString([Encoding? encoding]) { 35 | encoding ??= body.bodyType?.encoding ?? utf8; 36 | return encoding.decodeStream(read()); 37 | } 38 | 39 | /// Determines if the body is empty by checking the content length. 40 | bool get isEmpty => body.contentLength == 0; 41 | 42 | /// Creates a new message by copying existing values and applying specified changes. 43 | Message copyWith({ 44 | final Headers headers, 45 | final Map context, 46 | final Body? body, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/method/request_method.dart: -------------------------------------------------------------------------------- 1 | import '../../relic.dart'; 2 | 3 | /// Represents the HTTP methods used in requests as constants. 4 | class RequestMethod { 5 | /// Predefined HTTP method constants. 6 | static const _get = 'GET'; 7 | static const _post = 'POST'; 8 | static const _put = 'PUT'; 9 | static const _delete = 'DELETE'; 10 | static const _patch = 'PATCH'; 11 | static const _head = 'HEAD'; 12 | static const _options = 'OPTIONS'; 13 | static const _trace = 'TRACE'; 14 | static const _connect = 'CONNECT'; 15 | 16 | /// The string representation of the HTTP method. 17 | final String value; 18 | 19 | /// Creates a new [RequestMethod] instance with the given HTTP method [value]. 20 | const RequestMethod._(this.value); 21 | 22 | /// Predefined HTTP method constants. 23 | static const get = RequestMethod._(_get); 24 | static const post = RequestMethod._(_post); 25 | static const put = RequestMethod._(_put); 26 | static const delete = RequestMethod._(_delete); 27 | static const patch = RequestMethod._(_patch); 28 | static const head = RequestMethod._(_head); 29 | static const options = RequestMethod._(_options); 30 | static const trace = RequestMethod._(_trace); 31 | static const connect = RequestMethod._(_connect); 32 | 33 | /// Parses a [method] string and returns the corresponding [RequestMethod] instance. 34 | /// 35 | /// Throws an [ArgumentError] if the [method] string is empty. 36 | /// If the method is not found in the predefined values, 37 | /// it returns a new [RequestMethod] instance with the method name in uppercase. 38 | factory RequestMethod.parse(final String method) { 39 | if (method.isEmpty) { 40 | throw const FormatException('Value cannot be empty'); 41 | } 42 | 43 | switch (method.toUpperCase()) { 44 | case _get: 45 | return get; 46 | case _post: 47 | return post; 48 | case _put: 49 | return put; 50 | case _delete: 51 | return delete; 52 | case _patch: 53 | return patch; 54 | case _head: 55 | return head; 56 | case _options: 57 | return options; 58 | case _trace: 59 | return trace; 60 | case _connect: 61 | return connect; 62 | default: 63 | throw const FormatException('Invalid value'); 64 | } 65 | } 66 | static const codec = HeaderCodec.single(RequestMethod.parse, __encode); 67 | static List __encode(final RequestMethod value) => [value.toString()]; 68 | 69 | @override 70 | String toString() => 'Method($value)'; 71 | } 72 | -------------------------------------------------------------------------------- /lib/src/middleware/middleware.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import '../adapter/context.dart'; 4 | import '../handler/handler.dart'; 5 | import '../message/request.dart'; 6 | import '../message/response.dart'; 7 | 8 | /// A function which creates a new [Handler] by wrapping a [Handler]. 9 | /// 10 | /// You can extend the functions of a [Handler] by wrapping it in 11 | /// [Middleware] that can intercept and process a request before it it sent 12 | /// to a handler, a response after it is sent by a handler, or both. 13 | /// 14 | /// Because [Middleware] consumes a [Handler] and returns a new 15 | /// [Handler], multiple [Middleware] instances can be composed 16 | /// together to offer rich functionality. 17 | /// 18 | /// Common uses for middleware include caching, logging, and authentication. 19 | /// 20 | /// Middleware that captures exceptions should be sure to pass 21 | /// [HijackException]s on without modification. 22 | /// 23 | /// A simple [Middleware] can be created using [createMiddleware]. 24 | typedef Middleware = Handler Function(Handler innerHandler); 25 | 26 | /// Creates a [Middleware] using the provided functions. 27 | /// 28 | /// If provided, [onRequest] receives a [Request]. It can respond to 29 | /// the request by returning a [Response] or [Future]. 30 | /// [onRequest] can also return `null` for some or all requests in which 31 | /// case the request is sent to the inner [Handler]. 32 | /// 33 | /// If provided, [onResponse] is called with the [Response] generated 34 | /// by the inner [Handler]. Responses generated by [onRequest] are not 35 | /// sent to [onResponse]. 36 | /// 37 | /// [onResponse] should return either a [Response] or 38 | /// [Future]. It may return the response parameter it receives or 39 | /// create a new response object. 40 | /// 41 | /// If provided, [onError] receives errors thrown by the inner handler. It 42 | /// does not receive errors thrown by [onRequest] or [onResponse], nor 43 | /// does it receive [HijackException]s. It can either return a new response or 44 | /// throw an error. 45 | Middleware createMiddleware({ 46 | FutureOr Function(Request)? onRequest, 47 | FutureOr Function(Response)? onResponse, 48 | final FutureOr Function(Object error, StackTrace)? onError, 49 | }) { 50 | onRequest ??= (final request) => null; 51 | onResponse ??= (final response) => response; 52 | 53 | return (final innerHandler) { 54 | return (final ctx) async { 55 | var response = await onRequest!(ctx.request); 56 | if (response != null) return ctx.withResponse(response); 57 | late ResponseContext responseCtx; 58 | try { 59 | final newCtx = await innerHandler(ctx); 60 | if (newCtx is! ResponseContext) return newCtx; 61 | responseCtx = newCtx; 62 | } catch (e, s) { 63 | if (onError != null) { 64 | return ctx.withResponse(await onError(e, s)); 65 | } 66 | rethrow; 67 | } 68 | response = await onResponse!(responseCtx.response); 69 | return responseCtx.withResponse(response); 70 | }; 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/middleware/middleware_extensions.dart: -------------------------------------------------------------------------------- 1 | import '../handler/handler.dart'; 2 | import 'middleware.dart'; 3 | 4 | /// Extensions on [Middleware] to aid in composing [Middleware] and [Handler]s. 5 | /// 6 | /// These members can be used in place of [Pipeline]. 7 | extension MiddlewareExtensions on Middleware { 8 | /// Merges `this` and [other] into a new [Middleware]. 9 | Middleware addMiddleware(final Middleware other) => 10 | (final Handler handler) => this(other(handler)); 11 | 12 | /// Merges `this` and [handler] into a new [Handler]. 13 | Handler addHandler(final Handler handler) => this(handler); 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/middleware/middleware_logger.dart: -------------------------------------------------------------------------------- 1 | import '../../relic.dart'; 2 | import '../logger/logger.dart'; 3 | 4 | /// Middleware which prints the time of the request, the elapsed time for the 5 | /// inner handlers, the response's status code and the request URI. 6 | /// 7 | /// If [logger] is passed, it's called for each request. The `msg` parameter is 8 | /// a formatted string that includes the request time, duration, request method, 9 | /// and requested path. When an exception is thrown, it also includes the 10 | /// exception's string and stack trace; otherwise, it includes the status code. 11 | /// The `isError` parameter indicates whether the message is caused by an error. 12 | /// 13 | /// If [logger] is not passed, the message is just passed to [print]. 14 | Middleware logRequests({ 15 | final Logger? logger, 16 | }) => 17 | (final innerHandler) { 18 | final localLogger = logger ?? logMessage; 19 | 20 | return (final ctx) async { 21 | final startTime = DateTime.now(); 22 | final watch = Stopwatch()..start(); 23 | 24 | try { 25 | final handledCtx = await innerHandler(ctx); 26 | final msg = switch (handledCtx) { 27 | final ResponseContext rc => '${rc.response.statusCode}', 28 | final HijackContext _ => 'hijacked', 29 | final ConnectContext _ => 'connected', 30 | }; 31 | localLogger( 32 | _message(startTime, handledCtx.request, watch.elapsed, msg)); 33 | return handledCtx; 34 | } catch (error, stackTrace) { 35 | localLogger( 36 | _errorMessage(startTime, ctx.request, watch.elapsed, error), 37 | type: LoggerType.error, 38 | stackTrace: stackTrace, 39 | ); 40 | 41 | rethrow; 42 | } 43 | }; 44 | }; 45 | 46 | String _formatQuery(final String query) { 47 | return query == '' ? '' : '?$query'; 48 | } 49 | 50 | String _message( 51 | final DateTime requestTime, 52 | final Request request, 53 | final Duration elapsedTime, 54 | final String message, 55 | ) { 56 | final method = request.method.value; 57 | final requestedUri = request.url; 58 | 59 | return '${requestTime.toIso8601String()} ' 60 | '${elapsedTime.toString().padLeft(15)} ' 61 | '${method.padRight(7)} [$message] ' // 7 - longest standard HTTP method 62 | '${requestedUri.path}${_formatQuery(requestedUri.query)}'; 63 | } 64 | 65 | String _errorMessage( 66 | final DateTime requestTime, 67 | final Request request, 68 | final Duration elapsedTime, 69 | final Object error, 70 | ) { 71 | return _message(requestTime, request, elapsedTime, 'ERROR: $error'); 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/middleware/routing_middleware.dart: -------------------------------------------------------------------------------- 1 | import '../adapter/context.dart'; 2 | import '../handler/handler.dart'; 3 | import '../method/request_method.dart'; 4 | import '../router/router.dart'; 5 | import 'middleware.dart'; 6 | 7 | Middleware routeWith( 8 | final Router router, { 9 | final Handler Function(T)? toHandler, 10 | }) => 11 | _RoutingMiddlewareBuilder(router, toHandler: toHandler).build(); 12 | 13 | final _pathParametersStorage = Expando>(); 14 | 15 | class _RoutingMiddlewareBuilder { 16 | final Router _router; 17 | late final Handler Function(T) _toHandler; 18 | 19 | _RoutingMiddlewareBuilder( 20 | this._router, { 21 | final Handler Function(T)? toHandler, 22 | }) { 23 | if (toHandler != null) { 24 | _toHandler = toHandler; 25 | } else if (_isSubtype()) { 26 | _toHandler = (final x) => x as Handler; 27 | } 28 | ArgumentError.checkNotNull(_toHandler, 'toHandler'); 29 | } 30 | 31 | Handler _meddle(final Handler next) { 32 | return (final ctx) async { 33 | final req = ctx.request; 34 | final url = ctx.request.url; // TODO: Use requestUri 35 | final match = _router.lookup(req.method.convert(), url.path); 36 | if (match != null) { 37 | ctx._pathParameters = match.parameters; 38 | final handler = _toHandler(match.value); 39 | return await handler(ctx); 40 | } else { 41 | return await next(ctx); 42 | } 43 | }; 44 | } 45 | 46 | Middleware build() => _meddle; 47 | } 48 | 49 | extension RequestContextEx on RequestContext { 50 | Map get pathParameters => 51 | _pathParametersStorage[token] ?? 52 | (throw StateError('Add RoutingMiddleware!')); 53 | 54 | set _pathParameters(final Map value) => 55 | _pathParametersStorage[token] = value; 56 | } 57 | 58 | bool _isSubtype() => [] is List; 59 | 60 | extension on RequestMethod { 61 | Method convert() { 62 | return switch (this) { 63 | RequestMethod.get => Method.get, 64 | RequestMethod.post => Method.post, 65 | RequestMethod.put => Method.put, 66 | RequestMethod.delete => Method.delete, 67 | RequestMethod.head => Method.head, 68 | RequestMethod.options => Method.options, 69 | RequestMethod.patch => Method.patch, 70 | RequestMethod.trace => Method.trace, 71 | RequestMethod.connect => Method.connect, 72 | _ => throw UnimplementedError(), 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/router/lookup_result.dart: -------------------------------------------------------------------------------- 1 | import 'normalized_path.dart'; 2 | import 'path_trie.dart'; 3 | 4 | /// Represents the result of a route lookup. 5 | final class LookupResult { 6 | /// The value associated with the matched route. 7 | final T value; 8 | 9 | /// A map of parameter names to their extracted values from the path. 10 | final Parameters parameters; 11 | 12 | /// The normalized path that was matched. 13 | final NormalizedPath matched; 14 | 15 | /// If a match, does not consume the full path, then stores the [remaining] 16 | /// 17 | /// This can only happen with a path that ends with a tail segment `/**`, 18 | /// otherwise it will be empty. 19 | final NormalizedPath remaining; 20 | 21 | /// Creates a [LookupResult] with the given [value] and [parameters]. 22 | const LookupResult(this.value, this.parameters, this.matched, this.remaining); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/router/lru_cache.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | /// A simple Least Recently Used (LRU) cache implementation. 4 | /// 5 | /// Keeps a fixed number of items ([_maxSize]). When the cache is full and a new 6 | /// item is added, the least recently used item is evicted. Accessing an item 7 | /// (get or update) marks it as the most recently used. 8 | final class LruCache { 9 | final int _maxSize; 10 | 11 | // ignore: prefer_collection_literals 12 | final _cache = LinkedHashMap(); 13 | 14 | /// Creates an LRU cache with the specified maximum size. 15 | /// 16 | /// Throws an [ArgumentError] if [_maxSize] is not positive. 17 | LruCache(this._maxSize) { 18 | if (_maxSize <= 0) { 19 | throw ArgumentError('Cache size must be positive'); 20 | } 21 | } 22 | 23 | /// Retrieves the value associated with [key]. 24 | /// 25 | /// Returns null if the key is not found. Accessing the key marks it as the most 26 | /// recently used item. 27 | V? operator [](final K key) { 28 | final value = _cache.remove(key); 29 | if (value != null) { 30 | // Re-insert to move to the end (most recently used position) 31 | _cache[key] = value; 32 | } 33 | return value; 34 | } 35 | 36 | /// Associates [value] with [key] in the cache. 37 | /// 38 | /// If the key already exists, its value is updated. Adding or updating a key 39 | /// marks it as the most recently used item. If adding the item exceeds the cache 40 | /// capacity, the least recently used item is evicted. 41 | void operator []=(final K key, final V value) { 42 | // Remove existing entry if present 43 | _cache.remove(key); 44 | 45 | // Add new entry (will be at the end - most recently used) 46 | _cache[key] = value; 47 | _trim(); 48 | } 49 | 50 | void _trim() { 51 | // Evict oldest entries if we've exceeded max size 52 | var keysToRemove = _cache.length - _maxSize; 53 | while (keysToRemove-- > 0) { 54 | _cache.remove(_cache.keys.first); 55 | } 56 | } 57 | 58 | /// Returns the current number of items in the cache. 59 | int get length => _cache.length; 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/util/util.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | /// Run [callback] and capture any errors that would otherwise be top-leveled. 4 | /// 5 | /// If `this` is called in a non-root error zone, it will just run [callback] 6 | /// and return the result. Otherwise, it will capture any errors using 7 | /// [runZoned] and pass them to [onError]. 8 | void catchTopLevelErrors(final void Function() callback, 9 | final void Function(dynamic error, StackTrace) onError) { 10 | if (Zone.current.inSameErrorZone(Zone.root)) { 11 | return runZonedGuarded(callback, onError); 12 | } else { 13 | return callback(); 14 | } 15 | } 16 | 17 | /// Returns a [Map] with the values from [original] and the values from 18 | /// [updates]. 19 | /// 20 | /// For keys that are the same between [original] and [updates], the value in 21 | /// [updates] is used. 22 | /// 23 | /// If [updates] is `null` or empty, [original] is returned unchanged. 24 | Map updateMap(final Map original, final Map? updates) { 25 | if (updates == null || updates.isEmpty) return original; 26 | 27 | final value = Map.of(original); 28 | for (final entry in updates.entries) { 29 | final val = entry.value; 30 | if (val == null) { 31 | value.remove(entry.key); 32 | } else { 33 | value[entry.key] = val; 34 | } 35 | } 36 | 37 | return value; 38 | } 39 | 40 | /// Multiple header values are joined with commas. 41 | /// See https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-p1-messaging-21#page-22 42 | String? joinHeaderValues(final List? values) { 43 | if (values == null) return null; 44 | if (values.isEmpty) return ''; 45 | if (values.length == 1) return values.single; 46 | return values.join(','); 47 | } 48 | -------------------------------------------------------------------------------- /misc/images/github-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverpod/relic/db2b6ff4cbde2cde6b5275c90c902058bc4b11f2/misc/images/github-banner.jpg -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: relic 2 | description: A lightweight and flexible web server inspired by Shelf for building APIs and backend services. 3 | version: 0.3.0 4 | repository: https://github.com/serverpod/relic 5 | 6 | environment: 7 | sdk: ^3.5.0 8 | 9 | dependencies: 10 | async: ^2.11.0 11 | collection: ^1.18.0 12 | convert: ^3.1.1 13 | http_parser: ^4.0.2 14 | meta: ^1.16.0 15 | mime: ">=1.0.6 <3.0.0" 16 | path: ^1.8.3 17 | stack_trace: ^1.10.0 18 | stream_channel: ^2.1.1 19 | web_socket: ^1.0.0 20 | web_socket_channel: ^3.0.3 21 | 22 | dev_dependencies: 23 | benchmark_harness: ^2.3.1 24 | cli_tools: ^0.5.1 25 | git: ^2.2.1 26 | http: ^1.1.0 27 | lints: ">=3.0.0 <7.0.0" 28 | mockito: ^5.4.4 29 | routingkit: ^5.1.2 30 | serverpod_lints: ^2.5.0 31 | spanner: ^1.0.5 32 | test: ^1.25.5 33 | test_descriptor: ^2.0.1 34 | # The following are not used directly, but are included transitively. 35 | # Due to bad semver hygiene we need to bound the versions higher than 36 | # our direct dependencies indicate. 37 | file: ^7.0.0 # ignore: sort_pub_dependencies 38 | frontend_server_client: ^4.0.0 39 | pub_semver: ^2.1.4 40 | watcher: ^1.1.0 41 | -------------------------------------------------------------------------------- /test/exception/relic_exceptions_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:http/http.dart' as http; 4 | import 'package:relic/relic.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../headers/headers_test_utils.dart'; 8 | import '../util/test_util.dart'; 9 | 10 | void main() { 11 | tearDown(() async { 12 | final server = _server; 13 | if (server != null) { 14 | try { 15 | await server.close().timeout(const Duration(seconds: 5)); 16 | } catch (e) { 17 | await server.close(); 18 | } finally { 19 | _server = null; 20 | } 21 | } 22 | }); 23 | 24 | group('Given a server', () { 25 | test( 26 | 'when a handler throws an InvalidHeaderException ' 27 | 'then it returns a 400 Bad Request response with exception message ' 28 | 'included in the response body', () async { 29 | await _scheduleServer( 30 | (final _) => throw const InvalidHeaderException( 31 | 'Value cannot be empty', 32 | headerType: 'test', 33 | ), 34 | ); 35 | final response = await _get(); 36 | expect(response.statusCode, 400); 37 | expect(response.body, "Invalid 'test' header: Value cannot be empty"); 38 | }); 39 | 40 | test( 41 | 'when a handler throws an UnimplementedError ' 42 | 'then it returns a 500 Internal Server Error response', () async { 43 | await _scheduleServer( 44 | (final _) => throw UnimplementedError(), 45 | ); 46 | final response = await _get(); 47 | expect(response.statusCode, 500); 48 | expect(response.body, 'Internal Server Error'); 49 | }); 50 | 51 | test( 52 | 'when a handler throws an Exception ' 53 | 'then it returns a 500 Internal Server Error response', () async { 54 | await _scheduleServer((final _) => throw Exception()); 55 | final response = await _get(); 56 | expect(response.statusCode, 500); 57 | expect(response.body, 'Internal Server Error'); 58 | }); 59 | 60 | test( 61 | 'when a handler throws an Error ' 62 | 'then it returns a 500 Internal Server Error response', () async { 63 | await _scheduleServer((final _) => throw Error()); 64 | final response = await _get(); 65 | expect(response.statusCode, 500); 66 | expect(response.body, 'Internal Server Error'); 67 | }); 68 | }); 69 | } 70 | 71 | RelicServer? _server; 72 | 73 | Future _scheduleServer(final Handler handler) async { 74 | assert(_server == null); 75 | _server = await testServe(handler); 76 | } 77 | 78 | Future _get({ 79 | final Map? headers, 80 | final String path = '', 81 | }) async { 82 | final request = http.Request( 83 | RequestMethod.get.value, 84 | _server!.url.replace(path: path), 85 | ); 86 | 87 | if (headers != null) request.headers.addAll(headers); 88 | 89 | final response = await request.send(); 90 | return await http.Response.fromStream(response) 91 | .timeout(const Duration(seconds: 1)); 92 | } 93 | -------------------------------------------------------------------------------- /test/handler/pipeline_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../util/test_util.dart'; 5 | 6 | void main() { 7 | var accessLocation = 0; 8 | 9 | setUp(() { 10 | accessLocation = 0; 11 | }); 12 | 13 | Handler middlewareA(final Handler innerHandler) => (final request) { 14 | expect(accessLocation, 0); 15 | accessLocation = 1; 16 | final response = innerHandler(request); 17 | expect(accessLocation, 4); 18 | accessLocation = 5; 19 | return response; 20 | }; 21 | 22 | Handler middlewareB(final Handler innerHandler) => (final request) { 23 | expect(accessLocation, 1); 24 | accessLocation = 2; 25 | final response = innerHandler(request); 26 | expect(accessLocation, 3); 27 | accessLocation = 4; 28 | return response; 29 | }; 30 | 31 | HandledContext innerHandler(final NewContext request) { 32 | expect(accessLocation, 2); 33 | accessLocation = 3; 34 | return syncHandler(request); 35 | } 36 | 37 | test( 38 | 'Given a pipeline with middlewareA and middlewareB when a request is processed then it completes with accessLocation 5', 39 | () async { 40 | final handler = const Pipeline() 41 | .addMiddleware(middlewareA) 42 | .addMiddleware(middlewareB) 43 | .addHandler(innerHandler); 44 | 45 | final response = await makeSimpleRequest(handler); 46 | expect(response, isNotNull); 47 | expect(accessLocation, 5); 48 | }); 49 | 50 | test( 51 | 'Given middlewareA and middlewareB when composed using extensions then a request completes with accessLocation 5', 52 | () async { 53 | final handler = 54 | middlewareA.addMiddleware(middlewareB).addHandler(innerHandler); 55 | 56 | final response = await makeSimpleRequest(handler); 57 | expect(response, isNotNull); 58 | expect(accessLocation, 5); 59 | }); 60 | 61 | test( 62 | 'Given a pipeline used as middleware when a request is processed then it completes with accessLocation 5', 63 | () async { 64 | final innerPipeline = 65 | const Pipeline().addMiddleware(middlewareA).addMiddleware(middlewareB); 66 | 67 | final handler = const Pipeline() 68 | .addMiddleware(innerPipeline.middleware) 69 | .addHandler(innerHandler); 70 | 71 | final response = await makeSimpleRequest(handler); 72 | expect(response, isNotNull); 73 | expect(accessLocation, 5); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/headers/basic/server_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group( 11 | 'Given a Server header with the strict flag true', 12 | () { 13 | late RelicServer server; 14 | 15 | setUp(() async { 16 | server = await createServer(strictHeaders: true); 17 | }); 18 | 19 | tearDown(() => server.close()); 20 | 21 | test( 22 | 'when an empty Server header is passed then the server responds ' 23 | 'with a bad request including a message that states the header value ' 24 | 'cannot be empty', 25 | () async { 26 | expect( 27 | getServerRequestHeaders( 28 | server: server, 29 | headers: {'server': ''}, 30 | touchHeaders: (final h) => h.server, 31 | ), 32 | throwsA( 33 | isA().having( 34 | (final e) => e.message, 35 | 'message', 36 | contains('Value cannot be empty'), 37 | ), 38 | ), 39 | ); 40 | }, 41 | ); 42 | 43 | test( 44 | 'when a Server header with an empty value is passed ' 45 | 'then the server does not respond with a bad request if the headers ' 46 | 'is not actually used', 47 | () async { 48 | final headers = await getServerRequestHeaders( 49 | server: server, 50 | touchHeaders: (final _) {}, 51 | headers: {'server': ''}, 52 | ); 53 | 54 | expect(headers, isNotNull); 55 | }, 56 | ); 57 | 58 | test( 59 | 'when a valid Server header is passed then it should parse the server correctly', 60 | () async { 61 | final headers = await getServerRequestHeaders( 62 | server: server, 63 | touchHeaders: (final _) {}, 64 | headers: {'server': 'MyServer/1.0'}, 65 | ); 66 | 67 | expect(headers.server, equals('MyServer/1.0')); 68 | }, 69 | ); 70 | 71 | test( 72 | 'when a Server header with extra whitespace is passed then it should parse the server correctly', 73 | () async { 74 | final headers = await getServerRequestHeaders( 75 | server: server, 76 | headers: {'server': ' MyServer/1.0 '}, 77 | touchHeaders: (final h) => h.server, 78 | ); 79 | 80 | expect(headers.server, equals('MyServer/1.0')); 81 | }, 82 | ); 83 | 84 | test( 85 | 'when no Server header is passed then it should return null', 86 | () async { 87 | final headers = await getServerRequestHeaders( 88 | server: server, 89 | headers: {}, 90 | touchHeaders: (final h) => h.server, 91 | ); 92 | 93 | expect(headers.server, isNull); 94 | }, 95 | ); 96 | }, 97 | ); 98 | 99 | group('Given a Server header with the strict flag false', () { 100 | late RelicServer server; 101 | 102 | setUp(() async { 103 | server = await createServer(strictHeaders: false); 104 | }); 105 | 106 | tearDown(() => server.close()); 107 | 108 | group('when an empty Server header is passed', () { 109 | test( 110 | 'then it should return null', 111 | () async { 112 | final headers = await getServerRequestHeaders( 113 | server: server, 114 | touchHeaders: (final _) {}, 115 | headers: {'server': ''}, 116 | ); 117 | 118 | expect(Headers.server[headers].valueOrNullIfInvalid, isNull); 119 | expect(() => headers.server, throwsInvalidHeader); 120 | }, 121 | ); 122 | }); 123 | }); 124 | } 125 | -------------------------------------------------------------------------------- /test/headers/basic/user_agent_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given a User-Agent header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty User-Agent header is passed then the server responds ' 21 | 'with a bad request including a message that states the header value ' 22 | 'cannot be empty', 23 | () async { 24 | expect( 25 | getServerRequestHeaders( 26 | server: server, 27 | headers: {'user-agent': ''}, 28 | touchHeaders: (final h) => h.userAgent, 29 | ), 30 | throwsA( 31 | isA().having( 32 | (final e) => e.message, 33 | 'message', 34 | contains('Value cannot be empty'), 35 | ), 36 | ), 37 | ); 38 | }, 39 | ); 40 | 41 | test( 42 | 'when a User-Agent header with an empty value is passed ' 43 | 'then the server does not respond with a bad request if the headers ' 44 | 'is not actually used', 45 | () async { 46 | final headers = await getServerRequestHeaders( 47 | server: server, 48 | touchHeaders: (final _) {}, 49 | headers: {'user-agent': ''}, 50 | ); 51 | 52 | expect(headers, isNotNull); 53 | }, 54 | ); 55 | 56 | test( 57 | 'when a User-Agent string is passed then it should parse correctly', 58 | () async { 59 | final headers = await getServerRequestHeaders( 60 | server: server, 61 | headers: {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}, 62 | touchHeaders: (final h) => h.userAgent, 63 | ); 64 | 65 | expect( 66 | headers.userAgent, 67 | equals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), 68 | ); 69 | }, 70 | ); 71 | 72 | test( 73 | 'when no User-Agent header is passed then it should default to a non-null value', 74 | () async { 75 | final headers = await getServerRequestHeaders( 76 | server: server, 77 | headers: {}, 78 | touchHeaders: (final h) => h.userAgent, 79 | ); 80 | 81 | expect(headers.userAgent, isNotNull); 82 | }, 83 | ); 84 | }); 85 | 86 | group('Given a User-Agent header with the strict flag false', () { 87 | late RelicServer server; 88 | 89 | setUp(() async { 90 | server = await createServer(strictHeaders: false); 91 | }); 92 | 93 | tearDown(() => server.close()); 94 | 95 | group('when an invalid User-Agent header is passed', () { 96 | test( 97 | 'then it should return null', 98 | () async { 99 | final headers = await getServerRequestHeaders( 100 | server: server, 101 | touchHeaders: (final _) {}, 102 | headers: {'user-agent': ''}, 103 | ); 104 | 105 | expect(Headers.userAgent[headers].valueOrNullIfInvalid, isNull); 106 | expect(() => headers.userAgent, throwsInvalidHeader); 107 | }, 108 | ); 109 | }); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /test/headers/basic/x_powered_by_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// About empty value test, check the [StrictValidationDocs] class for more details. 8 | void main() { 9 | group( 10 | 'Given an X-Powered-By header with the strict flag true', 11 | () { 12 | late RelicServer server; 13 | 14 | setUp(() async { 15 | server = await createServer(strictHeaders: true); 16 | }); 17 | 18 | tearDown(() => server.close()); 19 | 20 | test( 21 | 'when an empty X-Powered-By header is passed then the server responds ' 22 | 'with a bad request including a message that states the header value ' 23 | 'cannot be empty', () async { 24 | expect( 25 | getServerRequestHeaders( 26 | server: server, 27 | headers: {'x-powered-by': ''}, 28 | touchHeaders: (final h) => h.xPoweredBy), 29 | throwsA( 30 | isA().having( 31 | (final e) => e.message, 32 | 'message', 33 | contains('Value cannot be empty'), 34 | ), 35 | ), 36 | ); 37 | }); 38 | 39 | test( 40 | 'when a valid X-Powered-By value is passed then it should parse correctly', 41 | () async { 42 | final headers = await getServerRequestHeaders( 43 | server: server, 44 | headers: {'x-powered-by': 'Express'}, 45 | touchHeaders: (final h) => h.xPoweredBy, 46 | ); 47 | 48 | expect(headers.xPoweredBy, equals('Express')); 49 | }, 50 | ); 51 | }, 52 | ); 53 | 54 | group( 55 | 'Given an X-Powered-By header with the strict flag false', 56 | skip: 'x-powered-by is a response header (stripped on get request)', 57 | () { 58 | late RelicServer server; 59 | 60 | setUp(() async { 61 | server = await createServer(strictHeaders: false); 62 | }); 63 | 64 | tearDown(() => server.close()); 65 | }, 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /test/headers/docs/strict_validation_docs.dart: -------------------------------------------------------------------------------- 1 | /// ## Strict Header Validation 2 | /// 3 | /// This code will throw an exception when the header is empty and the strict flag is set to true. 4 | /// In many servers, an empty header is considered as null, which can lead to unexpected behavior. 5 | /// By enforcing strict validation, we ensure that any empty headers are caught and handled appropriately, 6 | /// preventing potential issues in the server's request handling process. 7 | class StrictValidationDocs { 8 | const StrictValidationDocs(); 9 | } 10 | -------------------------------------------------------------------------------- /test/headers/headers_test_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:http/http.dart' as http; 5 | import 'package:relic/relic.dart'; 6 | import 'package:relic/src/adapter/io/bind_http_server.dart'; 7 | import 'package:relic/src/adapter/io/io_adapter.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | /// Thrown when the server returns a 400 status code. 11 | class BadRequestException implements Exception { 12 | final String message; 13 | 14 | BadRequestException( 15 | this.message, 16 | ); 17 | } 18 | 19 | /// Extension methods for RelicServer 20 | extension RelicServerTestEx on RelicServer { 21 | static final Expando _serverUrls = Expando(); 22 | 23 | /// Fake [url] property for the [RelicServer] for testing purposes. 24 | Uri get url => _serverUrls[this] ??= _inferUrl(); 25 | set url(final Uri value) => _serverUrls[this] = value; 26 | 27 | /// Infer a probable URL for the server. 28 | /// 29 | /// In general a server cannot know what URL it is being accessed by before an 30 | /// actual request arrives, but for testing purposes we can infer a URL based 31 | /// on the server's address. 32 | Uri _inferUrl() { 33 | final adapter = this.adapter; 34 | if (adapter is! IOAdapter) throw ArgumentError(); 35 | 36 | if (adapter.address.isLoopback) { 37 | return Uri(scheme: 'http', host: 'localhost', port: adapter.port); 38 | } 39 | 40 | if (adapter.address.type == InternetAddressType.IPv6) { 41 | return Uri( 42 | scheme: 'http', 43 | host: '[${adapter.address.address}]', 44 | port: adapter.port, 45 | ); 46 | } 47 | 48 | return Uri( 49 | scheme: 'http', 50 | host: adapter.address.address, 51 | port: adapter.port, 52 | ); 53 | } 54 | } 55 | 56 | /// Creates a [RelicServer] that listens on the loopback IPv4 address. 57 | Future createServer({required final bool strictHeaders}) async { 58 | final adapter = IOAdapter(await bindHttpServer(InternetAddress.loopbackIPv4)); 59 | return RelicServer(adapter, strictHeaders: strictHeaders); 60 | } 61 | 62 | /// Returns the headers from the server request if the server returns a 200 63 | /// status code. Otherwise, throws an exception. 64 | Future getServerRequestHeaders({ 65 | required final RelicServer server, 66 | required final Map headers, 67 | required final void Function(Headers) touchHeaders, 68 | }) async { 69 | var requestHeaders = Headers.empty(); 70 | 71 | await server.mountAndStart( 72 | respondWith((final Request request) { 73 | requestHeaders = request.headers; 74 | touchHeaders(requestHeaders); 75 | return Response.ok(); 76 | }), 77 | ); 78 | 79 | final response = await http.get(server.url, headers: headers); 80 | 81 | final statusCode = response.statusCode; 82 | 83 | if (statusCode == 400) { 84 | throw BadRequestException( 85 | response.body, 86 | ); 87 | } 88 | 89 | if (statusCode != 200) { 90 | throw StateError( 91 | 'Unexpected response from server: Status:${response.statusCode}: Response: ${response.body}', 92 | ); 93 | } 94 | 95 | return requestHeaders; 96 | } 97 | 98 | Matcher throwsInvalidHeader = throwsA(isA()); 99 | Matcher throwsMissingHeader = throwsA(isA()); 100 | -------------------------------------------------------------------------------- /test/headers/mutable_headers_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/src/headers/headers.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Given a mutable headers collection', () { 6 | late MutableHeaders mutable; 7 | setUp(() { 8 | mutable = MutableHeaders(); 9 | mutable['1'] = ['a']; 10 | mutable['2'] = ['b']; 11 | mutable['3'] = ['c']; 12 | }); 13 | test( 14 | 'when assigning null to a key ' 15 | 'then the value disappear', () { 16 | expect(mutable['1'], isNotNull); 17 | expect(mutable..remove('1'), { 18 | '2': ['b'], 19 | '3': ['c'] 20 | }); 21 | }); 22 | test( 23 | 'when removing a key ' 24 | 'then the value is removed', () { 25 | expect(() => mutable['2'] = null, returnsNormally); 26 | expect(mutable, { 27 | '1': ['a'], 28 | '3': ['c'] 29 | }); 30 | }); 31 | test( 32 | 'when accessing keys ' 33 | 'then all keys are returned', () { 34 | expect(mutable.keys, ['1', '2', '3']); 35 | }); 36 | test( 37 | 'when clearing ' 38 | 'then all values is removed', () { 39 | expect(() => mutable.clear(), returnsNormally); 40 | expect(mutable, MutableHeaders()); 41 | }); 42 | }); 43 | 44 | test( 45 | 'When assigning a value during Headers.build ' 46 | 'then its present in the returned headers collection', () { 47 | final headers = Headers.build((final mh) { 48 | mh['foo'] = ['bar']; 49 | }); 50 | expect(headers['foo'], ['bar']); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /test/headers/typed_headers/accept_ranges_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given an Accept-Ranges header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty Accept-Ranges header is passed then the server should respond with a bad request ' 21 | 'including a message that states the value cannot be empty', 22 | () async { 23 | expect( 24 | getServerRequestHeaders( 25 | server: server, 26 | touchHeaders: (final h) => h.acceptRanges, 27 | headers: {'accept-ranges': ''}, 28 | ), 29 | throwsA(isA().having( 30 | (final e) => e.message, 31 | 'message', 32 | contains('Value cannot be empty'), 33 | )), 34 | ); 35 | }, 36 | ); 37 | 38 | test( 39 | 'when an Accept-Ranges header with an empty value is passed ' 40 | 'then the server does not respond with a bad request if the headers ' 41 | 'is not actually used', 42 | () async { 43 | final headers = await getServerRequestHeaders( 44 | server: server, 45 | touchHeaders: (final _) {}, 46 | headers: {'accept-ranges': ''}, 47 | ); 48 | 49 | expect(headers, isNotNull); 50 | }, 51 | ); 52 | 53 | test( 54 | 'when a valid Accept-Ranges header is passed then it should parse the range unit correctly', 55 | () async { 56 | final headers = await getServerRequestHeaders( 57 | server: server, 58 | touchHeaders: (final h) => h.acceptRanges, 59 | headers: {'accept-ranges': 'bytes'}, 60 | ); 61 | 62 | expect(headers.acceptRanges?.rangeUnit, equals('bytes')); 63 | expect(headers.acceptRanges?.isBytes, isTrue); 64 | }, 65 | ); 66 | 67 | test( 68 | 'when a Accept-Ranges header with "none" is passed then it should parse correctly', 69 | () async { 70 | final headers = await getServerRequestHeaders( 71 | server: server, 72 | touchHeaders: (final h) => h.acceptRanges, 73 | headers: {'accept-ranges': 'none'}, 74 | ); 75 | 76 | expect(headers.acceptRanges?.rangeUnit, equals('none')); 77 | expect(headers.acceptRanges?.isNone, isTrue); 78 | }, 79 | ); 80 | 81 | test( 82 | 'when no Accept-Ranges header is passed then it should return null', 83 | () async { 84 | final headers = await getServerRequestHeaders( 85 | server: server, 86 | touchHeaders: (final h) => h.acceptRanges, 87 | headers: {}, 88 | ); 89 | 90 | expect(headers.acceptRanges, isNull); 91 | }, 92 | ); 93 | }); 94 | 95 | group('Given an Accept-Ranges header with the strict flag false', () { 96 | late RelicServer server; 97 | 98 | setUp(() async { 99 | server = await createServer(strictHeaders: false); 100 | }); 101 | 102 | tearDown(() => server.close()); 103 | 104 | group('when an empty Accept-Ranges header is passed', () { 105 | test( 106 | 'then it should return null', 107 | () async { 108 | final headers = await getServerRequestHeaders( 109 | server: server, 110 | touchHeaders: (final _) {}, 111 | headers: {'accept-ranges': ''}, 112 | ); 113 | 114 | expect(Headers.acceptRanges[headers].valueOrNullIfInvalid, isNull); 115 | expect(() => headers.acceptRanges, throwsInvalidHeader); 116 | }, 117 | ); 118 | }); 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /test/headers/typed_headers/expect_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given an Expect header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty Expect header is passed then the server responds ' 21 | 'with a bad request including a message that states the header value ' 22 | 'cannot be empty', 23 | () async { 24 | expect( 25 | getServerRequestHeaders( 26 | server: server, 27 | touchHeaders: (final h) => h.expect, 28 | headers: {'expect': ''}, 29 | ), 30 | throwsA( 31 | isA().having( 32 | (final e) => e.message, 33 | 'message', 34 | contains('Value cannot be empty'), 35 | ), 36 | ), 37 | ); 38 | }, 39 | ); 40 | 41 | test( 42 | 'when an invalid Expect header is passed then the server should respond with a bad request ' 43 | 'including a message that states the value is invalid', 44 | () async { 45 | expect( 46 | getServerRequestHeaders( 47 | server: server, 48 | touchHeaders: (final h) => h.expect, 49 | headers: {'expect': 'custom-directive'}, 50 | ), 51 | throwsA(isA().having( 52 | (final e) => e.message, 53 | 'message', 54 | contains('Invalid value'), 55 | )), 56 | ); 57 | }, 58 | ); 59 | 60 | test( 61 | 'when an Expect header with an invalid value is passed ' 62 | 'then the server does not respond with a bad request if the headers ' 63 | 'is not actually used', 64 | () async { 65 | final headers = await getServerRequestHeaders( 66 | server: server, 67 | touchHeaders: (final _) {}, 68 | headers: {'expect': 'custom-directive'}, 69 | ); 70 | 71 | expect(headers, isNotNull); 72 | }, 73 | ); 74 | 75 | test( 76 | 'when a valid Expect header is passed then it should parse the directives correctly', 77 | () async { 78 | final headers = await getServerRequestHeaders( 79 | server: server, 80 | touchHeaders: (final h) => h.expect, 81 | headers: {'expect': '100-continue'}, 82 | ); 83 | 84 | expect( 85 | headers.expect?.value, 86 | contains('100-continue'), 87 | ); 88 | }, 89 | ); 90 | }); 91 | 92 | group('Given an Expect header with the strict flag false', () { 93 | late RelicServer server; 94 | 95 | setUp(() async { 96 | server = await createServer(strictHeaders: false); 97 | }); 98 | 99 | tearDown(() => server.close()); 100 | 101 | group('when an empty Expect header is passed', () { 102 | test( 103 | 'then it should return null', 104 | () async { 105 | final headers = await getServerRequestHeaders( 106 | server: server, 107 | touchHeaders: (final _) {}, 108 | headers: {'expect': ''}, 109 | ); 110 | 111 | expect(Headers.expect[headers].valueOrNullIfInvalid, isNull); 112 | expect(() => headers.expect, throwsInvalidHeader); 113 | }, 114 | ); 115 | }); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /test/headers/typed_headers/sec_fetch_dest_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given a Sec-Fetch-Dest header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty Sec-Fetch-Dest header is passed then the server should respond with a bad request ' 21 | 'including a message that states the value cannot be empty', 22 | () async { 23 | expect( 24 | getServerRequestHeaders( 25 | server: server, 26 | touchHeaders: (final h) => h.secFetchDest, 27 | headers: {'sec-fetch-dest': ''}, 28 | ), 29 | throwsA(isA().having( 30 | (final e) => e.message, 31 | 'message', 32 | contains('Value cannot be empty'), 33 | )), 34 | ); 35 | }, 36 | ); 37 | 38 | test( 39 | 'when an invalid Sec-Fetch-Dest header is passed then the server should respond with a bad request ' 40 | 'including a message that states the value is invalid', 41 | () async { 42 | expect( 43 | getServerRequestHeaders( 44 | server: server, 45 | touchHeaders: (final h) => h.secFetchDest, 46 | headers: {'sec-fetch-dest': 'custom-destination'}, 47 | ), 48 | throwsA(isA().having( 49 | (final e) => e.message, 50 | 'message', 51 | contains('Invalid value'), 52 | )), 53 | ); 54 | }, 55 | ); 56 | 57 | test( 58 | 'when a Sec-Fetch-Dest header with an invalid value is passed ' 59 | 'then the server does not respond with a bad request if the headers ' 60 | 'is not actually used', 61 | () async { 62 | final headers = await getServerRequestHeaders( 63 | server: server, 64 | touchHeaders: (final _) {}, 65 | headers: {'sec-fetch-dest': 'custom-destination'}, 66 | ); 67 | expect(headers, isNotNull); 68 | }, 69 | ); 70 | 71 | test( 72 | 'when a valid Sec-Fetch-Dest header is passed then it should parse the destination correctly', 73 | () async { 74 | final headers = await getServerRequestHeaders( 75 | server: server, 76 | touchHeaders: (final h) => h.secFetchDest, 77 | headers: {'sec-fetch-dest': 'document'}, 78 | ); 79 | 80 | expect(headers.secFetchDest?.destination, equals('document')); 81 | }, 82 | ); 83 | 84 | test( 85 | 'when no Sec-Fetch-Dest header is passed then it should return null', 86 | () async { 87 | final headers = await getServerRequestHeaders( 88 | server: server, 89 | touchHeaders: (final h) => h.secFetchDest, 90 | headers: {}, 91 | ); 92 | 93 | expect(headers.secFetchDest, isNull); 94 | }, 95 | ); 96 | }); 97 | 98 | group('Given a Sec-Fetch-Dest header with the strict flag false', () { 99 | late RelicServer server; 100 | 101 | setUp(() async { 102 | server = await createServer(strictHeaders: false); 103 | }); 104 | 105 | tearDown(() => server.close()); 106 | 107 | group('When an empty Sec-Fetch-Dest header is passed', () { 108 | test( 109 | 'then it should return null', 110 | () async { 111 | final headers = await getServerRequestHeaders( 112 | server: server, 113 | touchHeaders: (final _) {}, 114 | headers: {}, 115 | ); 116 | 117 | expect(headers.secFetchDest, isNull); 118 | }, 119 | ); 120 | }); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /test/headers/typed_headers/sec_fetch_mode_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given a Sec-Fetch-Mode header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty Sec-Fetch-Mode header is passed then the server should respond with a bad request ' 21 | 'including a message that states the value cannot be empty', 22 | () async { 23 | expect( 24 | getServerRequestHeaders( 25 | server: server, 26 | touchHeaders: (final h) => h.secFetchMode, 27 | headers: {'sec-fetch-mode': ''}, 28 | ), 29 | throwsA(isA().having( 30 | (final e) => e.message, 31 | 'message', 32 | contains('Value cannot be empty'), 33 | )), 34 | ); 35 | }, 36 | ); 37 | 38 | test( 39 | 'when an invalid Sec-Fetch-Mode header is passed then the server should respond with a bad request ' 40 | 'including a message that states the value is invalid', 41 | () async { 42 | expect( 43 | getServerRequestHeaders( 44 | server: server, 45 | touchHeaders: (final h) => h.secFetchMode, 46 | headers: {'sec-fetch-mode': 'custom-mode'}, 47 | ), 48 | throwsA(isA().having( 49 | (final e) => e.message, 50 | 'message', 51 | contains('Invalid value'), 52 | )), 53 | ); 54 | }, 55 | ); 56 | 57 | test( 58 | 'when a Sec-Fetch-Mode header with an invalid value is passed ' 59 | 'then the server does not respond with a bad request if the headers ' 60 | 'is not actually used', 61 | () async { 62 | final headers = await getServerRequestHeaders( 63 | server: server, 64 | touchHeaders: (final _) {}, 65 | headers: {'sec-fetch-mode': 'custom-mode'}, 66 | ); 67 | expect(headers, isNotNull); 68 | }, 69 | ); 70 | 71 | test( 72 | 'when a valid Sec-Fetch-Mode header is passed then it should parse the mode correctly', 73 | () async { 74 | final headers = await getServerRequestHeaders( 75 | server: server, 76 | touchHeaders: (final h) => h.secFetchMode, 77 | headers: {'sec-fetch-mode': 'cors'}, 78 | ); 79 | 80 | expect(headers.secFetchMode?.mode, equals('cors')); 81 | }, 82 | ); 83 | 84 | test( 85 | 'when no Sec-Fetch-Mode header is passed then it should return null', 86 | () async { 87 | final headers = await getServerRequestHeaders( 88 | server: server, 89 | touchHeaders: (final h) => h.secFetchMode, 90 | headers: {}, 91 | ); 92 | 93 | expect(headers.secFetchMode, isNull); 94 | }, 95 | ); 96 | }); 97 | 98 | group('Given a Sec-Fetch-Mode header with the strict flag false', () { 99 | late RelicServer server; 100 | 101 | setUp(() async { 102 | server = await createServer(strictHeaders: false); 103 | }); 104 | 105 | tearDown(() => server.close()); 106 | 107 | group('When an empty Sec-Fetch-Mode header is passed', () { 108 | test( 109 | 'then it should return null', 110 | () async { 111 | final headers = await getServerRequestHeaders( 112 | server: server, 113 | touchHeaders: (final _) {}, 114 | headers: {}, 115 | ); 116 | 117 | expect(headers.secFetchMode, isNull); 118 | }, 119 | ); 120 | }); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /test/headers/typed_headers/sec_fetch_site_header_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import '../docs/strict_validation_docs.dart'; 5 | import '../headers_test_utils.dart'; 6 | 7 | /// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site 8 | /// About empty value test, check the [StrictValidationDocs] class for more details. 9 | void main() { 10 | group('Given a Sec-Fetch-Site header with the strict flag true', () { 11 | late RelicServer server; 12 | 13 | setUp(() async { 14 | server = await createServer(strictHeaders: true); 15 | }); 16 | 17 | tearDown(() => server.close()); 18 | 19 | test( 20 | 'when an empty Sec-Fetch-Site header is passed then the server should respond with a bad request ' 21 | 'including a message that states the value cannot be empty', 22 | () async { 23 | expect( 24 | getServerRequestHeaders( 25 | server: server, 26 | touchHeaders: (final h) => h.secFetchSite, 27 | headers: {'sec-fetch-site': ''}, 28 | ), 29 | throwsA(isA().having( 30 | (final e) => e.message, 31 | 'message', 32 | contains('Value cannot be empty'), 33 | )), 34 | ); 35 | }, 36 | ); 37 | 38 | test( 39 | 'when an invalid Sec-Fetch-Site header is passed then the server should respond with a bad request ' 40 | 'including a message that states the value is invalid', 41 | () async { 42 | expect( 43 | getServerRequestHeaders( 44 | server: server, 45 | touchHeaders: (final h) => h.secFetchSite, 46 | headers: {'sec-fetch-site': 'custom-site'}, 47 | ), 48 | throwsA(isA().having( 49 | (final e) => e.message, 50 | 'message', 51 | contains('Invalid value'), 52 | )), 53 | ); 54 | }, 55 | ); 56 | 57 | test( 58 | 'when a Sec-Fetch-Site header with an invalid value is passed ' 59 | 'then the server does not respond with a bad request if the headers ' 60 | 'is not actually used', 61 | () async { 62 | final headers = await getServerRequestHeaders( 63 | server: server, 64 | touchHeaders: (final _) {}, 65 | headers: {'sec-fetch-site': 'custom-site'}, 66 | ); 67 | expect(headers, isNotNull); 68 | }, 69 | ); 70 | 71 | test( 72 | 'when a valid Sec-Fetch-Site header is passed then it should parse the site correctly', 73 | () async { 74 | final headers = await getServerRequestHeaders( 75 | server: server, 76 | touchHeaders: (final h) => h.secFetchSite, 77 | headers: {'sec-fetch-site': 'same-origin'}, 78 | ); 79 | 80 | expect(headers.secFetchSite?.site, equals('same-origin')); 81 | }, 82 | ); 83 | 84 | test( 85 | 'when no Sec-Fetch-Site header is passed then it should return null', 86 | () async { 87 | final headers = await getServerRequestHeaders( 88 | server: server, 89 | touchHeaders: (final h) => h.secFetchSite, 90 | headers: {}, 91 | ); 92 | 93 | expect(headers.secFetchSite, isNull); 94 | }, 95 | ); 96 | }); 97 | 98 | group('Given a Sec-Fetch-Site header with the strict flag false', () { 99 | late RelicServer server; 100 | 101 | setUp(() async { 102 | server = await createServer(strictHeaders: false); 103 | }); 104 | 105 | tearDown(() => server.close()); 106 | 107 | group('When an empty Sec-Fetch-Site header is passed', () { 108 | test( 109 | 'then it should return null', 110 | () async { 111 | final headers = await getServerRequestHeaders( 112 | server: server, 113 | touchHeaders: (final _) {}, 114 | headers: {}, 115 | ); 116 | 117 | expect(headers.secFetchSite, isNull); 118 | }, 119 | ); 120 | }); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /test/hijack/relic_hijack_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:http/http.dart' as http; 4 | import 'package:relic/relic.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../headers/headers_test_utils.dart'; 8 | import '../util/test_util.dart'; 9 | 10 | void main() { 11 | tearDown(() async { 12 | final server = _server; 13 | if (server != null) { 14 | try { 15 | await server.close().timeout(const Duration(seconds: 5)); 16 | } catch (e) { 17 | await server.close(); 18 | } finally { 19 | _server = null; 20 | } 21 | } 22 | }); 23 | 24 | group('Given a server', () { 25 | test( 26 | 'when request context is hijacked ' 27 | 'then an HijackContext is returned and the request times out because ' 28 | 'server does not write the response to the HTTP response', 29 | () async { 30 | await _scheduleServer( 31 | (final ctx) { 32 | final newCtx = ctx.hijack((final _) {}); 33 | expect(newCtx, isA()); 34 | return newCtx; 35 | }, 36 | ); 37 | expect( 38 | _get(), 39 | throwsA(isA()), 40 | ); 41 | }, 42 | ); 43 | }); 44 | } 45 | 46 | RelicServer? _server; 47 | 48 | Future _scheduleServer(final Handler handler) async { 49 | assert(_server == null); 50 | _server = await testServe(handler); 51 | } 52 | 53 | Future _get({ 54 | final Map? headers, 55 | final String path = '', 56 | }) async { 57 | final request = http.Request( 58 | RequestMethod.get.value, 59 | _server!.url.replace(path: path), 60 | ); 61 | 62 | if (headers != null) request.headers.addAll(headers); 63 | 64 | final response = await request.send().timeout(const Duration(seconds: 1)); 65 | return await http.Response.fromStream(response) 66 | .timeout(const Duration(seconds: 1)); 67 | } 68 | -------------------------------------------------------------------------------- /test/middleware/log_middleware_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:relic/relic.dart'; 2 | import 'package:relic/src/logger/logger.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../util/test_util.dart'; 6 | 7 | void main() { 8 | late bool gotLog; 9 | 10 | setUp(() { 11 | gotLog = false; 12 | }); 13 | 14 | void logger( 15 | final String msg, { 16 | final LoggerType type = LoggerType.info, 17 | final StackTrace? stackTrace, 18 | }) { 19 | expect(gotLog, isFalse); 20 | gotLog = true; 21 | expect(type, LoggerType.info); 22 | expect(msg, contains(RequestMethod.get.value)); 23 | expect(msg, contains('[200]')); 24 | } 25 | 26 | test( 27 | 'Given a request with a synchronous response when logged then it logs the request', 28 | () async { 29 | final handler = const Pipeline() 30 | .addMiddleware(logRequests(logger: logger)) 31 | .addHandler(syncHandler); 32 | 33 | await makeSimpleRequest(handler); 34 | expect(gotLog, isTrue); 35 | }); 36 | 37 | test( 38 | 'Given a request with an asynchronous response when logged then it logs the request', 39 | () async { 40 | final handler = const Pipeline() 41 | .addMiddleware(logRequests(logger: logger)) 42 | .addHandler(asyncHandler); 43 | 44 | await makeSimpleRequest(handler); 45 | expect(gotLog, isTrue); 46 | }); 47 | 48 | test( 49 | 'Given a request with an asynchronous error response when logged then it logs the error', 50 | () { 51 | final handler = const Pipeline().addMiddleware(logRequests( 52 | logger: ( 53 | final msg, { 54 | final LoggerType type = LoggerType.info, 55 | final StackTrace? stackTrace, 56 | }) { 57 | expect(gotLog, isFalse); 58 | gotLog = true; 59 | expect(type, LoggerType.error); 60 | expect(msg, contains('oh no')); 61 | }, 62 | )).addHandler( 63 | (final request) { 64 | throw StateError('oh no'); 65 | }, 66 | ); 67 | 68 | expect(makeSimpleRequest(handler), throwsA(isOhNoStateError)); 69 | }, 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /test/relic_server_test.dart: -------------------------------------------------------------------------------- 1 | @Timeout.none 2 | library; 3 | 4 | import 'dart:async'; 5 | 6 | import 'package:http/http.dart' as http; 7 | import 'package:relic/relic.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | import 'headers/headers_test_utils.dart'; 11 | import 'util/test_util.dart'; 12 | 13 | void main() { 14 | // Use concrete type to ensure extensions are applied 15 | late RelicServer server; 16 | 17 | setUp(() async { 18 | server = await createServer(strictHeaders: false); 19 | }); 20 | 21 | tearDown(() => server.close()); 22 | 23 | group('Given a server', () { 24 | test( 25 | 'when a valid HTTP request is made ' 26 | 'then it serves the request using the mounted handler', () async { 27 | await server.mountAndStart(syncHandler); 28 | // Use toUri to ensure we have a valid Uri object 29 | final response = await http.read(server.url); 30 | expect(response, equals('Hello from /')); 31 | }); 32 | 33 | test( 34 | 'when a malformed HTTP request is made ' 35 | 'then it returns a 400 Bad Request response', () async { 36 | await server.mountAndStart(syncHandler); 37 | final rs = await http 38 | .get(Uri.parse('${server.url}/%D0%C2%BD%A8%CE%C4%BC%FE%BC%D0.zip')); 39 | expect(rs.statusCode, 400); 40 | expect(rs.body, 'Bad Request'); 41 | }); 42 | 43 | test( 44 | 'when no handler is mounted initially ' 45 | 'then it delays requests until a handler is mounted', () async { 46 | final delayedResponse = http.read(server.url); 47 | await Future.delayed(Duration.zero); 48 | await server.mountAndStart(asyncHandler); 49 | expect(delayedResponse, completion(equals('Hello from /'))); 50 | }); 51 | 52 | test( 53 | 'when a handler is already mounted ' 54 | 'then mounting another handler throws a StateError', () async { 55 | await server.mountAndStart((final _) => throw UnimplementedError()); 56 | expect( 57 | () => server.mountAndStart((final _) => throw UnimplementedError()), 58 | throwsStateError, 59 | ); 60 | expect( 61 | () => server.mountAndStart((final _) => throw UnimplementedError()), 62 | throwsStateError, 63 | ); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /test/static/alternative_root_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:relic/relic.dart'; 4 | import 'package:relic/src/io/static/static_handler.dart'; 5 | import 'package:test/test.dart'; 6 | import 'package:test_descriptor/test_descriptor.dart' as d; 7 | 8 | import 'test_util.dart'; 9 | 10 | void main() { 11 | setUp(() async { 12 | await d.file('root.txt', 'root txt').create(); 13 | await d.dir('files', [ 14 | d.file('test.txt', 'test txt content'), 15 | d.file('with space.txt', 'with space content') 16 | ]).create(); 17 | }); 18 | 19 | test('Given a root file when accessed then it returns the file content', 20 | () async { 21 | final handler = createStaticHandler(d.sandbox); 22 | 23 | final response = await makeRequest( 24 | handler, 25 | '/static/root.txt', 26 | handlerPath: 'static', 27 | ); 28 | expect(response.statusCode, HttpStatus.ok); 29 | expect(response.body.contentLength, 8); 30 | expect(response.readAsString(), completion('root txt')); 31 | }); 32 | 33 | test( 34 | 'Given a root file with space when accessed then it returns the file content', 35 | () async { 36 | final handler = createStaticHandler(d.sandbox); 37 | 38 | final response = await makeRequest( 39 | handler, '/static/files/with%20space.txt', 40 | handlerPath: 'static'); 41 | expect(response.statusCode, HttpStatus.ok); 42 | expect(response.body.contentLength, 18); 43 | expect(response.readAsString(), completion('with space content')); 44 | }); 45 | 46 | test( 47 | 'Given a root file with unencoded space when accessed then it returns the file content', 48 | () async { 49 | final handler = createStaticHandler(d.sandbox); 50 | 51 | final response = await makeRequest( 52 | handler, '/static/files/with%20space.txt', 53 | handlerPath: 'static'); 54 | expect(response.statusCode, HttpStatus.ok); 55 | expect(response.body.contentLength, 18); 56 | expect(response.readAsString(), completion('with space content')); 57 | }); 58 | 59 | test( 60 | 'Given a file under directory when accessed then it returns the file content', 61 | () async { 62 | final handler = createStaticHandler(d.sandbox); 63 | 64 | final response = await makeRequest(handler, '/static/files/test.txt', 65 | handlerPath: 'static'); 66 | expect(response.statusCode, HttpStatus.ok); 67 | expect(response.body.contentLength, 16); 68 | expect(response.readAsString(), completion('test txt content')); 69 | }); 70 | 71 | test('Given a non-existent file when accessed then it returns a 404 status', 72 | () async { 73 | final handler = createStaticHandler(d.sandbox); 74 | 75 | final response = await makeRequest(handler, '/static/not_here.txt', 76 | handlerPath: 'static'); 77 | expect(response.statusCode, HttpStatus.notFound); 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /test/static/directory_listing_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:relic/relic.dart'; 4 | import 'package:test/test.dart'; 5 | import 'package:test_descriptor/test_descriptor.dart' as d; 6 | 7 | import 'test_util.dart'; 8 | 9 | void main() { 10 | setUp(() async { 11 | await d.file('index.html', '').create(); 12 | await d.file('root.txt', 'root txt').create(); 13 | await d.dir('files', [ 14 | d.file('index.html', 'files'), 15 | d.file('with space.txt', 'with space content'), 16 | d.dir('empty subfolder', []), 17 | ]).create(); 18 | }); 19 | 20 | test( 21 | 'Given directory listing is enabled when accessing "/" then it lists the directory contents', 22 | () async { 23 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 24 | 25 | final response = await makeRequest(handler, '/'); 26 | expect(response.statusCode, HttpStatus.ok); 27 | expect(response.readAsString(), completes); 28 | }); 29 | 30 | test( 31 | 'Given directory listing is enabled when accessing "/files" then it redirects to "/files/"', 32 | () async { 33 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 34 | 35 | final response = await makeRequest(handler, '/files'); 36 | expect(response.statusCode, HttpStatus.movedPermanently); 37 | expect( 38 | response.headers.location, 39 | Uri.parse('http://localhost/files/'), 40 | ); 41 | }); 42 | 43 | test( 44 | 'Given directory listing is enabled when accessing "/files/" then it lists the directory contents', 45 | () async { 46 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 47 | 48 | final response = await makeRequest(handler, '/files/'); 49 | expect(response.statusCode, HttpStatus.ok); 50 | expect(response.readAsString(), completes); 51 | }); 52 | 53 | test( 54 | 'Given directory listing is enabled when accessing "/files/empty subfolder" then it redirects to "/files/empty subfolder/"', 55 | () async { 56 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 57 | 58 | final response = await makeRequest(handler, '/files/empty subfolder'); 59 | expect(response.statusCode, HttpStatus.movedPermanently); 60 | expect( 61 | response.headers.location, 62 | Uri.parse('http://localhost/files/empty%20subfolder/'), 63 | ); 64 | }); 65 | 66 | test( 67 | 'Given directory listing is enabled when accessing "/files/empty subfolder/" then it lists the directory contents', 68 | () async { 69 | final handler = createStaticHandler(d.sandbox, listDirectories: true); 70 | 71 | final response = await makeRequest(handler, '/files/empty subfolder/'); 72 | expect(response.statusCode, HttpStatus.ok); 73 | expect(response.readAsString(), completes); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/static/get_handler_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:path/path.dart' as p; 2 | import 'package:relic/relic.dart'; 3 | import 'package:test/test.dart'; 4 | import 'package:test_descriptor/test_descriptor.dart' as d; 5 | 6 | void main() { 7 | setUp(() async { 8 | await d.file('root.txt', 'root txt').create(); 9 | await d.dir('files', [ 10 | d.file('test.txt', 'test txt content'), 11 | d.file('with space.txt', 'with space content') 12 | ]).create(); 13 | }); 14 | 15 | test( 16 | 'Given a non-existent relative path when creating a static handler then it throws an ArgumentError', 17 | () async { 18 | expect(() => createStaticHandler('random/relative'), throwsArgumentError); 19 | }); 20 | 21 | test( 22 | 'Given an existing relative path when creating a static handler then it returns normally', 23 | () async { 24 | final existingRelative = p.relative(d.sandbox); 25 | expect(() => createStaticHandler(existingRelative), returnsNormally); 26 | }); 27 | 28 | test( 29 | 'Given a non-existent absolute path when creating a static handler then it throws an ArgumentError', 30 | () { 31 | final nonExistingAbsolute = p.join(d.sandbox, 'not_here'); 32 | expect(() => createStaticHandler(nonExistingAbsolute), throwsArgumentError); 33 | }); 34 | 35 | test( 36 | 'Given an existing absolute path when creating a static handler then it returns normally', 37 | () { 38 | expect(() => createStaticHandler(d.sandbox), returnsNormally); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /test/static/test_util.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:path/path.dart' as p; 4 | import 'package:relic/relic.dart'; 5 | import 'package:relic/src/adapter/context.dart'; 6 | import 'package:relic/src/io/static/extension/datetime_extension.dart'; 7 | import 'package:test/test.dart'; 8 | 9 | final p.Context _ctx = p.url; 10 | 11 | /// Makes a simple GET request to [handler] and returns the result. 12 | Future makeRequest( 13 | final Handler handler, 14 | final String path, { 15 | final String? handlerPath, 16 | final Headers? headers, 17 | final RequestMethod method = RequestMethod.get, 18 | }) async { 19 | final rootedHandler = _rootHandler(handlerPath, handler); 20 | final request = _fromPath(path, headers, method: method); 21 | final ctx = await rootedHandler(request.toContext(Object())); 22 | if (ctx is! ResponseContext) throw ArgumentError(ctx); 23 | return ctx.response; 24 | } 25 | 26 | Request _fromPath( 27 | final String path, 28 | final Headers? headers, { 29 | required final RequestMethod method, 30 | }) => 31 | Request( 32 | method, 33 | Uri.parse('http://localhost$path'), 34 | headers: headers, 35 | ); 36 | 37 | Handler _rootHandler(final String? path, final Handler handler) { 38 | if (path == null || path.isEmpty) { 39 | return handler; 40 | } 41 | 42 | return (final requestCtx) { 43 | final ctx = requestCtx as RespondableContext; 44 | final request = ctx.request; 45 | if (!_ctx.isWithin('/$path', request.requestedUri.path)) { 46 | return ctx.withResponse(Response.notFound( 47 | body: Body.fromString( 48 | 'not found', 49 | ), 50 | )); 51 | } 52 | assert(request.handlerPath == '/'); 53 | 54 | final relativeRequest = request.copyWith(path: path); 55 | 56 | return handler(relativeRequest.toContext(Object())); 57 | }; 58 | } 59 | 60 | Matcher atSameTimeToSecond(final DateTime value) => 61 | _SecondResolutionDateTimeMatcher(value); 62 | 63 | class _SecondResolutionDateTimeMatcher extends Matcher { 64 | final DateTime _target; 65 | 66 | _SecondResolutionDateTimeMatcher(final DateTime target) 67 | : _target = target.toSecondResolution; 68 | 69 | @override 70 | bool matches(final dynamic item, final Map matchState) { 71 | if (item is! DateTime) return false; 72 | 73 | return _datesEqualToSecond(_target, item); 74 | } 75 | 76 | @override 77 | Description describe(final Description description) => 78 | description.add('Must be at the same moment as $_target with resolution ' 79 | 'to the second.'); 80 | } 81 | 82 | bool _datesEqualToSecond(final DateTime d1, final DateTime d2) => 83 | d1.toSecondResolution.isAtSameMomentAs(d2.toSecondResolution); 84 | --------------------------------------------------------------------------------